Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7186acb9a1 | ||
|
|
668e606ee2 | ||
|
|
da53483164 | ||
|
|
18936cf8b1 | ||
|
|
069d75d2f2 | ||
|
|
6fb79ee2a9 | ||
|
|
77a063d263 | ||
|
|
e21a96640d | ||
|
|
7f1ac694e4 | ||
|
|
33c3831bf2 |
@@ -1,93 +0,0 @@
|
|||||||
TRADING_MODE=paper
|
|
||||||
HOST=127.0.0.1
|
|
||||||
PORT=8787
|
|
||||||
|
|
||||||
BYBIT_TESTNET=false
|
|
||||||
BYBIT_API_KEY=
|
|
||||||
BYBIT_API_SECRET=
|
|
||||||
|
|
||||||
STARTING_BALANCE_USDT=100
|
|
||||||
AUTO_SELECT_SYMBOLS=false
|
|
||||||
TOP_SYMBOLS_COUNT=12
|
|
||||||
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
|
|
||||||
|
|
||||||
STRATEGY_MODE=torch_forecast
|
|
||||||
BASE_INTERVAL=60
|
|
||||||
KLINE_LIMIT=240
|
|
||||||
TREND_INTERVAL=D
|
|
||||||
TREND_KLINE_LIMIT=260
|
|
||||||
LOOP_INTERVAL_SECONDS=5
|
|
||||||
FAST_TRADING_ENABLED=false
|
|
||||||
FAST_LOOP_INTERVAL_SECONDS=1
|
|
||||||
FAST_ENTRY_COOLDOWN_SECONDS=20
|
|
||||||
MAX_ENTRIES_PER_MINUTE=12
|
|
||||||
WEBSOCKET_ENABLED=true
|
|
||||||
MIN_SIGNAL_CONFIDENCE=0.64
|
|
||||||
MAX_SPREAD_PERCENT=0.18
|
|
||||||
MIN_24H_TURNOVER_USDT=1000000
|
|
||||||
PATTERN_ANALYSIS_ENABLED=true
|
|
||||||
PATTERN_SCORE_WEIGHT=0.18
|
|
||||||
LEARNING_ENABLED=true
|
|
||||||
LEARNING_LOOKBACK_TRADES=120
|
|
||||||
LEARNING_MIN_SAMPLES=3
|
|
||||||
LEARNING_MAX_ADJUSTMENT=0.12
|
|
||||||
LEARNING_MAX_POSITION_MULTIPLIER=1.6
|
|
||||||
MIN_POSITION_USDT=1
|
|
||||||
MAX_POSITION_USDT=8
|
|
||||||
MAX_SYMBOL_EXPOSURE_USDT=25
|
|
||||||
MAX_TOTAL_EXPOSURE_USDT=100
|
|
||||||
MAX_OPEN_POSITIONS=24
|
|
||||||
MAX_POSITIONS_PER_SYMBOL=6
|
|
||||||
GRID_TRADING_ENABLED=false
|
|
||||||
GRID_ENTRY_CONFIDENCE=0.58
|
|
||||||
GRID_BUY_ZONE=0.45
|
|
||||||
GRID_MAX_POSITION_USDT=8
|
|
||||||
REBOUND_TRADING_ENABLED=true
|
|
||||||
REBOUND_ENTRY_CONFIDENCE=0.55
|
|
||||||
REBOUND_MIN_PROBABILITY=0.55
|
|
||||||
REBOUND_MAX_POSITION_USDT=6
|
|
||||||
KELLY_SIZING_ENABLED=true
|
|
||||||
KELLY_FRACTION=0.25
|
|
||||||
KELLY_MAX_FRACTION=0.20
|
|
||||||
RISK_PER_TRADE_PERCENT=0.01
|
|
||||||
RISK_GUARD_ENABLED=true
|
|
||||||
RISK_SYMBOL_GUARD_ENABLED=false
|
|
||||||
RISK_RECENT_TRADE_WINDOW=20
|
|
||||||
RISK_MAX_CONSECUTIVE_LOSSES=4
|
|
||||||
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
|
|
||||||
RISK_REDUCE_MULTIPLIER=1.0
|
|
||||||
ATR_TRAILING_MULTIPLIER=2.2
|
|
||||||
TREND_RSI_MIN=45
|
|
||||||
TREND_RSI_MAX=65
|
|
||||||
TIME_SERIES_FORECAST_ENABLED=true
|
|
||||||
TIME_SERIES_MIN_CANDLES=120
|
|
||||||
TIME_SERIES_FORECAST_HORIZON=3
|
|
||||||
TIME_SERIES_MIN_EDGE_PERCENT=0.10
|
|
||||||
TIME_SERIES_MIN_PROBABILITY_UP=0.47
|
|
||||||
TIME_SERIES_MIN_CONFIDENCE=0.4
|
|
||||||
TIME_SERIES_MAX_ADJUSTMENT=0.08
|
|
||||||
TIME_SERIES_LSTM_ENABLED=true
|
|
||||||
TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json
|
|
||||||
TIME_SERIES_PROBE_ENABLED=true
|
|
||||||
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
|
|
||||||
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
|
||||||
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
|
|
||||||
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
|
|
||||||
STOP_LOSS_PERCENT=0.04
|
|
||||||
STOP_LOSS_EXIT_ENABLED=false
|
|
||||||
TAKE_PROFIT_PERCENT=0.035
|
|
||||||
TRAILING_STOP_PERCENT=0.015
|
|
||||||
MIN_HOLD_SECONDS=180
|
|
||||||
ENTRY_COOLDOWN_SECONDS=180
|
|
||||||
MAX_DAILY_DRAWDOWN_USDT=6
|
|
||||||
MIN_CASH_RESERVE_USDT=5
|
|
||||||
TAKER_FEE_RATE=0.001
|
|
||||||
SLIPPAGE_RATE=0.0003
|
|
||||||
|
|
||||||
# Real trading is locked unless all three values are set explicitly.
|
|
||||||
ENABLE_LIVE_TRADING=false
|
|
||||||
LIVE_TRADING_CONFIRM=
|
|
||||||
LIVE_ORDER_MAX_USDT=10
|
|
||||||
|
|
||||||
DATABASE_PATH=runtime/tradebot.sqlite3
|
|
||||||
LOG_PATH=runtime/tradebot.log
|
|
||||||
+26
-1
@@ -71,7 +71,16 @@ 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
|
TIME_SERIES_REBOUND_FALLBACK_ENABLED=false
|
||||||
|
# Use the independently guarded trend/MACD strategy while no accepted fresh
|
||||||
|
# Torch model is available. The rejected model is never used for entries.
|
||||||
|
TIME_SERIES_TREND_FALLBACK_ENABLED=true
|
||||||
|
TIME_SERIES_REQUIRE_QUALITY_GATE=true
|
||||||
|
# Emergency paper-only override. Keep false unless a failed guard is accepted manually.
|
||||||
|
TIME_SERIES_MANUAL_QUALITY_OVERRIDE=false
|
||||||
|
TIME_SERIES_REQUIRE_FRESH_MODEL=true
|
||||||
|
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
|
||||||
|
MARKET_TICKER_MAX_AGE_SECONDS=45
|
||||||
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
|
||||||
@@ -86,6 +95,22 @@ SLIPPAGE_RATE=0.0003
|
|||||||
ENABLE_LIVE_TRADING=false
|
ENABLE_LIVE_TRADING=false
|
||||||
LIVE_TRADING_CONFIRM=
|
LIVE_TRADING_CONFIRM=
|
||||||
LIVE_ORDER_MAX_USDT=10
|
LIVE_ORDER_MAX_USDT=10
|
||||||
|
LIVE_ORDER_FILL_TIMEOUT_SECONDS=20
|
||||||
|
LIVE_RECONCILIATION_INTERVAL_SECONDS=30
|
||||||
|
LIVE_PROTECTIVE_STOP_ENABLED=true
|
||||||
|
|
||||||
|
# Required for direct API access. If a trusted reverse proxy authenticates
|
||||||
|
# requests, set TRUSTED_PROXY_USER_HEADER to the header injected by that proxy.
|
||||||
|
TRADEBOT_API_TOKEN=
|
||||||
|
TRADEBOT_TRAINING_TOKEN=
|
||||||
|
TRUSTED_PROXY_USER_HEADER=
|
||||||
|
|
||||||
|
HOLD_SIGNAL_SAMPLE_SECONDS=60
|
||||||
|
STORAGE_RETENTION_DAYS=30
|
||||||
|
STORAGE_PRUNE_INTERVAL_SECONDS=3600
|
||||||
|
|
||||||
|
# Windows trainer keeps this final tail untouched by training and early stopping.
|
||||||
|
TORCH_RETRAIN_HOLDOUT_WINDOW=1000
|
||||||
|
|
||||||
DATABASE_PATH=runtime/tradebot.sqlite3
|
DATABASE_PATH=runtime/tradebot.sqlite3
|
||||||
LOG_PATH=runtime/tradebot.log
|
LOG_PATH=runtime/tradebot.log
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt /app/requirements.txt
|
COPY requirements.txt /app/requirements.txt
|
||||||
RUN pip install --no-cache-dir --upgrade pip \
|
RUN pip install --no-cache-dir --upgrade pip \
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ Spot-бот для демо-торговли криптовалютой на р
|
|||||||
- 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`.
|
||||||
- Основная стратегия `torch_forecast`: входы и forecast-выходы идут только от экспортированной PyTorch LSTM/GRU модели; MACD/RSI/дневная EMA не являются условиями входа в этом режиме. Спред, ликвидность, stop-loss, ATR trailing stop, запрет DCA и лимиты экспозиции остаются защитой исполнения и риска.
|
- Основная стратегия `torch_forecast`: входы и forecast-выходы идут только от свежей экспортированной PyTorch LSTM/GRU модели с успешным quality gate; MACD/RSI/дневная EMA не являются условиями входа в этом режиме. Rebound fallback без модели выключен по умолчанию. Спред, ликвидность, stop-loss, ATR trailing stop, запрет DCA и лимиты экспозиции остаются защитой исполнения и риска.
|
||||||
|
- При `TIME_SERIES_TREND_FALLBACK_ENABLED=true` отсутствие принятой свежей Torch-модели включает самостоятельную `trend_macd`-стратегию. Отклонённый artifact не используется, fallback явно отражается в readiness и диагностике сигналов, а после появления принятой модели выключается автоматически.
|
||||||
- Основная стратегия `trend_macd`: вход на `1h`, дневной фильтр тренда на `1d`, long только если цена выше дневной EMA200 и дневная EMA50 выше EMA200.
|
- Основная стратегия `trend_macd`: вход на `1h`, дневной фильтр тренда на `1d`, long только если цена выше дневной EMA200 и дневная EMA50 выше EMA200.
|
||||||
- Вход `trend_macd`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры.
|
- Вход `trend_macd`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры.
|
||||||
- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`.
|
- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`.
|
||||||
@@ -20,7 +21,8 @@ Spot-бот для демо-торговли криптовалютой на р
|
|||||||
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
|
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
|
||||||
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для просмотра 12 пар, свечей, Torch/Kelly параметров, расписания удалённого retrain и live-чеклиста.
|
- 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`.
|
- Liveness `/api/health`, readiness `/api/ready`, объединенный mobile snapshot `/api/mobile/snapshot` и Prometheus-compatible `/metrics`.
|
||||||
|
- Все приватные API endpoints требуют токен или подтвержденный reverse-proxy user header; health и metrics остаются доступными для локального мониторинга.
|
||||||
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост.
|
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост.
|
||||||
- Live trading guard: live не стартует без `ENABLE_LIVE_TRADING=true`, `LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK` и Bybit API-ключей.
|
- Live trading guard: live не стартует без `ENABLE_LIVE_TRADING=true`, `LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK` и Bybit API-ключей.
|
||||||
|
|
||||||
@@ -78,7 +80,9 @@ Dashboard: <http://127.0.0.1:8787/>
|
|||||||
--epochs 70
|
--epochs 70
|
||||||
```
|
```
|
||||||
|
|
||||||
Новый artifact версии 4 обучается как probabilistic multi-horizon модель: вход включает доходности, форму свечи, объем, ATR%, realized volatility, RSI/MACD/EMA slopes, 4h/24h rolling trend, дневные EMA-признаки, BTC/ETH cross-asset признаки и числовые признаки текущего шаблона пары. Цель обучается как `future log return - комиссии - проскальзывание`, нормализованная на текущую волатильность. Модель сразу прогнозирует горизонты `1/3/6/12`, quantile-оценки `q10/q50/q90` и `P(up)`.
|
Новый artifact версии 6 обучается как торговая multi-task multi-horizon модель: вход включает доходности, форму свечи, объем, ATR%, realized volatility, RSI/MACD/EMA slopes, 4h/24h rolling trend, дневные EMA-признаки, BTC/ETH cross-asset признаки и числовые признаки текущего шаблона пары. Для каждой точки симулируется вход по open следующей свечи; затем до каждого горизонта проверяется, что было достигнуто раньше — take-profit или stop-loss. Денежная цель равна чистому log-PnL при первом барьере либо закрытии по горизонту после комиссий и проскальзывания. Вторая цель — вероятность `P(TP before SL)`. Если одна OHLC-свеча касается обоих барьеров, разметка консервативно считает stop-loss первым. Модель прогнозирует горизонты `3/6/12/24` и quantile-оценки `q10/q50/q90` чистого результата.
|
||||||
|
|
||||||
|
Последний tail (`--holdout-window`, по умолчанию 1000 samples на символ) полностью исключается из training и early stopping. Между train/validation/holdout оставляется purge по максимальному forecast horizon. Threshold walk-forward и guard работают только на этом untouched holdout; calibration и guard криптографически привязаны к SHA-256 конкретного model artifact. В каждом walk-forward fold торговать могут только пары, которые получили жизнеспособный порог на предшествующей train-части; общий порог больше не возвращает в портфель нестабильные пары.
|
||||||
|
|
||||||
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В стратегии `torch_forecast` экспортированная PyTorch LSTM/GRU модель является единственным направляющим сигналом для входа и forecast-выхода. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены.
|
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В стратегии `torch_forecast` экспортированная PyTorch LSTM/GRU модель является единственным направляющим сигналом для входа и forecast-выхода. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены.
|
||||||
|
|
||||||
@@ -92,12 +96,16 @@ powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.p
|
|||||||
Для удалённого запуска с телефона или с бота используется Windows training agent. Бот на `tb.kusoft.xyz` хранит очередь заданий, а Windows-машина сама подключается к интернету, забирает задания, обучает модель и загружает артефакты обратно:
|
Для удалённого запуска с телефона или с бота используется Windows training agent. Бот на `tb.kusoft.xyz` хранит очередь заданий, а Windows-машина сама подключается к интернету, забирает задания, обучает модель и загружает артефакты обратно:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps1 -ApiAuth "login:password" -StartNow
|
powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps1 -ApiAuth "<TRADEBOT_TRAINING_TOKEN>" -StartNow
|
||||||
```
|
```
|
||||||
|
|
||||||
Установщик регистрирует Scheduled Task `TradeBot Windows Training Agent` при входе в Windows и удаляет старые локальные retrain-задачи, чтобы обучение запускалось через очередь, а не двумя независимыми механизмами.
|
Установщик сохраняет worker-токен через Windows DPAPI, удаляет его старую plaintext-копию из пользовательского окружения и включает постоянный запуск агента. С правами администратора используется Scheduled Task с watchdog; без повышения прав — штатный ярлык в пользовательской папке Startup. Старые локальные 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`.
|
По умолчанию Windows-agent обучает отдельную PyTorch `LSTM/GRU` для каждой пары на `6000` часовых свечах. Это не заставляет разнородные активы делить одну архитектуру и один набор recurrent-весов. Прогноз усредняется по seed `7/19`, модели сравниваются на validation-folds, а пороги калибруются отдельно для каждой пары. Ensemble guard выполняется пакетно на GPU, а экспорт не дублирует первый набор весов. Search space использует lookback `32/64/128`, hidden `64/96`, dropout `0.20`, AdamW learning rate `0.0007` и weight decay `0.0005`; untouched holdout и quality gate не ослабляются. Для диагностического pooled-запуска используется ключ `-Pooled`. Параметры можно переопределить через 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_ENSEMBLE_SEEDS`, `TORCH_RETRAIN_SELECTION_FOLDS`, `TORCH_RETRAIN_LEARNING_RATE`, `TORCH_RETRAIN_WEIGHT_DECAY`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
|
||||||
|
|
||||||
|
Loss и выбор гиперпараметров учитывают after-cost trading utility, ошибку ожидаемого чистого PnL, quantile-loss и focal BCE для события `TP before SL`, а не только MAE направления цены. В каждом walk-forward fold вероятность успеха калибруется Platt-моделью исключительно на train-части; затем на этой же train-части выбираются глобальные и per-symbol пороги, которые применяются к test-части. Для выбора порога требуется минимум 24 непересекающиеся сделки, а финальный quality gate по-прежнему требует не менее 30 OOS-сделок. Калибратор не имеет fallback на единичные сделки: если минимальная статистика не набрана, кандидат получает `calibration_insufficient` и не может пройти gate.
|
||||||
|
|
||||||
|
Основной decision horizon — `12h`, дополнительные горизонты — `3/6/12/24`. Размеры обучающих барьеров берутся из `STOP_LOSS_PERCENT` и `TAKE_PROFIT_PERCENT`, а round-trip cost — из fee/slippage настроек. Threshold search оценивается тем же execution replay со stop-loss, take-profit, ATR trailing и forecast-exit, который используется в walk-forward. `holdout_skill` остаётся только в финальном отчёте и никогда не участвует в фильтрации входов или подборе порогов.
|
||||||
|
|
||||||
Если 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:
|
||||||
|
|
||||||
@@ -105,7 +113,7 @@ powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps
|
|||||||
powershell -ExecutionPolicy Bypass -File tools\sync_torch_artifacts_to_pi.ps1 -RemoteHost 192.168.0.185 -RemoteUser sevenhill -RemoteRoot /mnt/data/tradebot
|
powershell -ExecutionPolicy Bypass -File tools\sync_torch_artifacts_to_pi.ps1 -RemoteHost 192.168.0.185 -RemoteUser sevenhill -RemoteRoot /mnt/data/tradebot
|
||||||
```
|
```
|
||||||
|
|
||||||
Внутри recurrent модели используются exportable attention pooling и LayerNorm перед forecast-head; Raspberry Pi по-прежнему исполняет модель из JSON без PyTorch runtime.
|
Внутри recurrent модели используются exportable attention pooling и LayerNorm. После recurrent-контекста добавлена нелинейная GELU-проекция и две отдельные экспортируемые головы: одна для ожидаемого PnL/quantiles, вторая для `P(TP before SL)`. Raspberry Pi по-прежнему исполняет модель из JSON без PyTorch runtime.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -185,7 +193,11 @@ 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
|
TIME_SERIES_REBOUND_FALLBACK_ENABLED=false
|
||||||
|
TIME_SERIES_REQUIRE_QUALITY_GATE=true
|
||||||
|
TIME_SERIES_REQUIRE_FRESH_MODEL=true
|
||||||
|
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
|
||||||
|
MARKET_TICKER_MAX_AGE_SECONDS=45
|
||||||
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
|
||||||
@@ -213,9 +225,17 @@ LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK
|
|||||||
BYBIT_API_KEY=...
|
BYBIT_API_KEY=...
|
||||||
BYBIT_API_SECRET=...
|
BYBIT_API_SECRET=...
|
||||||
LIVE_ORDER_MAX_USDT=10
|
LIVE_ORDER_MAX_USDT=10
|
||||||
|
LIVE_ORDER_FILL_TIMEOUT_SECONDS=20
|
||||||
|
LIVE_RECONCILIATION_INTERVAL_SECONDS=30
|
||||||
|
LIVE_PROTECTIVE_STOP_ENABLED=true
|
||||||
|
TRADEBOT_API_TOKEN=
|
||||||
|
TRADEBOT_TRAINING_TOKEN=
|
||||||
|
TRUSTED_PROXY_USER_HEADER=
|
||||||
|
HOLD_SIGNAL_SAMPLE_SECONDS=60
|
||||||
|
STORAGE_RETENTION_DAYS=30
|
||||||
```
|
```
|
||||||
|
|
||||||
Текущее live-исполнение отправляет market buy/sell в Bybit и ведет локальную shadow-позицию для dashboard и правил выхода. Для промышленной торговли реальными средствами следующий обязательный шаг — reconciliation с реальным wallet/order history Bybit, чтобы локальное состояние сверялось с фактическими fills и балансами.
|
Live-исполнение ведет журнал order intent до отправки, подтверждает фактические fills через Bybit executions/order history, записывает фактическую цену/количество/комиссию, периодически сверяет wallet и открытые ордера и блокирует новые входы при расхождении. После подтвержденной покупки создается биржевой spot TP/SL stop-order; если защитный ордер создать не удалось, позиция немедленно закрывается. Перед первым использованием реальных средств этот контур все равно необходимо проверить на Bybit testnet с API-ключом без права вывода.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -234,5 +254,6 @@ LIVE_ORDER_MAX_USDT=10
|
|||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
python -m pip install -r requirements-dev.txt
|
||||||
python -m pytest
|
python -m pytest
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
- Русский интерфейс без bubble/pill-оформления.
|
- Русский интерфейс без bubble/pill-оформления.
|
||||||
- Современная биржевая компоновка: список пар, один выбранный график, компактные параметры ниже.
|
- Современная биржевая компоновка: список пар, один выбранный график, компактные параметры ниже.
|
||||||
- Свечной график 1h: тела свечей, фитили, объём, EMA50, EMA200, последняя цена.
|
- Свечной график 1h: тела свечей, фитили, объём, EMA50, EMA200, последняя цена.
|
||||||
- Параметры Torch: edge, P(up), confidence, skill, quantiles, gate, причина решения.
|
- Параметры Torch: ожидаемый чистый edge, P(TP<SL) для новых торговых моделей, confidence, skill, quantiles, gate, причина решения; для старых артефактов сохраняется P(up).
|
||||||
- Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill.
|
- Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill.
|
||||||
- Обзор equity/cash/exposure/PnL и последних решений.
|
- Обзор equity/cash/exposure/PnL и последних решений.
|
||||||
- Удалённый запуск retrain через очередь заданий на боте и закреплённый Windows-компьютер обучения.
|
- Удалённый запуск retrain через очередь заданий на боте и закреплённый Windows-компьютер обучения.
|
||||||
@@ -38,15 +38,11 @@ https://tb.kusoft.xyz
|
|||||||
|
|
||||||
Этот адрес установлен в приложении по умолчанию. Если в настройках ввести просто `tb.kusoft.xyz`, приложение само добавит `https://`.
|
Этот адрес установлен в приложении по умолчанию. Если в настройках ввести просто `tb.kusoft.xyz`, приложение само добавит `https://`.
|
||||||
|
|
||||||
Если домен защищён авторизацией, в поле `API auth` можно указать:
|
В поле `API-токен` указывается отдельный токен Android-клиента (`TRADEBOT_API_TOKEN` на сервере). Логин и пароль reverse proxy приложению не нужны. Токен отправляется как Bearer/X-TradeBot-Token и хранится зашифрованным ключом Android Keystore.
|
||||||
|
|
||||||
- `login:password` — приложение отправит HTTP Basic;
|
|
||||||
- `Basic ...` — готовый Basic header;
|
|
||||||
- `Bearer ...` или просто токен — приложение отправит Bearer.
|
|
||||||
|
|
||||||
## Переобучение
|
## Переобучение
|
||||||
|
|
||||||
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `DESKTOP-TMFDL0H` сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети.
|
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `SEVENHILL` (`G:\Repos\TradeBot`) сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети.
|
||||||
|
|
||||||
## Live-торговля
|
## Live-торговля
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ android {
|
|||||||
applicationId = "xyz.kusoft.tradebotmonitor"
|
applicationId = "xyz.kusoft.tradebotmonitor"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 12
|
versionCode = 21
|
||||||
versionName = "0.2.9"
|
versionName = "0.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
android:roundIcon="@drawable/ic_launcher"
|
android:roundIcon="@drawable/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="false">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package xyz.kusoft.tradebotmonitor
|
package xyz.kusoft.tradebotmonitor
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
class AppPrefs(context: Context) {
|
class AppPrefs(context: Context) {
|
||||||
private val prefs = context.getSharedPreferences("tradebot_monitor", Context.MODE_PRIVATE)
|
private val prefs = context.getSharedPreferences("tradebot_monitor", Context.MODE_PRIVATE)
|
||||||
@@ -10,7 +16,14 @@ class AppPrefs(context: Context) {
|
|||||||
if (saved.isNullOrBlank() || saved == LEGACY_PI_API_BASE_URL) {
|
if (saved.isNullOrBlank() || saved == LEGACY_PI_API_BASE_URL) {
|
||||||
prefs.edit().putString("api_base_url", DEFAULT_API_BASE_URL).apply()
|
prefs.edit().putString("api_base_url", DEFAULT_API_BASE_URL).apply()
|
||||||
}
|
}
|
||||||
if (prefs.getString("training_computer_name", null).isNullOrBlank()) {
|
val trainingComputerName = prefs.getString("training_computer_name", null)?.trim()
|
||||||
|
val trainingComputerPath = prefs.getString("training_computer_path", null)?.trim()
|
||||||
|
if (
|
||||||
|
trainingComputerName.isNullOrBlank() ||
|
||||||
|
trainingComputerName == LEGACY_TRAINING_COMPUTER_NAME ||
|
||||||
|
trainingComputerPath.isNullOrBlank() ||
|
||||||
|
trainingComputerPath == LEGACY_TRAINING_COMPUTER_PATH
|
||||||
|
) {
|
||||||
pinDefaultTrainingComputer()
|
pinDefaultTrainingComputer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,8 +33,23 @@ class AppPrefs(context: Context) {
|
|||||||
set(value) = prefs.edit().putString("api_base_url", normalizeBaseUrl(value)).apply()
|
set(value) = prefs.edit().putString("api_base_url", normalizeBaseUrl(value)).apply()
|
||||||
|
|
||||||
var commandToken: String
|
var commandToken: String
|
||||||
get() = prefs.getString("command_token", "") ?: ""
|
get() {
|
||||||
set(value) = prefs.edit().putString("command_token", value.trim()).apply()
|
val encrypted = prefs.getString("command_token_v2", "").orEmpty()
|
||||||
|
if (encrypted.isNotBlank()) return decryptToken(encrypted)
|
||||||
|
val legacy = prefs.getString("command_token", "").orEmpty()
|
||||||
|
if (legacy.isNotBlank()) {
|
||||||
|
commandToken = legacy
|
||||||
|
prefs.edit().remove("command_token").apply()
|
||||||
|
}
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
set(value) {
|
||||||
|
val clean = value.trim()
|
||||||
|
prefs.edit()
|
||||||
|
.putString("command_token_v2", if (clean.isBlank()) "" else encryptToken(clean))
|
||||||
|
.remove("command_token")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
var selectedSymbol: String
|
var selectedSymbol: String
|
||||||
get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT"
|
get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT"
|
||||||
@@ -59,17 +87,64 @@ class AppPrefs(context: Context) {
|
|||||||
private fun normalizeBaseUrl(value: String): String {
|
private fun normalizeBaseUrl(value: String): String {
|
||||||
val trimmed = value.trim().trimEnd('/')
|
val trimmed = value.trim().trimEnd('/')
|
||||||
if (trimmed.isBlank()) return DEFAULT_API_BASE_URL
|
if (trimmed.isBlank()) return DEFAULT_API_BASE_URL
|
||||||
return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
return when {
|
||||||
trimmed
|
trimmed.startsWith("https://") -> trimmed
|
||||||
} else {
|
trimmed.startsWith("http://") -> "https://${trimmed.removePrefix("http://")}"
|
||||||
"https://$trimmed"
|
else -> "https://$trimmed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun encryptToken(value: String): String {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, tokenKey())
|
||||||
|
val iv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP)
|
||||||
|
val data = Base64.encodeToString(cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
|
||||||
|
return "$iv:$data"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptToken(value: String): String {
|
||||||
|
return try {
|
||||||
|
val parts = value.split(':', limit = 2)
|
||||||
|
if (parts.size != 2) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE,
|
||||||
|
tokenKey(),
|
||||||
|
GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP)),
|
||||||
|
)
|
||||||
|
String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokenKey(): SecretKey {
|
||||||
|
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
(store.getKey(TOKEN_KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||||
|
val generator = KeyGenerator.getInstance("AES", "AndroidKeyStore")
|
||||||
|
generator.init(
|
||||||
|
android.security.keystore.KeyGenParameterSpec.Builder(
|
||||||
|
TOKEN_KEY_ALIAS,
|
||||||
|
android.security.keystore.KeyProperties.PURPOSE_ENCRYPT or
|
||||||
|
android.security.keystore.KeyProperties.PURPOSE_DECRYPT,
|
||||||
|
)
|
||||||
|
.setBlockModes(android.security.keystore.KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
return generator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz"
|
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 LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787"
|
||||||
const val DEFAULT_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
|
const val DEFAULT_TRAINING_COMPUTER_NAME = "SEVENHILL"
|
||||||
const val DEFAULT_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
|
const val DEFAULT_TRAINING_COMPUTER_PATH = "G:\\Repos\\TradeBot"
|
||||||
|
const val LEGACY_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
|
||||||
|
const val LEGACY_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
|
||||||
|
const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+639
-82
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,8 @@ data class ForecastData(
|
|||||||
val model: String,
|
val model: String,
|
||||||
val expectedReturnPercent: Double,
|
val expectedReturnPercent: Double,
|
||||||
val probabilityUp: Double,
|
val probabilityUp: Double,
|
||||||
|
val probabilityTakeProfitFirst: Double?,
|
||||||
|
val targetTransform: String,
|
||||||
val skill: Double,
|
val skill: Double,
|
||||||
val volatilityPercent: Double,
|
val volatilityPercent: Double,
|
||||||
val horizon: Int,
|
val horizon: Int,
|
||||||
@@ -73,10 +75,17 @@ data class SignalData(
|
|||||||
?: 0.0
|
?: 0.0
|
||||||
|
|
||||||
val probabilityUp: Double
|
val probabilityUp: Double
|
||||||
get() = diagnostics.optDoubleOrNull("probability_up")
|
get() = diagnostics.optDoubleOrNull("probability_take_profit_first")
|
||||||
|
?: diagnostics.optJSONObject("forecast")?.optDoubleOrNull("probability_take_profit_first")
|
||||||
|
?: diagnostics.optDoubleOrNull("probability_up")
|
||||||
?: diagnostics.optJSONObject("forecast")?.optDoubleOrNull("probability_up")
|
?: diagnostics.optJSONObject("forecast")?.optDoubleOrNull("probability_up")
|
||||||
?: 0.0
|
?: 0.0
|
||||||
|
|
||||||
|
val targetTransform: String
|
||||||
|
get() = diagnostics.optString("target_transform").ifBlank {
|
||||||
|
diagnostics.optJSONObject("forecast")?.optString("target_transform").orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
val positionNotionalUsdt: Double
|
val positionNotionalUsdt: Double
|
||||||
get() = diagnostics.optDoubleOrNull("position_notional_usdt")
|
get() = diagnostics.optDoubleOrNull("position_notional_usdt")
|
||||||
?: diagnostics.optJSONObject("position_sizing")?.optDoubleOrNull("notional_usdt")
|
?: diagnostics.optJSONObject("position_sizing")?.optDoubleOrNull("notional_usdt")
|
||||||
@@ -84,6 +93,7 @@ data class SignalData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class PositionData(
|
data class PositionData(
|
||||||
|
val id: Long?,
|
||||||
val symbol: String,
|
val symbol: String,
|
||||||
val qty: Double,
|
val qty: Double,
|
||||||
val entryPrice: Double,
|
val entryPrice: Double,
|
||||||
@@ -97,6 +107,7 @@ data class PositionData(
|
|||||||
val highestPrice: Double?,
|
val highestPrice: Double?,
|
||||||
val trailingStop: Double?,
|
val trailingStop: Double?,
|
||||||
val atrTrailingStop: Double?,
|
val atrTrailingStop: Double?,
|
||||||
|
val openedAt: String,
|
||||||
val exitAction: String,
|
val exitAction: String,
|
||||||
val exitReason: String,
|
val exitReason: String,
|
||||||
val stopLossExitEnabled: Boolean,
|
val stopLossExitEnabled: Boolean,
|
||||||
@@ -134,6 +145,8 @@ data class ClosedTradesSummary(
|
|||||||
data class BotSnapshot(
|
data class BotSnapshot(
|
||||||
val ok: Boolean,
|
val ok: Boolean,
|
||||||
val running: Boolean,
|
val running: Boolean,
|
||||||
|
val ready: Boolean,
|
||||||
|
val readinessReasons: List<String>,
|
||||||
val mode: String,
|
val mode: String,
|
||||||
val account: AccountData,
|
val account: AccountData,
|
||||||
val positions: List<PositionData>,
|
val positions: List<PositionData>,
|
||||||
@@ -153,5 +166,8 @@ fun JSONObject.optStringClean(name: String): String =
|
|||||||
fun JSONObject.optDoubleOrNull(name: String): Double? =
|
fun JSONObject.optDoubleOrNull(name: String): Double? =
|
||||||
if (has(name) && !isNull(name)) optDouble(name) else null
|
if (has(name) && !isNull(name)) optDouble(name) else null
|
||||||
|
|
||||||
|
fun JSONObject.optLongOrNull(name: String): Long? =
|
||||||
|
if (has(name) && !isNull(name)) optLong(name) else null
|
||||||
|
|
||||||
fun JSONObject.optBooleanOrNull(name: String): Boolean? =
|
fun JSONObject.optBooleanOrNull(name: String): Boolean? =
|
||||||
if (has(name) && !isNull(name)) optBoolean(name) else null
|
if (has(name) && !isNull(name)) optBoolean(name) else null
|
||||||
|
|||||||
+3
-1
@@ -39,7 +39,8 @@ object RetrainScheduler {
|
|||||||
class RetrainAlarmReceiver : BroadcastReceiver() {
|
class RetrainAlarmReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
Executors.newSingleThreadExecutor().execute {
|
val executor = Executors.newSingleThreadExecutor()
|
||||||
|
executor.execute {
|
||||||
try {
|
try {
|
||||||
val prefs = AppPrefs(context)
|
val prefs = AppPrefs(context)
|
||||||
if (prefs.retrainScheduleEnabled) {
|
if (prefs.retrainScheduleEnabled) {
|
||||||
@@ -47,6 +48,7 @@ class RetrainAlarmReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
|
executor.shutdown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-21
@@ -1,6 +1,5 @@
|
|||||||
package xyz.kusoft.tradebotmonitor
|
package xyz.kusoft.tradebotmonitor
|
||||||
|
|
||||||
import android.util.Base64
|
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
@@ -14,16 +13,19 @@ class TradeBotApi(
|
|||||||
private val token: String,
|
private val token: String,
|
||||||
) {
|
) {
|
||||||
fun fetchSnapshot(): BotSnapshot {
|
fun fetchSnapshot(): BotSnapshot {
|
||||||
val health = getJson("/api/health")
|
val snapshot = getJson("/api/mobile/snapshot")
|
||||||
val status = getJson("/api/status")
|
val health = snapshot.optJSONObject("health") ?: JSONObject()
|
||||||
val markets = getJson("/api/markets")
|
val status = snapshot.optJSONObject("status") ?: JSONObject()
|
||||||
val signals = getJson("/api/signals?limit=220")
|
val markets = snapshot.optJSONObject("markets") ?: JSONObject()
|
||||||
val config = getJson("/api/config")
|
val signals = snapshot.optJSONObject("signals") ?: JSONObject()
|
||||||
val trades = getJson("/api/trades?limit=10")
|
val config = snapshot.optJSONObject("config") ?: JSONObject()
|
||||||
val retrain = getJson("/api/retrain")
|
val trades = snapshot.optJSONObject("trades") ?: JSONObject()
|
||||||
val backtest = getJson("/api/backtest")
|
val retrain = snapshot.optJSONObject("retrain") ?: JSONObject()
|
||||||
|
val backtest = snapshot.optJSONObject("backtest") ?: JSONObject()
|
||||||
|
|
||||||
val accountJson = status.optJSONObject("account") ?: JSONObject()
|
val accountJson = status.optJSONObject("account") ?: JSONObject()
|
||||||
|
val readiness = status.optJSONObject("readiness") ?: JSONObject()
|
||||||
|
val readinessReasons = readiness.optJSONArray("reasons") ?: JSONArray()
|
||||||
val account = AccountData(
|
val account = AccountData(
|
||||||
equity = accountJson.optDouble("equity", 0.0),
|
equity = accountJson.optDouble("equity", 0.0),
|
||||||
cash = accountJson.optDouble("cash", 0.0),
|
cash = accountJson.optDouble("cash", 0.0),
|
||||||
@@ -33,6 +35,10 @@ class TradeBotApi(
|
|||||||
return BotSnapshot(
|
return BotSnapshot(
|
||||||
ok = health.optBoolean("ok", false),
|
ok = health.optBoolean("ok", false),
|
||||||
running = health.optBoolean("running", false),
|
running = health.optBoolean("running", false),
|
||||||
|
ready = readiness.optBoolean("ready", false),
|
||||||
|
readinessReasons = List(readinessReasons.length()) { index ->
|
||||||
|
readinessReasons.optString(index)
|
||||||
|
}.filter { it.isNotBlank() },
|
||||||
mode = health.optStringClean("mode"),
|
mode = health.optStringClean("mode"),
|
||||||
account = account,
|
account = account,
|
||||||
positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()),
|
positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()),
|
||||||
@@ -91,7 +97,7 @@ class TradeBotApi(
|
|||||||
connection.disconnect()
|
connection.disconnect()
|
||||||
if (code !in 200..299) {
|
if (code !in 200..299) {
|
||||||
if (code == HttpURLConnection.HTTP_UNAUTHORIZED) {
|
if (code == HttpURLConnection.HTTP_UNAUTHORIZED) {
|
||||||
throw IllegalStateException("HTTP 401: сервер требует логин и пароль")
|
throw IllegalStateException("HTTP 401: API-токен отсутствует или неверен")
|
||||||
}
|
}
|
||||||
throw IllegalStateException("HTTP $code: ${text.take(240)}")
|
throw IllegalStateException("HTTP $code: ${text.take(240)}")
|
||||||
}
|
}
|
||||||
@@ -104,6 +110,7 @@ class TradeBotApi(
|
|||||||
val row = items.optJSONObject(index) ?: continue
|
val row = items.optJSONObject(index) ?: continue
|
||||||
val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject()
|
val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject()
|
||||||
output += PositionData(
|
output += PositionData(
|
||||||
|
id = row.optLongOrNull("id"),
|
||||||
symbol = row.optStringClean("symbol"),
|
symbol = row.optStringClean("symbol"),
|
||||||
qty = row.optDouble("qty", 0.0),
|
qty = row.optDouble("qty", 0.0),
|
||||||
entryPrice = row.optDouble("entry_price", 0.0),
|
entryPrice = row.optDouble("entry_price", 0.0),
|
||||||
@@ -117,6 +124,7 @@ class TradeBotApi(
|
|||||||
highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"),
|
highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"),
|
||||||
trailingStop = exitPlan.optDoubleOrNull("trailing_stop"),
|
trailingStop = exitPlan.optDoubleOrNull("trailing_stop"),
|
||||||
atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"),
|
atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"),
|
||||||
|
openedAt = row.optStringClean("opened_at"),
|
||||||
exitAction = exitPlan.optStringClean("action"),
|
exitAction = exitPlan.optStringClean("action"),
|
||||||
exitReason = exitPlan.optStringClean("reason"),
|
exitReason = exitPlan.optStringClean("reason"),
|
||||||
stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true,
|
stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true,
|
||||||
@@ -234,7 +242,10 @@ class TradeBotApi(
|
|||||||
return ForecastData(
|
return ForecastData(
|
||||||
model = row.optStringClean("model"),
|
model = row.optStringClean("model"),
|
||||||
expectedReturnPercent = row.optDouble("expected_return_percent", 0.0),
|
expectedReturnPercent = row.optDouble("expected_return_percent", 0.0),
|
||||||
probabilityUp = row.optDouble("probability_up", 0.0),
|
probabilityUp = row.optDoubleOrNull("probability_take_profit_first")
|
||||||
|
?: row.optDouble("probability_up", 0.0),
|
||||||
|
probabilityTakeProfitFirst = row.optDoubleOrNull("probability_take_profit_first"),
|
||||||
|
targetTransform = row.optStringClean("target_transform"),
|
||||||
skill = row.optDouble("skill", 0.0),
|
skill = row.optDouble("skill", 0.0),
|
||||||
volatilityPercent = row.optDouble("volatility_percent", 0.0),
|
volatilityPercent = row.optDouble("volatility_percent", 0.0),
|
||||||
horizon = row.optInt("horizon", 0),
|
horizon = row.optInt("horizon", 0),
|
||||||
@@ -273,15 +284,8 @@ class TradeBotApi(
|
|||||||
private fun applyAuthHeaders(connection: HttpURLConnection, token: String) {
|
private fun applyAuthHeaders(connection: HttpURLConnection, token: String) {
|
||||||
val value = token.trim()
|
val value = token.trim()
|
||||||
if (value.isBlank()) return
|
if (value.isBlank()) return
|
||||||
connection.setRequestProperty("X-TradeBot-Token", value)
|
val rawToken = value.removePrefix("Bearer ").removePrefix("bearer ").trim()
|
||||||
val authorization = when {
|
connection.setRequestProperty("X-TradeBot-Token", rawToken)
|
||||||
value.startsWith("Basic ", ignoreCase = true) -> value
|
val authorization = "Bearer $rawToken"
|
||||||
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)
|
connection.setRequestProperty("Authorization", authorization)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
Vendored
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+93
@@ -0,0 +1,93 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -198,9 +198,12 @@ def _group_stats(trades: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
def _active_universe_trades(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _active_universe_trades(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
symbols = {symbol.upper() for symbol in settings.symbols}
|
symbols = {symbol.upper() for symbol in settings.symbols}
|
||||||
if not symbols:
|
return [
|
||||||
return trades
|
trade
|
||||||
return [trade for trade in trades if str(trade.get("symbol", "")).upper() in symbols]
|
for trade in trades
|
||||||
|
if (not symbols or str(trade.get("symbol", "")).upper() in symbols)
|
||||||
|
and str(trade.get("mode", "paper")) == settings.trading_mode
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
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]]:
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
|
from crypto_spot_bot.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class ApiAuthorizer:
|
||||||
|
"""Authenticate API calls either directly or through an authenticated proxy."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
async def require(self, request: Request) -> None:
|
||||||
|
if self._proxy_authenticated(request) or self._token_authenticated(
|
||||||
|
request, self.settings.api_auth_token
|
||||||
|
):
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="API authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def require_training(self, request: Request) -> None:
|
||||||
|
expected = self.settings.training_worker_token or self.settings.api_auth_token
|
||||||
|
if self._proxy_authenticated(request) or self._token_authenticated(request, expected):
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="training worker authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
self.settings.api_auth_token
|
||||||
|
or self.settings.training_worker_token
|
||||||
|
or self.settings.trusted_proxy_user_header
|
||||||
|
)
|
||||||
|
|
||||||
|
def _proxy_authenticated(self, request: Request) -> bool:
|
||||||
|
header = self.settings.trusted_proxy_user_header
|
||||||
|
if not header:
|
||||||
|
return False
|
||||||
|
return bool(request.headers.get(header, "").strip())
|
||||||
|
|
||||||
|
def _token_authenticated(self, request: Request, expected: str) -> bool:
|
||||||
|
if not expected:
|
||||||
|
return False
|
||||||
|
candidates = [request.headers.get("X-TradeBot-Token", "").strip()]
|
||||||
|
authorization = request.headers.get("Authorization", "").strip()
|
||||||
|
if authorization.lower().startswith("bearer "):
|
||||||
|
candidates.append(authorization[7:].strip())
|
||||||
|
elif authorization.lower().startswith("basic "):
|
||||||
|
decoded = _decode_basic(authorization[6:].strip())
|
||||||
|
if decoded:
|
||||||
|
candidates.append(decoded)
|
||||||
|
return any(candidate and hmac.compare_digest(candidate, expected) for candidate in candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_basic(value: str) -> str:
|
||||||
|
try:
|
||||||
|
return base64.b64decode(value, validate=True).decode("utf-8")
|
||||||
|
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||||
|
return ""
|
||||||
+161
-13
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from crypto_spot_bot.analytics import risk_guard_snapshot
|
from crypto_spot_bot.analytics import risk_guard_snapshot
|
||||||
@@ -10,11 +12,14 @@ from crypto_spot_bot.learning import TradeLearner
|
|||||||
from crypto_spot_bot.market_data import MarketData
|
from crypto_spot_bot.market_data import MarketData
|
||||||
from crypto_spot_bot.models import BotStatus, Signal, Ticker, utc_now
|
from crypto_spot_bot.models import BotStatus, Signal, Ticker, utc_now
|
||||||
from crypto_spot_bot.patterns import PatternAnalyzer
|
from crypto_spot_bot.patterns import PatternAnalyzer
|
||||||
from crypto_spot_bot.strategy import SpotStrategy
|
from crypto_spot_bot.strategy import SpotStrategy, torch_model_readiness_reasons
|
||||||
from crypto_spot_bot.storage import Storage
|
from crypto_spot_bot.storage import Storage
|
||||||
from crypto_spot_bot.time_series import TimeSeriesForecaster
|
from crypto_spot_bot.time_series import TimeSeriesForecaster
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CryptoSpotBot:
|
class CryptoSpotBot:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -44,6 +49,9 @@ class CryptoSpotBot:
|
|||||||
self._entry_cooldown_until: dict[str, datetime] = {}
|
self._entry_cooldown_until: dict[str, datetime] = {}
|
||||||
self._loop_task: asyncio.Task | None = None
|
self._loop_task: asyncio.Task | None = None
|
||||||
self._ws_task: asyncio.Task | None = None
|
self._ws_task: asyncio.Task | None = None
|
||||||
|
self._last_reconciliation_at: datetime | None = None
|
||||||
|
self._last_prune_at: datetime | None = None
|
||||||
|
self._consecutive_loop_errors = 0
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if self.running:
|
if self.running:
|
||||||
@@ -51,6 +59,17 @@ class CryptoSpotBot:
|
|||||||
self.market.reset_stop()
|
self.market.reset_stop()
|
||||||
if not self.market.symbols:
|
if not self.market.symbols:
|
||||||
await self.market.bootstrap()
|
await self.market.bootstrap()
|
||||||
|
if isinstance(self.broker, LiveBroker):
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(self.broker.reconcile, self.market.instruments)
|
||||||
|
self._last_reconciliation_at = utc_now()
|
||||||
|
except Exception as exc:
|
||||||
|
self.broker.reconciliation_state = {
|
||||||
|
"status": "error",
|
||||||
|
"blocking": True,
|
||||||
|
"discrepancies": [{"code": "initial_reconciliation_failed", "message": str(exc)}],
|
||||||
|
}
|
||||||
|
self.storage.event(f"Initial live reconciliation failed: {exc}", "ERROR")
|
||||||
self._close_paper_positions_outside_symbol_universe()
|
self._close_paper_positions_outside_symbol_universe()
|
||||||
self._update_patterns()
|
self._update_patterns()
|
||||||
self._update_forecasts()
|
self._update_forecasts()
|
||||||
@@ -58,7 +77,10 @@ class CryptoSpotBot:
|
|||||||
self.running = True
|
self.running = True
|
||||||
self.started_at = utc_now()
|
self.started_at = utc_now()
|
||||||
self.message = "бот работает"
|
self.message = "бот работает"
|
||||||
self.storage.event("Бот запущен")
|
self._safe_event("Бот запущен")
|
||||||
|
# Maintenance must never delay the first market decision after startup.
|
||||||
|
# The bounded telemetry prune starts after the configured interval.
|
||||||
|
self._last_prune_at = utc_now()
|
||||||
if self.settings.websocket_enabled:
|
if self.settings.websocket_enabled:
|
||||||
self._ws_task = asyncio.create_task(self.market.websocket_loop())
|
self._ws_task = asyncio.create_task(self.market.websocket_loop())
|
||||||
self._loop_task = asyncio.create_task(self._run_loop())
|
self._loop_task = asyncio.create_task(self._run_loop())
|
||||||
@@ -72,7 +94,13 @@ class CryptoSpotBot:
|
|||||||
task.cancel()
|
task.cancel()
|
||||||
if tasks:
|
if tasks:
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
self.storage.event("Бот остановлен")
|
self._safe_event("Бот остановлен")
|
||||||
|
|
||||||
|
def _safe_event(self, message: str, level: str = "INFO") -> None:
|
||||||
|
try:
|
||||||
|
self.storage.event(message, level)
|
||||||
|
except sqlite3.Error:
|
||||||
|
logger.exception("Could not persist non-critical bot event: %s", message)
|
||||||
|
|
||||||
async def _run_loop(self) -> None:
|
async def _run_loop(self) -> None:
|
||||||
while self.running:
|
while self.running:
|
||||||
@@ -80,6 +108,7 @@ class CryptoSpotBot:
|
|||||||
rest_refresh_seconds = self._rest_refresh_seconds()
|
rest_refresh_seconds = self._rest_refresh_seconds()
|
||||||
if self._needs_rest_refresh(rest_refresh_seconds):
|
if self._needs_rest_refresh(rest_refresh_seconds):
|
||||||
await asyncio.to_thread(self.market.refresh_rest)
|
await asyncio.to_thread(self.market.refresh_rest)
|
||||||
|
await self._maintain_runtime()
|
||||||
self.broker.update_highs(self.market.tickers)
|
self.broker.update_highs(self.market.tickers)
|
||||||
self._update_patterns()
|
self._update_patterns()
|
||||||
self._update_forecasts()
|
self._update_forecasts()
|
||||||
@@ -88,9 +117,11 @@ class CryptoSpotBot:
|
|||||||
await self._process_entries()
|
await self._process_entries()
|
||||||
self.broker.mark_equity(self.market.prices())
|
self.broker.mark_equity(self.market.prices())
|
||||||
self.last_loop_at = utc_now()
|
self.last_loop_at = utc_now()
|
||||||
|
self._consecutive_loop_errors = 0
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
self._consecutive_loop_errors += 1
|
||||||
self.message = f"ошибка цикла: {exc}"
|
self.message = f"ошибка цикла: {exc}"
|
||||||
self.storage.event(self.message, "ERROR")
|
self.storage.event(self.message, "ERROR")
|
||||||
await asyncio.sleep(self.settings.effective_loop_interval_seconds)
|
await asyncio.sleep(self.settings.effective_loop_interval_seconds)
|
||||||
@@ -110,6 +141,18 @@ class CryptoSpotBot:
|
|||||||
prices = self.market.prices()
|
prices = self.market.prices()
|
||||||
reduction_candidate_id = self._reduction_candidate_id(prices)
|
reduction_candidate_id = self._reduction_candidate_id(prices)
|
||||||
for position in list(self.broker.open_positions()):
|
for position in list(self.broker.open_positions()):
|
||||||
|
freshness = self.market.symbol_freshness(position.symbol)
|
||||||
|
if not freshness["ok"]:
|
||||||
|
self._record_signal(
|
||||||
|
Signal(
|
||||||
|
position.symbol,
|
||||||
|
"HOLD",
|
||||||
|
0.0,
|
||||||
|
"market data is stale; exchange protective stop remains authoritative",
|
||||||
|
{"market_freshness": freshness},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
ticker = self.market.tickers.get(position.symbol)
|
ticker = self.market.tickers.get(position.symbol)
|
||||||
candles = self.market.candles.get(position.symbol, [])
|
candles = self.market.candles.get(position.symbol, [])
|
||||||
forecast = self.market.forecasts.get(position.symbol, {})
|
forecast = self.market.forecasts.get(position.symbol, {})
|
||||||
@@ -117,25 +160,40 @@ class CryptoSpotBot:
|
|||||||
adaptive_rules["reduce_now"] = position.id is not None and position.id == reduction_candidate_id
|
adaptive_rules["reduce_now"] = position.id is not None and position.id == reduction_candidate_id
|
||||||
learning = {"adaptive_rules": adaptive_rules}
|
learning = {"adaptive_rules": adaptive_rules}
|
||||||
signal = self.strategy.exit_signal(position, candles, ticker, learning, forecast)
|
signal = self.strategy.exit_signal(position, candles, ticker, learning, forecast)
|
||||||
self.storage.insert_signal(signal)
|
self._record_signal(signal)
|
||||||
if signal.action == "SELL" and ticker is not None:
|
if signal.action == "SELL" and ticker is not None:
|
||||||
self.broker.sell(position, ticker, signal.reason)
|
await asyncio.to_thread(self.broker.sell, position, ticker, signal.reason)
|
||||||
self._entry_cooldown_until[position.symbol] = utc_now()
|
self._entry_cooldown_until[position.symbol] = utc_now()
|
||||||
|
|
||||||
async def _process_entries(self) -> None:
|
async def _process_entries(self) -> None:
|
||||||
prices = self.market.prices()
|
prices = self.market.prices()
|
||||||
risk_guard = risk_guard_snapshot(
|
risk_guard = risk_guard_snapshot(
|
||||||
self.settings,
|
self.settings,
|
||||||
self.storage.closed_trades(self.settings.learning_lookback_trades),
|
self.storage.closed_trades(
|
||||||
self.storage.latest_equity(),
|
self.settings.learning_lookback_trades,
|
||||||
|
mode=self.settings.trading_mode,
|
||||||
|
),
|
||||||
|
self.storage.latest_equity(mode=self.settings.trading_mode),
|
||||||
)
|
)
|
||||||
for symbol in self.market.symbols:
|
for symbol in self.market.symbols:
|
||||||
|
freshness = self.market.symbol_freshness(symbol)
|
||||||
|
if not freshness["ok"]:
|
||||||
|
self._record_signal(
|
||||||
|
Signal(
|
||||||
|
symbol,
|
||||||
|
"HOLD",
|
||||||
|
0.0,
|
||||||
|
"market data is stale; new entries blocked",
|
||||||
|
{"market_freshness": freshness, "checks": {"market_fresh": False}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
cooldown_since = self._entry_cooldown_until.get(symbol)
|
cooldown_since = self._entry_cooldown_until.get(symbol)
|
||||||
if cooldown_since:
|
if cooldown_since:
|
||||||
age = (utc_now() - cooldown_since).total_seconds()
|
age = (utc_now() - cooldown_since).total_seconds()
|
||||||
cooldown_seconds = self.settings.effective_entry_cooldown_seconds
|
cooldown_seconds = self.settings.effective_entry_cooldown_seconds
|
||||||
if age < cooldown_seconds:
|
if age < cooldown_seconds:
|
||||||
self.storage.insert_signal(
|
self._record_signal(
|
||||||
Signal(
|
Signal(
|
||||||
symbol,
|
symbol,
|
||||||
"HOLD",
|
"HOLD",
|
||||||
@@ -162,7 +220,7 @@ class CryptoSpotBot:
|
|||||||
account["open_positions_for_symbol"] = open_count
|
account["open_positions_for_symbol"] = open_count
|
||||||
account["exchange_min_entry_usdt"] = self.broker.minimum_entry_budget(instrument, ticker)
|
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._record_signal(
|
||||||
Signal(
|
Signal(
|
||||||
symbol,
|
symbol,
|
||||||
"HOLD",
|
"HOLD",
|
||||||
@@ -178,7 +236,7 @@ class CryptoSpotBot:
|
|||||||
continue
|
continue
|
||||||
symbol_guard = self._risk_guard_for_symbol(risk_guard, symbol)
|
symbol_guard = self._risk_guard_for_symbol(risk_guard, symbol)
|
||||||
if symbol_guard.get("block_new_entries"):
|
if symbol_guard.get("block_new_entries"):
|
||||||
self.storage.insert_signal(
|
self._record_signal(
|
||||||
Signal(
|
Signal(
|
||||||
symbol,
|
symbol,
|
||||||
"HOLD",
|
"HOLD",
|
||||||
@@ -224,9 +282,10 @@ class CryptoSpotBot:
|
|||||||
account,
|
account,
|
||||||
trend_candles,
|
trend_candles,
|
||||||
)
|
)
|
||||||
self.storage.insert_signal(signal)
|
self._record_signal(signal)
|
||||||
if signal.action == "BUY" and ticker is not None:
|
if signal.action == "BUY" and ticker is not None:
|
||||||
position = self.broker.buy(
|
position = await asyncio.to_thread(
|
||||||
|
self.broker.buy,
|
||||||
signal,
|
signal,
|
||||||
ticker,
|
ticker,
|
||||||
instrument,
|
instrument,
|
||||||
@@ -235,6 +294,41 @@ class CryptoSpotBot:
|
|||||||
if position is not None:
|
if position is not None:
|
||||||
self._entry_cooldown_until[symbol] = utc_now()
|
self._entry_cooldown_until[symbol] = utc_now()
|
||||||
|
|
||||||
|
def _record_signal(self, signal: Signal) -> None:
|
||||||
|
self.storage.insert_signal(signal, self.settings.hold_signal_sample_seconds)
|
||||||
|
|
||||||
|
async def _maintain_runtime(self) -> None:
|
||||||
|
now = utc_now()
|
||||||
|
if isinstance(self.broker, LiveBroker):
|
||||||
|
age = (
|
||||||
|
(now - self._last_reconciliation_at).total_seconds()
|
||||||
|
if self._last_reconciliation_at
|
||||||
|
else float("inf")
|
||||||
|
)
|
||||||
|
if age >= self.settings.live_reconciliation_interval_seconds:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(self.broker.reconcile, self.market.instruments)
|
||||||
|
except Exception as exc:
|
||||||
|
self.broker.reconciliation_state = {
|
||||||
|
"status": "error",
|
||||||
|
"blocking": True,
|
||||||
|
"discrepancies": [
|
||||||
|
{"code": "periodic_reconciliation_failed", "message": str(exc)}
|
||||||
|
],
|
||||||
|
"checked_at": utc_now().isoformat(),
|
||||||
|
}
|
||||||
|
self.storage.event(f"Periodic live reconciliation failed: {exc}", "ERROR")
|
||||||
|
finally:
|
||||||
|
self._last_reconciliation_at = utc_now()
|
||||||
|
prune_age = (
|
||||||
|
(now - self._last_prune_at).total_seconds()
|
||||||
|
if self._last_prune_at
|
||||||
|
else float("inf")
|
||||||
|
)
|
||||||
|
if prune_age >= self.settings.storage_prune_interval_seconds:
|
||||||
|
await asyncio.to_thread(self.storage.prune, self.settings.storage_retention_days)
|
||||||
|
self._last_prune_at = 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:
|
||||||
rows = risk_guard.get("symbols")
|
rows = risk_guard.get("symbols")
|
||||||
@@ -334,16 +428,70 @@ class CryptoSpotBot:
|
|||||||
self.market.forecasts = forecasts
|
self.market.forecasts = forecasts
|
||||||
|
|
||||||
def status(self) -> BotStatus:
|
def status(self) -> BotStatus:
|
||||||
|
live_ready = self.settings.live_ready
|
||||||
|
if isinstance(self.broker, LiveBroker):
|
||||||
|
live_ready = live_ready and not self.broker.reconciliation_state.get("blocking", True)
|
||||||
return BotStatus(
|
return BotStatus(
|
||||||
running=self.running,
|
running=self.running,
|
||||||
mode=self.settings.trading_mode,
|
mode=self.settings.trading_mode,
|
||||||
live_trading_ready=self.settings.live_ready,
|
live_trading_ready=live_ready,
|
||||||
symbols=self.market.symbols,
|
symbols=self.market.symbols,
|
||||||
started_at=self.started_at,
|
started_at=self.started_at,
|
||||||
last_loop_at=self.last_loop_at,
|
last_loop_at=self.last_loop_at,
|
||||||
message=self.message,
|
message=self.message,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def readiness_snapshot(self) -> dict:
|
||||||
|
reasons: list[str] = []
|
||||||
|
now = utc_now()
|
||||||
|
if not self.running:
|
||||||
|
reasons.append("bot_not_running")
|
||||||
|
max_loop_age = max(30.0, self.settings.effective_loop_interval_seconds * 4)
|
||||||
|
loop_age = (now - self.last_loop_at).total_seconds() if self.last_loop_at else None
|
||||||
|
if loop_age is None or loop_age > max_loop_age:
|
||||||
|
reasons.append("decision_loop_stale")
|
||||||
|
stale_symbols = [
|
||||||
|
symbol for symbol in self.market.symbols if not self.market.symbol_freshness(symbol)["ok"]
|
||||||
|
]
|
||||||
|
if stale_symbols:
|
||||||
|
reasons.append("stale_market_data")
|
||||||
|
if self._consecutive_loop_errors >= 3:
|
||||||
|
reasons.append("repeated_loop_errors")
|
||||||
|
if self.settings.strategy_mode == "torch_forecast":
|
||||||
|
invalid_models = []
|
||||||
|
for symbol in self.market.symbols:
|
||||||
|
forecast = self.market.forecasts.get(symbol, {})
|
||||||
|
if torch_model_readiness_reasons(self.settings, forecast):
|
||||||
|
invalid_models.append(symbol)
|
||||||
|
if invalid_models:
|
||||||
|
if self.settings.time_series_trend_fallback_enabled:
|
||||||
|
forecast_fallback_active = True
|
||||||
|
else:
|
||||||
|
forecast_fallback_active = False
|
||||||
|
reasons.append("forecast_model_not_ready")
|
||||||
|
else:
|
||||||
|
forecast_fallback_active = False
|
||||||
|
else:
|
||||||
|
invalid_models = []
|
||||||
|
forecast_fallback_active = False
|
||||||
|
reconciliation: dict = {}
|
||||||
|
if isinstance(self.broker, LiveBroker):
|
||||||
|
reconciliation = dict(self.broker.reconciliation_state)
|
||||||
|
if reconciliation.get("blocking", True):
|
||||||
|
reasons.append("live_reconciliation_blocking")
|
||||||
|
return {
|
||||||
|
"ready": not reasons,
|
||||||
|
"mode": self.settings.trading_mode,
|
||||||
|
"reasons": reasons,
|
||||||
|
"loop_age_seconds": round(loop_age, 3) if loop_age is not None else None,
|
||||||
|
"stale_symbols": stale_symbols,
|
||||||
|
"consecutive_loop_errors": self._consecutive_loop_errors,
|
||||||
|
"reconciliation": reconciliation,
|
||||||
|
"forecast_model_ready": not invalid_models,
|
||||||
|
"forecast_fallback_active": forecast_fallback_active,
|
||||||
|
"forecast_invalid_symbols": invalid_models,
|
||||||
|
}
|
||||||
|
|
||||||
def account_snapshot(self) -> dict[str, float]:
|
def account_snapshot(self) -> dict[str, float]:
|
||||||
prices = self.market.prices()
|
prices = self.market.prices()
|
||||||
state = self.broker.account_state(prices)
|
state = self.broker.account_state(prices)
|
||||||
|
|||||||
+181
-4
@@ -9,6 +9,8 @@ from typing import Any
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.retry import Retry
|
||||||
|
|
||||||
from crypto_spot_bot.config import Settings
|
from crypto_spot_bot.config import Settings
|
||||||
from crypto_spot_bot.models import Candle, Ticker
|
from crypto_spot_bot.models import Candle, Ticker
|
||||||
@@ -40,14 +42,47 @@ class Instrument:
|
|||||||
class BybitClient:
|
class BybitClient:
|
||||||
def __init__(self, settings: Settings):
|
def __init__(self, settings: Settings):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.session = requests.Session()
|
self.session = self._build_session()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_session() -> requests.Session:
|
||||||
|
session = requests.Session()
|
||||||
|
retry = Retry(
|
||||||
|
total=3,
|
||||||
|
connect=3,
|
||||||
|
read=3,
|
||||||
|
status=3,
|
||||||
|
backoff_factor=0.4,
|
||||||
|
status_forcelist=(429, 500, 502, 503, 504),
|
||||||
|
allowed_methods=frozenset({"GET"}),
|
||||||
|
respect_retry_after_header=True,
|
||||||
|
)
|
||||||
|
session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _reset_session(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.session = self._build_session()
|
||||||
|
|
||||||
def public_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
def public_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
f"{self.settings.rest_base_url}{path}",
|
f"{self.settings.rest_base_url}{path}",
|
||||||
params=params,
|
params=params,
|
||||||
timeout=12,
|
timeout=12,
|
||||||
)
|
)
|
||||||
|
break
|
||||||
|
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
||||||
|
if attempt >= 2:
|
||||||
|
raise
|
||||||
|
# A failed TLS session can remain poisoned in urllib3's pool.
|
||||||
|
# Recreate the pool before retrying instead of reusing it.
|
||||||
|
self._reset_session()
|
||||||
|
time.sleep(0.5 * (2**attempt))
|
||||||
|
if response is None: # pragma: no cover - loop either returns or raises.
|
||||||
|
raise BybitError("Bybit public request produced no response")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return self._unwrap(response.json())
|
return self._unwrap(response.json())
|
||||||
|
|
||||||
@@ -208,27 +243,165 @@ class BybitClient:
|
|||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"side": side,
|
"side": side,
|
||||||
"orderType": "Market",
|
"orderType": "Market",
|
||||||
"qty": f"{qty:.8f}".rstrip("0").rstrip("."),
|
"qty": _decimal_text(qty),
|
||||||
"timeInForce": "IOC",
|
"timeInForce": "IOC",
|
||||||
"isLeverage": 0,
|
"isLeverage": 0,
|
||||||
"orderFilter": "Order",
|
"orderFilter": "Order",
|
||||||
"marketUnit": market_unit,
|
"marketUnit": market_unit,
|
||||||
"orderLinkId": order_link_id,
|
"orderLinkId": order_link_id,
|
||||||
}
|
}
|
||||||
|
slippage_percent = max(0.01, min(10.0, self.settings.slippage_rate * 100.0))
|
||||||
|
payload["slippageToleranceType"] = "Percent"
|
||||||
|
payload["slippageTolerance"] = f"{slippage_percent:.2f}"
|
||||||
return self.private_post("/v5/order/create", payload)
|
return self.private_post("/v5/order/create", payload)
|
||||||
|
|
||||||
|
def place_spot_protective_stop(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
qty: float,
|
||||||
|
trigger_price: float,
|
||||||
|
order_link_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"category": "spot",
|
||||||
|
"symbol": symbol,
|
||||||
|
"side": "Sell",
|
||||||
|
"orderType": "Market",
|
||||||
|
"qty": _decimal_text(qty),
|
||||||
|
"triggerPrice": _decimal_text(trigger_price),
|
||||||
|
"timeInForce": "IOC",
|
||||||
|
"isLeverage": 0,
|
||||||
|
"orderFilter": "tpslOrder",
|
||||||
|
"marketUnit": "baseCoin",
|
||||||
|
"orderLinkId": order_link_id,
|
||||||
|
}
|
||||||
|
return self.private_post("/v5/order/create", payload)
|
||||||
|
|
||||||
|
def cancel_spot_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
order_id: str | None = None,
|
||||||
|
order_link_id: str | None = None,
|
||||||
|
order_filter: str = "Order",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not order_id and not order_link_id:
|
||||||
|
raise ValueError("order_id or order_link_id is required")
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"category": "spot",
|
||||||
|
"symbol": symbol,
|
||||||
|
"orderFilter": order_filter,
|
||||||
|
}
|
||||||
|
if order_id:
|
||||||
|
payload["orderId"] = order_id
|
||||||
|
if order_link_id:
|
||||||
|
payload["orderLinkId"] = order_link_id
|
||||||
|
return self.private_post("/v5/order/cancel", payload)
|
||||||
|
|
||||||
def wallet_balance(self, account_type: str = "UNIFIED", coin: str | None = None) -> dict[str, Any]:
|
def wallet_balance(self, account_type: str = "UNIFIED", coin: str | None = None) -> dict[str, Any]:
|
||||||
return self.private_get(
|
return self.private_get(
|
||||||
"/v5/account/wallet-balance",
|
"/v5/account/wallet-balance",
|
||||||
{"accountType": account_type, "coin": coin},
|
{"accountType": account_type, "coin": coin},
|
||||||
)
|
)
|
||||||
|
|
||||||
def realtime_orders(self, *, category: str = "spot", open_only: int = 0, limit: int = 50) -> dict[str, Any]:
|
def realtime_orders(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
category: str = "spot",
|
||||||
|
open_only: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
symbol: str | None = None,
|
||||||
|
order_id: str | None = None,
|
||||||
|
order_link_id: str | None = None,
|
||||||
|
order_filter: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
return self.private_get(
|
return self.private_get(
|
||||||
"/v5/order/realtime",
|
"/v5/order/realtime",
|
||||||
{"category": category, "openOnly": open_only, "limit": max(1, min(limit, 50))},
|
{
|
||||||
|
"category": category,
|
||||||
|
"openOnly": open_only,
|
||||||
|
"limit": max(1, min(limit, 50)),
|
||||||
|
"symbol": symbol,
|
||||||
|
"orderId": order_id,
|
||||||
|
"orderLinkId": order_link_id,
|
||||||
|
"orderFilter": order_filter,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def order_history(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str | None = None,
|
||||||
|
order_id: str | None = None,
|
||||||
|
order_link_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self.private_get(
|
||||||
|
"/v5/order/history",
|
||||||
|
{
|
||||||
|
"category": "spot",
|
||||||
|
"symbol": symbol,
|
||||||
|
"orderId": order_id,
|
||||||
|
"orderLinkId": order_link_id,
|
||||||
|
"limit": max(1, min(limit, 50)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def executions(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str | None = None,
|
||||||
|
order_id: str | None = None,
|
||||||
|
order_link_id: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self.private_get(
|
||||||
|
"/v5/execution/list",
|
||||||
|
{
|
||||||
|
"category": "spot",
|
||||||
|
"symbol": symbol,
|
||||||
|
"orderId": order_id,
|
||||||
|
"orderLinkId": order_link_id,
|
||||||
|
"limit": max(1, min(limit, 100)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def wait_for_spot_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: str,
|
||||||
|
symbol: str,
|
||||||
|
timeout_seconds: float,
|
||||||
|
poll_seconds: float = 0.5,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
deadline = time.monotonic() + max(1.0, timeout_seconds)
|
||||||
|
latest: dict[str, Any] = {}
|
||||||
|
terminal = {
|
||||||
|
"Filled",
|
||||||
|
"Cancelled",
|
||||||
|
"Rejected",
|
||||||
|
"PartiallyFilledCanceled",
|
||||||
|
"PartillyFilledCancelled",
|
||||||
|
"Deactivated",
|
||||||
|
}
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
realtime = self.realtime_orders(symbol=symbol, order_id=order_id, open_only=1, limit=1)
|
||||||
|
rows = realtime.get("list") if isinstance(realtime.get("list"), list) else []
|
||||||
|
if rows and isinstance(rows[0], dict):
|
||||||
|
latest = rows[0]
|
||||||
|
if str(latest.get("orderStatus", "")) in terminal:
|
||||||
|
break
|
||||||
|
time.sleep(max(0.1, poll_seconds))
|
||||||
|
if not latest or str(latest.get("orderStatus", "")) not in terminal:
|
||||||
|
history = self.order_history(symbol=symbol, order_id=order_id, limit=1)
|
||||||
|
rows = history.get("list") if isinstance(history.get("list"), list) else []
|
||||||
|
if rows and isinstance(rows[0], dict):
|
||||||
|
latest = rows[0]
|
||||||
|
execution_result = self.executions(symbol=symbol, order_id=order_id)
|
||||||
|
executions = execution_result.get("list") if isinstance(execution_result.get("list"), list) else []
|
||||||
|
return {"order": latest, "executions": executions}
|
||||||
|
|
||||||
|
|
||||||
def websocket_subscribe_message(symbols: list[str], interval: str = "1") -> str:
|
def websocket_subscribe_message(symbols: list[str], interval: str = "1") -> str:
|
||||||
args: list[str] = []
|
args: list[str] = []
|
||||||
@@ -254,3 +427,7 @@ def _looks_like_stablecoin(base_coin: str) -> bool:
|
|||||||
"PYUSD",
|
"PYUSD",
|
||||||
"USD1",
|
"USD1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _decimal_text(value: float) -> str:
|
||||||
|
return f"{value:.12f}".rstrip("0").rstrip(".")
|
||||||
|
|||||||
@@ -137,11 +137,13 @@ class Settings:
|
|||||||
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
|
time_series_rebound_fallback_enabled: bool
|
||||||
|
time_series_trend_fallback_enabled: bool
|
||||||
stop_loss_percent: float
|
stop_loss_percent: float
|
||||||
stop_loss_exit_enabled: bool
|
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
|
||||||
@@ -153,6 +155,20 @@ class Settings:
|
|||||||
database_path: Path
|
database_path: Path
|
||||||
log_path: Path
|
log_path: Path
|
||||||
env_file_path: Path
|
env_file_path: Path
|
||||||
|
api_auth_token: str = ""
|
||||||
|
training_worker_token: str = ""
|
||||||
|
trusted_proxy_user_header: str = ""
|
||||||
|
time_series_require_quality_gate: bool = False
|
||||||
|
time_series_manual_quality_override: bool = False
|
||||||
|
time_series_require_fresh_model: bool = False
|
||||||
|
time_series_model_max_age_hours: float = 48.0
|
||||||
|
market_ticker_max_age_seconds: float = 45.0
|
||||||
|
live_order_fill_timeout_seconds: float = 20.0
|
||||||
|
live_reconciliation_interval_seconds: float = 30.0
|
||||||
|
live_protective_stop_enabled: bool = True
|
||||||
|
hold_signal_sample_seconds: int = 60
|
||||||
|
storage_retention_days: int = 30
|
||||||
|
storage_prune_interval_seconds: int = 3600
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rest_base_url(self) -> str:
|
def rest_base_url(self) -> str:
|
||||||
@@ -288,12 +304,14 @@ 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),
|
time_series_rebound_fallback_enabled=_bool_env("TIME_SERIES_REBOUND_FALLBACK_ENABLED", False),
|
||||||
|
time_series_trend_fallback_enabled=_bool_env("TIME_SERIES_TREND_FALLBACK_ENABLED", False),
|
||||||
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),
|
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),
|
||||||
@@ -305,7 +323,28 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
database_path=Path(os.getenv("DATABASE_PATH", "runtime/tradebot.sqlite3")),
|
database_path=Path(os.getenv("DATABASE_PATH", "runtime/tradebot.sqlite3")),
|
||||||
log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")),
|
log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")),
|
||||||
env_file_path=env_path,
|
env_file_path=env_path,
|
||||||
|
api_auth_token=os.getenv("TRADEBOT_API_TOKEN", "").strip(),
|
||||||
|
training_worker_token=os.getenv("TRADEBOT_TRAINING_TOKEN", "").strip(),
|
||||||
|
trusted_proxy_user_header=os.getenv("TRUSTED_PROXY_USER_HEADER", "").strip(),
|
||||||
|
time_series_require_quality_gate=_bool_env(
|
||||||
|
"TIME_SERIES_REQUIRE_QUALITY_GATE", strategy_mode == "torch_forecast"
|
||||||
|
),
|
||||||
|
time_series_manual_quality_override=_bool_env(
|
||||||
|
"TIME_SERIES_MANUAL_QUALITY_OVERRIDE", False
|
||||||
|
),
|
||||||
|
time_series_require_fresh_model=_bool_env(
|
||||||
|
"TIME_SERIES_REQUIRE_FRESH_MODEL", strategy_mode == "torch_forecast"
|
||||||
|
),
|
||||||
|
time_series_model_max_age_hours=_float_env("TIME_SERIES_MODEL_MAX_AGE_HOURS", 48.0),
|
||||||
|
market_ticker_max_age_seconds=_float_env("MARKET_TICKER_MAX_AGE_SECONDS", 45.0),
|
||||||
|
live_order_fill_timeout_seconds=_float_env("LIVE_ORDER_FILL_TIMEOUT_SECONDS", 20.0),
|
||||||
|
live_reconciliation_interval_seconds=_float_env("LIVE_RECONCILIATION_INTERVAL_SECONDS", 30.0),
|
||||||
|
live_protective_stop_enabled=_bool_env("LIVE_PROTECTIVE_STOP_ENABLED", True),
|
||||||
|
hold_signal_sample_seconds=_int_env("HOLD_SIGNAL_SAMPLE_SECONDS", 60),
|
||||||
|
storage_retention_days=_int_env("STORAGE_RETENTION_DAYS", 30),
|
||||||
|
storage_prune_interval_seconds=_int_env("STORAGE_PRUNE_INTERVAL_SECONDS", 3600),
|
||||||
)
|
)
|
||||||
|
_validate_settings(settings)
|
||||||
if settings.trading_mode == "live" and not settings.live_ready:
|
if settings.trading_mode == "live" and not settings.live_ready:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Live mode is locked. Set ENABLE_LIVE_TRADING=true, "
|
"Live mode is locked. Set ENABLE_LIVE_TRADING=true, "
|
||||||
@@ -314,6 +353,36 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_settings(settings: Settings) -> None:
|
||||||
|
errors: list[str] = []
|
||||||
|
if not 1 <= settings.port <= 65535:
|
||||||
|
errors.append("PORT must be in range 1..65535")
|
||||||
|
if settings.starting_balance_usdt <= 0:
|
||||||
|
errors.append("STARTING_BALANCE_USDT must be positive")
|
||||||
|
if settings.min_position_usdt < 0:
|
||||||
|
errors.append("MIN_POSITION_USDT must be non-negative")
|
||||||
|
if settings.max_position_usdt < settings.min_position_usdt:
|
||||||
|
errors.append("MAX_POSITION_USDT must be >= MIN_POSITION_USDT")
|
||||||
|
if settings.max_symbol_exposure_usdt < settings.min_position_usdt:
|
||||||
|
errors.append("MAX_SYMBOL_EXPOSURE_USDT must be >= MIN_POSITION_USDT")
|
||||||
|
if settings.max_total_exposure_usdt < settings.max_symbol_exposure_usdt:
|
||||||
|
errors.append("MAX_TOTAL_EXPOSURE_USDT must be >= MAX_SYMBOL_EXPOSURE_USDT")
|
||||||
|
if settings.max_open_positions < 1 or settings.max_positions_per_symbol < 1:
|
||||||
|
errors.append("position count limits must be positive")
|
||||||
|
if settings.taker_fee_rate < 0 or settings.slippage_rate < 0:
|
||||||
|
errors.append("TAKER_FEE_RATE and SLIPPAGE_RATE must be non-negative")
|
||||||
|
if settings.market_ticker_max_age_seconds <= 0:
|
||||||
|
errors.append("MARKET_TICKER_MAX_AGE_SECONDS must be positive")
|
||||||
|
if settings.time_series_model_max_age_hours <= 0:
|
||||||
|
errors.append("TIME_SERIES_MODEL_MAX_AGE_HOURS must be positive")
|
||||||
|
if settings.live_order_fill_timeout_seconds <= 0:
|
||||||
|
errors.append("LIVE_ORDER_FILL_TIMEOUT_SECONDS must be positive")
|
||||||
|
if settings.live_reconciliation_interval_seconds <= 0:
|
||||||
|
errors.append("LIVE_RECONCILIATION_INTERVAL_SECONDS must be positive")
|
||||||
|
if errors:
|
||||||
|
raise ValueError("; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
def update_env_value(path: Path, key: str, value: str) -> None:
|
def update_env_value(path: Path, key: str, value: str) -> None:
|
||||||
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
||||||
output: list[str] = []
|
output: list[str] = []
|
||||||
|
|||||||
+126
-30
@@ -1,13 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Response
|
from fastapi import Depends, FastAPI, HTTPException, Response
|
||||||
from fastapi.responses import 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.auth import ApiAuthorizer
|
||||||
from crypto_spot_bot.bot import CryptoSpotBot
|
from crypto_spot_bot.bot import CryptoSpotBot
|
||||||
from crypto_spot_bot.bybit import BybitClient
|
from crypto_spot_bot.bybit import BybitClient
|
||||||
from crypto_spot_bot.config import Settings, load_settings, update_env_value
|
from crypto_spot_bot.config import Settings, load_settings, update_env_value
|
||||||
@@ -23,6 +26,7 @@ from crypto_spot_bot.training_coordination import TrainingCoordinator
|
|||||||
|
|
||||||
|
|
||||||
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
|
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
@@ -44,6 +48,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
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)
|
training = TrainingCoordinator(settings.time_series_lstm_model_path.parent)
|
||||||
|
authorizer = ApiAuthorizer(settings)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
@@ -66,50 +71,62 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health() -> dict[str, Any]:
|
async def health() -> dict[str, Any]:
|
||||||
return {"ok": True, "running": bot.running, "mode": settings.trading_mode}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"running": bot.running,
|
||||||
|
"mode": settings.trading_mode,
|
||||||
|
"auth_configured": authorizer.configured(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/ready")
|
||||||
|
async def ready() -> JSONResponse:
|
||||||
|
payload = bot.readiness_snapshot()
|
||||||
|
return JSONResponse(payload, status_code=200 if payload["ready"] else 503)
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
async def status() -> dict[str, Any]:
|
async def status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"status": bot.status().as_dict(),
|
"status": bot.status().as_dict(),
|
||||||
"account": bot.account_snapshot(),
|
"account": bot.account_snapshot(),
|
||||||
"positions": bot.positions_snapshot(),
|
"positions": bot.positions_snapshot(),
|
||||||
"learning": bot.learning_snapshot(),
|
"learning": bot.learning_snapshot(),
|
||||||
"latest_equity": storage.latest_equity(),
|
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
|
||||||
|
"readiness": bot.readiness_snapshot(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/markets")
|
@app.get("/api/markets")
|
||||||
async def markets() -> dict[str, Any]:
|
async def markets(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return market.snapshot()
|
return market.snapshot()
|
||||||
|
|
||||||
@app.get("/api/trades")
|
@app.get("/api/trades")
|
||||||
async def trades(limit: int = 80) -> dict[str, Any]:
|
async def trades(limit: int = 80, _: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
row_limit = _limit(limit)
|
row_limit = _limit(limit)
|
||||||
return {
|
return {
|
||||||
"items": storage.recent_trades(row_limit),
|
"items": storage.recent_trades(row_limit, mode=settings.trading_mode),
|
||||||
"closed_items": storage.closed_trades(row_limit),
|
"closed_items": storage.closed_trades(row_limit, mode=settings.trading_mode),
|
||||||
"closed_summary": storage.closed_trade_summary(),
|
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/signals")
|
@app.get("/api/signals")
|
||||||
async def signals(limit: int = 120) -> dict[str, Any]:
|
async def signals(limit: int = 120, _: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return {"items": storage.recent_signals(_limit(limit))}
|
return {"items": storage.recent_signals(_limit(limit))}
|
||||||
|
|
||||||
@app.get("/api/events")
|
@app.get("/api/events")
|
||||||
async def events(limit: int = 120) -> dict[str, Any]:
|
async def events(limit: int = 120, _: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return {"items": storage.recent_events(_limit(limit))}
|
return {"items": storage.recent_events(_limit(limit))}
|
||||||
|
|
||||||
@app.get("/api/analytics")
|
@app.get("/api/analytics")
|
||||||
async def analytics() -> dict[str, Any]:
|
async def analytics(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return analytics_snapshot(settings, storage)
|
return analytics_snapshot(settings, storage)
|
||||||
|
|
||||||
@app.get("/api/quality")
|
@app.get("/api/quality")
|
||||||
async def quality() -> dict[str, Any]:
|
async def quality(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return market.snapshot().get("quality", {})
|
return market.snapshot().get("quality", {})
|
||||||
|
|
||||||
@app.get("/api/reconciliation")
|
@app.get("/api/reconciliation")
|
||||||
async def reconciliation() -> dict[str, Any]:
|
async def reconciliation(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return reconciliation_snapshot(
|
return await asyncio.to_thread(
|
||||||
|
reconciliation_snapshot,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
storage=storage,
|
storage=storage,
|
||||||
client=client,
|
client=client,
|
||||||
@@ -117,58 +134,113 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/backtest")
|
@app.get("/api/backtest")
|
||||||
async def backtest() -> dict[str, Any]:
|
async def backtest(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return _runtime_json(settings, "torch_threshold_calibration.json")
|
return _runtime_json(settings, "torch_threshold_calibration.json")
|
||||||
|
|
||||||
@app.get("/api/retrain")
|
@app.get("/api/retrain")
|
||||||
async def retrain() -> dict[str, Any]:
|
async def retrain(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
data = _runtime_json(settings, "torch_retrain_guard.json")
|
data = _runtime_json(settings, "torch_retrain_guard.json")
|
||||||
data["coordination"] = training.status()
|
data["coordination"] = training.status()
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@app.get("/api/training/status")
|
@app.get("/api/training/status")
|
||||||
async def training_status() -> dict[str, Any]:
|
async def training_status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return training.status()
|
return training.status()
|
||||||
|
|
||||||
@app.post("/api/training/retrain")
|
@app.post("/api/training/retrain")
|
||||||
async def training_retrain(payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
async def training_retrain(
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
_: None = Depends(authorizer.require),
|
||||||
|
) -> dict[str, Any]:
|
||||||
return training.request_retrain(payload)
|
return training.request_retrain(payload)
|
||||||
|
|
||||||
@app.post("/api/training/heartbeat")
|
@app.post("/api/training/heartbeat")
|
||||||
async def training_heartbeat(payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
async def training_heartbeat(
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
_: None = Depends(authorizer.require_training),
|
||||||
|
) -> dict[str, Any]:
|
||||||
return training.heartbeat(payload)
|
return training.heartbeat(payload)
|
||||||
|
|
||||||
@app.post("/api/training/claim")
|
@app.post("/api/training/claim")
|
||||||
async def training_claim(payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
async def training_claim(
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
_: None = Depends(authorizer.require_training),
|
||||||
|
) -> dict[str, Any]:
|
||||||
return training.claim(payload)
|
return training.claim(payload)
|
||||||
|
|
||||||
@app.post("/api/training/jobs/{job_id}/artifacts/chunk")
|
@app.post("/api/training/jobs/{job_id}/artifacts/chunk")
|
||||||
async def training_artifact_chunk(job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
async def training_artifact_chunk(
|
||||||
|
job_id: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
_: None = Depends(authorizer.require_training),
|
||||||
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return training.save_artifact_chunk(job_id, payload)
|
return training.save_artifact_chunk(job_id, payload)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.post("/api/training/jobs/{job_id}/progress")
|
@app.post("/api/training/jobs/{job_id}/progress")
|
||||||
async def training_progress(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
async def training_progress(
|
||||||
|
job_id: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
_: None = Depends(authorizer.require_training),
|
||||||
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return training.progress(job_id, payload)
|
return training.progress(job_id, payload)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.post("/api/training/jobs/{job_id}/complete")
|
@app.post("/api/training/jobs/{job_id}/complete")
|
||||||
async def training_complete(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
async def training_complete(
|
||||||
|
job_id: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
_: None = Depends(authorizer.require_training),
|
||||||
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return training.complete(job_id, payload)
|
return training.complete(job_id, payload)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.get("/api/config")
|
@app.get("/api/config")
|
||||||
async def config() -> dict[str, Any]:
|
async def config(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return _safe_config(settings)
|
return _safe_config(settings)
|
||||||
|
|
||||||
|
@app.get("/api/mobile/snapshot")
|
||||||
|
async def mobile_snapshot(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
|
row_limit = 220
|
||||||
|
retrain_data = _runtime_json(settings, "torch_retrain_guard.json")
|
||||||
|
retrain_data["coordination"] = training.status()
|
||||||
|
return {
|
||||||
|
"health": {
|
||||||
|
"ok": True,
|
||||||
|
"running": bot.running,
|
||||||
|
"mode": settings.trading_mode,
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"status": bot.status().as_dict(),
|
||||||
|
"account": bot.account_snapshot(),
|
||||||
|
"positions": bot.positions_snapshot(),
|
||||||
|
"learning": bot.learning_snapshot(),
|
||||||
|
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
|
||||||
|
"readiness": bot.readiness_snapshot(),
|
||||||
|
},
|
||||||
|
"markets": market.snapshot(),
|
||||||
|
"signals": {"items": storage.recent_signals(row_limit)},
|
||||||
|
"config": _safe_config(settings),
|
||||||
|
"trades": {
|
||||||
|
"items": storage.recent_trades(10, mode=settings.trading_mode),
|
||||||
|
"closed_items": storage.closed_trades(10, mode=settings.trading_mode),
|
||||||
|
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
|
||||||
|
},
|
||||||
|
"retrain": retrain_data,
|
||||||
|
"backtest": _runtime_json(settings, "torch_threshold_calibration.json"),
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/api/config/fast-trading")
|
@app.post("/api/config/fast-trading")
|
||||||
async def set_fast_trading(payload: dict[str, Any]) -> dict[str, Any]:
|
async def set_fast_trading(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
_: None = Depends(authorizer.require),
|
||||||
|
) -> dict[str, Any]:
|
||||||
enabled = _enabled_from_payload(payload)
|
enabled = _enabled_from_payload(payload)
|
||||||
env_persisted = _apply_fast_trading(settings, storage, enabled)
|
env_persisted = _apply_fast_trading(settings, storage, enabled)
|
||||||
response = _safe_config(settings)
|
response = _safe_config(settings)
|
||||||
@@ -176,12 +248,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
@app.post("/api/control/start")
|
@app.post("/api/control/start")
|
||||||
async def start() -> dict[str, Any]:
|
async def start(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
await bot.start()
|
await bot.start()
|
||||||
return bot.status().as_dict()
|
return bot.status().as_dict()
|
||||||
|
|
||||||
@app.post("/api/control/stop")
|
@app.post("/api/control/stop")
|
||||||
async def stop() -> dict[str, Any]:
|
async def stop(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
await bot.stop()
|
await bot.stop()
|
||||||
return bot.status().as_dict()
|
return bot.status().as_dict()
|
||||||
|
|
||||||
@@ -207,13 +279,22 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
"# HELP tradebot_loop_interval_seconds Effective bot decision loop interval.",
|
"# HELP tradebot_loop_interval_seconds Effective bot decision loop interval.",
|
||||||
"# TYPE tradebot_loop_interval_seconds gauge",
|
"# TYPE tradebot_loop_interval_seconds gauge",
|
||||||
f"tradebot_loop_interval_seconds {settings.effective_loop_interval_seconds:.4f}",
|
f"tradebot_loop_interval_seconds {settings.effective_loop_interval_seconds:.4f}",
|
||||||
|
"# HELP tradebot_ready Whether trading prerequisites are ready.",
|
||||||
|
"# TYPE tradebot_ready gauge",
|
||||||
|
f"tradebot_ready {1 if bot.readiness_snapshot()['ready'] else 0}",
|
||||||
|
"# HELP tradebot_rest_errors_total REST refresh errors observed by market data.",
|
||||||
|
"# TYPE tradebot_rest_errors_total counter",
|
||||||
|
f"tradebot_rest_errors_total {market.rest_error_count}",
|
||||||
]
|
]
|
||||||
return PlainTextResponse("\n".join(lines) + "\n")
|
return PlainTextResponse("\n".join(lines) + "\n")
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def error_handler(_, exc: Exception) -> JSONResponse:
|
async def error_handler(_, exc: Exception) -> JSONResponse:
|
||||||
|
try:
|
||||||
storage.event(f"API error: {exc}", "ERROR")
|
storage.event(f"API error: {exc}", "ERROR")
|
||||||
return JSONResponse({"error": str(exc)}, status_code=500)
|
except Exception:
|
||||||
|
logger.exception("Could not persist API error event")
|
||||||
|
return JSONResponse({"error": "internal server error"}, status_code=500)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -317,12 +398,19 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
|||||||
"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_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
|
||||||
|
"time_series_trend_fallback_enabled": settings.time_series_trend_fallback_enabled,
|
||||||
|
"time_series_require_quality_gate": settings.time_series_require_quality_gate,
|
||||||
|
"time_series_manual_quality_override": settings.time_series_manual_quality_override,
|
||||||
|
"time_series_require_fresh_model": settings.time_series_require_fresh_model,
|
||||||
|
"time_series_model_max_age_hours": settings.time_series_model_max_age_hours,
|
||||||
|
"market_ticker_max_age_seconds": settings.market_ticker_max_age_seconds,
|
||||||
"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,
|
"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,
|
||||||
@@ -330,6 +418,14 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
|||||||
"slippage_rate": settings.slippage_rate,
|
"slippage_rate": settings.slippage_rate,
|
||||||
"live_ready": settings.live_ready,
|
"live_ready": settings.live_ready,
|
||||||
"live_order_max_usdt": settings.live_order_max_usdt,
|
"live_order_max_usdt": settings.live_order_max_usdt,
|
||||||
|
"live_order_fill_timeout_seconds": settings.live_order_fill_timeout_seconds,
|
||||||
|
"live_reconciliation_interval_seconds": settings.live_reconciliation_interval_seconds,
|
||||||
|
"live_protective_stop_enabled": settings.live_protective_stop_enabled,
|
||||||
|
"api_auth_configured": bool(
|
||||||
|
settings.api_auth_token
|
||||||
|
or settings.training_worker_token
|
||||||
|
or settings.trusted_proxy_user_header
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+493
-13
@@ -3,7 +3,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, ROUND_UP
|
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||||
from typing import Iterable
|
from typing import Any, Iterable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from crypto_spot_bot.bybit import BybitClient, Instrument
|
from crypto_spot_bot.bybit import BybitClient, Instrument
|
||||||
@@ -38,9 +38,19 @@ class PaperBroker:
|
|||||||
def __init__(self, settings: Settings, storage: Storage):
|
def __init__(self, settings: Settings, storage: Storage):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
self.positions = storage.open_positions()
|
self.positions = storage.open_positions(settings.trading_mode)
|
||||||
self.cash = float(storage.get_runtime("paper_cash", settings.starting_balance_usdt))
|
self.cash = float(storage.get_runtime("paper_cash", settings.starting_balance_usdt))
|
||||||
self.peak_equity = float(storage.get_runtime("peak_equity", settings.starting_balance_usdt))
|
today = utc_now().date().isoformat()
|
||||||
|
stored_peak_day = str(storage.get_runtime("paper_peak_equity_day", ""))
|
||||||
|
self.peak_equity_day = today
|
||||||
|
self.peak_equity = float(
|
||||||
|
storage.get_runtime("paper_daily_peak_equity", settings.starting_balance_usdt)
|
||||||
|
if stored_peak_day == today
|
||||||
|
else settings.starting_balance_usdt
|
||||||
|
)
|
||||||
|
self.lifetime_peak_equity = float(
|
||||||
|
storage.get_runtime("paper_lifetime_peak_equity", settings.starting_balance_usdt)
|
||||||
|
)
|
||||||
self._entry_timestamps = deque()
|
self._entry_timestamps = deque()
|
||||||
|
|
||||||
def open_positions(self) -> list[Position]:
|
def open_positions(self) -> list[Position]:
|
||||||
@@ -64,11 +74,24 @@ class PaperBroker:
|
|||||||
def mark_equity(self, prices: dict[str, float]) -> dict[str, float]:
|
def mark_equity(self, prices: dict[str, float]) -> dict[str, float]:
|
||||||
state = self.account_state(prices)
|
state = self.account_state(prices)
|
||||||
equity = state["equity"]
|
equity = state["equity"]
|
||||||
|
today = utc_now().date().isoformat()
|
||||||
|
if today != self.peak_equity_day:
|
||||||
|
self.peak_equity_day = today
|
||||||
|
self.peak_equity = equity
|
||||||
self.peak_equity = max(self.peak_equity, equity)
|
self.peak_equity = max(self.peak_equity, equity)
|
||||||
|
self.lifetime_peak_equity = max(self.lifetime_peak_equity, equity)
|
||||||
state["drawdown"] = max(0.0, self.peak_equity - equity)
|
state["drawdown"] = max(0.0, self.peak_equity - equity)
|
||||||
self.storage.set_runtime("paper_cash", self.cash)
|
self.storage.set_runtime("paper_cash", self.cash)
|
||||||
self.storage.set_runtime("peak_equity", self.peak_equity)
|
self.storage.set_runtime("paper_peak_equity_day", self.peak_equity_day)
|
||||||
self.storage.insert_equity(equity, self.cash, self.exposure(), state["drawdown"])
|
self.storage.set_runtime("paper_daily_peak_equity", self.peak_equity)
|
||||||
|
self.storage.set_runtime("paper_lifetime_peak_equity", self.lifetime_peak_equity)
|
||||||
|
self.storage.insert_equity(
|
||||||
|
equity,
|
||||||
|
self.cash,
|
||||||
|
self.exposure(),
|
||||||
|
state["drawdown"],
|
||||||
|
mode=self.settings.trading_mode,
|
||||||
|
)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def account_state(self, prices: dict[str, float]) -> dict[str, float]:
|
def account_state(self, prices: dict[str, float]) -> dict[str, float]:
|
||||||
@@ -181,6 +204,7 @@ class PaperBroker:
|
|||||||
entry_confidence=signal.confidence,
|
entry_confidence=signal.confidence,
|
||||||
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
|
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
|
||||||
entry_diagnostics=signal.diagnostics,
|
entry_diagnostics=signal.diagnostics,
|
||||||
|
mode=self.settings.trading_mode,
|
||||||
)
|
)
|
||||||
position.id = self.storage.insert_position(position)
|
position.id = self.storage.insert_position(position)
|
||||||
self.positions.append(position)
|
self.positions.append(position)
|
||||||
@@ -201,6 +225,7 @@ class PaperBroker:
|
|||||||
entry_confidence=position.entry_confidence,
|
entry_confidence=position.entry_confidence,
|
||||||
entry_diagnostics=position.entry_diagnostics,
|
entry_diagnostics=position.entry_diagnostics,
|
||||||
opened_at=position.opened_at,
|
opened_at=position.opened_at,
|
||||||
|
mode=self.settings.trading_mode,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.storage.event(
|
self.storage.event(
|
||||||
@@ -244,6 +269,7 @@ class PaperBroker:
|
|||||||
entry_diagnostics=position.entry_diagnostics,
|
entry_diagnostics=position.entry_diagnostics,
|
||||||
opened_at=position.opened_at,
|
opened_at=position.opened_at,
|
||||||
closed_at=utc_now(),
|
closed_at=utc_now(),
|
||||||
|
mode=self.settings.trading_mode,
|
||||||
)
|
)
|
||||||
trade.id = self.storage.insert_trade(trade)
|
trade.id = self.storage.insert_trade(trade)
|
||||||
self.storage.event(
|
self.storage.event(
|
||||||
@@ -368,11 +394,139 @@ class PaperBroker:
|
|||||||
|
|
||||||
|
|
||||||
class LiveBroker(PaperBroker):
|
class LiveBroker(PaperBroker):
|
||||||
|
TERMINAL_ORDER_STATUSES = {
|
||||||
|
"Filled",
|
||||||
|
"Cancelled",
|
||||||
|
"Rejected",
|
||||||
|
"PartiallyFilledCanceled",
|
||||||
|
"PartillyFilledCancelled",
|
||||||
|
"Deactivated",
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, settings: Settings, storage: Storage, client: BybitClient):
|
def __init__(self, settings: Settings, storage: Storage, client: BybitClient):
|
||||||
super().__init__(settings, storage)
|
super().__init__(settings, storage)
|
||||||
if not settings.live_ready:
|
if not settings.live_ready:
|
||||||
raise BrokerError("Live mode is not unlocked by settings")
|
raise BrokerError("Live mode is not unlocked by settings")
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self.reconciliation_state: dict[str, Any] = {
|
||||||
|
"status": "unknown",
|
||||||
|
"blocking": True,
|
||||||
|
"discrepancies": ["live account has not been reconciled"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def can_open(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
prices: dict[str, float],
|
||||||
|
requested_notional: float | None = None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
if self.reconciliation_state.get("blocking", True):
|
||||||
|
return False, "live reconciliation is not clean"
|
||||||
|
return super().can_open(symbol, prices, requested_notional)
|
||||||
|
|
||||||
|
def reconcile(self, instruments: dict[str, Instrument]) -> dict[str, Any]:
|
||||||
|
coins = {"USDT"}
|
||||||
|
for symbol in self.settings.symbols:
|
||||||
|
instrument = instruments.get(symbol)
|
||||||
|
if instrument and instrument.base_coin:
|
||||||
|
coins.add(instrument.base_coin.upper())
|
||||||
|
wallet = self.client.wallet_balance(coin=",".join(sorted(coins)))
|
||||||
|
balances = _wallet_balances(wallet)
|
||||||
|
usdt = balances.get("USDT", {})
|
||||||
|
self.cash = max(0.0, float(usdt.get("wallet_balance", 0.0)) - float(usdt.get("locked", 0.0)))
|
||||||
|
|
||||||
|
local_by_coin: dict[str, float] = {}
|
||||||
|
discrepancies: list[dict[str, Any]] = []
|
||||||
|
for position in self.positions:
|
||||||
|
instrument = instruments.get(position.symbol)
|
||||||
|
coin = instrument.base_coin.upper() if instrument and instrument.base_coin else position.symbol.removesuffix("USDT")
|
||||||
|
local_by_coin[coin] = local_by_coin.get(coin, 0.0) + position.qty
|
||||||
|
for coin, local_qty in local_by_coin.items():
|
||||||
|
remote_qty = float((balances.get(coin) or {}).get("wallet_balance", 0.0))
|
||||||
|
tolerance = max(1e-8, local_qty * 0.002)
|
||||||
|
if remote_qty + tolerance < local_qty:
|
||||||
|
discrepancies.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"code": "remote_balance_below_local_position",
|
||||||
|
"coin": coin,
|
||||||
|
"local_qty": round(local_qty, 12),
|
||||||
|
"remote_qty": round(remote_qty, 12),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for coin, row in balances.items():
|
||||||
|
if coin == "USDT" or coin not in coins:
|
||||||
|
continue
|
||||||
|
remote_qty = float(row.get("wallet_balance", 0.0))
|
||||||
|
local_qty = local_by_coin.get(coin, 0.0)
|
||||||
|
tolerance = max(1e-8, local_qty * 0.002)
|
||||||
|
if remote_qty > local_qty + tolerance:
|
||||||
|
discrepancies.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"code": "remote_asset_without_matching_local_position",
|
||||||
|
"coin": coin,
|
||||||
|
"local_qty": round(local_qty, 12),
|
||||||
|
"remote_qty": round(remote_qty, 12),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
normal_orders = self.client.realtime_orders(
|
||||||
|
category="spot",
|
||||||
|
open_only=0,
|
||||||
|
limit=50,
|
||||||
|
order_filter="Order",
|
||||||
|
)
|
||||||
|
unresolved_orders = [
|
||||||
|
row
|
||||||
|
for row in normal_orders.get("list", [])
|
||||||
|
if isinstance(row, dict)
|
||||||
|
and str(row.get("orderStatus", "")) not in self.TERMINAL_ORDER_STATUSES
|
||||||
|
]
|
||||||
|
if unresolved_orders:
|
||||||
|
discrepancies.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"code": "unresolved_exchange_orders",
|
||||||
|
"count": len(unresolved_orders),
|
||||||
|
"order_ids": [str(row.get("orderId", "")) for row in unresolved_orders[:10]],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
protection_rows = self.client.realtime_orders(
|
||||||
|
category="spot",
|
||||||
|
open_only=0,
|
||||||
|
limit=50,
|
||||||
|
order_filter="tpslOrder",
|
||||||
|
)
|
||||||
|
active_protection = {
|
||||||
|
str(row.get("orderId", ""))
|
||||||
|
for row in protection_rows.get("list", [])
|
||||||
|
if isinstance(row, dict)
|
||||||
|
and str(row.get("orderStatus", "")) not in self.TERMINAL_ORDER_STATUSES
|
||||||
|
}
|
||||||
|
if self.settings.live_protective_stop_enabled:
|
||||||
|
for position in self.positions:
|
||||||
|
if not position.protective_order_id or position.protective_order_id not in active_protection:
|
||||||
|
discrepancies.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"code": "missing_exchange_protective_stop",
|
||||||
|
"position_id": position.id,
|
||||||
|
"symbol": position.symbol,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
blocking = any(row.get("severity") == "error" for row in discrepancies)
|
||||||
|
self.reconciliation_state = {
|
||||||
|
"status": "error" if blocking else ("warn" if discrepancies else "ok"),
|
||||||
|
"blocking": blocking,
|
||||||
|
"discrepancies": discrepancies,
|
||||||
|
"cash_usdt": round(self.cash, 8),
|
||||||
|
"checked_at": utc_now().isoformat(),
|
||||||
|
}
|
||||||
|
self.storage.set_runtime("live_reconciliation", self.reconciliation_state)
|
||||||
|
return dict(self.reconciliation_state)
|
||||||
|
|
||||||
def buy(
|
def buy(
|
||||||
self,
|
self,
|
||||||
@@ -400,30 +554,356 @@ class LiveBroker(PaperBroker):
|
|||||||
if budget < max(self.settings.min_position_usdt, 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
|
||||||
notional = budget / (1 + self.settings.taker_fee_rate)
|
requested_quote = budget / (1 + self.settings.taker_fee_rate)
|
||||||
|
client_order_id = f"tb-buy-{uuid4().hex[:18]}"
|
||||||
|
self.storage.upsert_order(
|
||||||
|
client_order_id=client_order_id,
|
||||||
|
symbol=ticker.symbol,
|
||||||
|
side="Buy",
|
||||||
|
order_kind="MARKET",
|
||||||
|
status="PENDING_SUBMIT",
|
||||||
|
requested_notional=requested_quote,
|
||||||
|
raw={"signal": signal.as_dict()},
|
||||||
|
)
|
||||||
|
try:
|
||||||
response = self.client.place_spot_market_order(
|
response = self.client.place_spot_market_order(
|
||||||
symbol=ticker.symbol,
|
symbol=ticker.symbol,
|
||||||
side="Buy",
|
side="Buy",
|
||||||
qty=notional,
|
qty=requested_quote,
|
||||||
market_unit="quoteCoin",
|
market_unit="quoteCoin",
|
||||||
order_link_id=f"tb-buy-{uuid4().hex[:18]}",
|
order_link_id=client_order_id,
|
||||||
)
|
)
|
||||||
self.storage.event(f"{ticker.symbol}: реальная покупка отправлена orderId={response.get('orderId')}")
|
order_id = str(response.get("orderId", ""))
|
||||||
return self._record_buy(signal, ticker, instrument, "реальная покупка, локальная запись")
|
if not order_id:
|
||||||
|
raise BrokerError("Bybit did not return orderId for live BUY")
|
||||||
|
self.storage.upsert_order(
|
||||||
|
client_order_id=client_order_id,
|
||||||
|
exchange_order_id=order_id,
|
||||||
|
symbol=ticker.symbol,
|
||||||
|
side="Buy",
|
||||||
|
order_kind="MARKET",
|
||||||
|
status="ACCEPTED",
|
||||||
|
requested_notional=requested_quote,
|
||||||
|
raw=response,
|
||||||
|
)
|
||||||
|
result = self.client.wait_for_spot_order(
|
||||||
|
order_id=order_id,
|
||||||
|
symbol=ticker.symbol,
|
||||||
|
timeout_seconds=self.settings.live_order_fill_timeout_seconds,
|
||||||
|
)
|
||||||
|
fill = _execution_fill(result, side="Buy", instrument=instrument)
|
||||||
|
self._save_order_fill(client_order_id, order_id, ticker.symbol, "Buy", requested_quote, result, fill)
|
||||||
|
if fill["qty"] <= 0 or fill["value"] <= 0:
|
||||||
|
raise BrokerError(f"live BUY was not filled, status={fill['status']}")
|
||||||
|
position = self._record_live_buy(signal, ticker, fill)
|
||||||
|
if self.settings.live_protective_stop_enabled:
|
||||||
|
try:
|
||||||
|
self._place_protective_stop(position)
|
||||||
|
except Exception as exc:
|
||||||
|
self.storage.event(
|
||||||
|
f"{ticker.symbol}: protective stop placement failed, closing position: {exc}",
|
||||||
|
"ERROR",
|
||||||
|
)
|
||||||
|
self.sell(position, ticker, "protective stop placement failed")
|
||||||
|
raise BrokerError("live BUY was unwound because protective stop failed") from exc
|
||||||
|
return position
|
||||||
|
except Exception as exc:
|
||||||
|
self.reconciliation_state["blocking"] = True
|
||||||
|
self.reconciliation_state["status"] = "error"
|
||||||
|
self.storage.event(f"{ticker.symbol}: live BUY failed: {exc}", "ERROR")
|
||||||
|
raise
|
||||||
|
|
||||||
def sell(self, position: Position, ticker: Ticker, reason: str) -> Trade:
|
def sell(self, position: Position, ticker: Ticker, reason: str) -> Trade:
|
||||||
|
if position.protective_order_id or position.protective_order_link_id:
|
||||||
|
self.client.cancel_spot_order(
|
||||||
|
symbol=position.symbol,
|
||||||
|
order_id=position.protective_order_id or None,
|
||||||
|
order_link_id=position.protective_order_link_id or None,
|
||||||
|
order_filter="tpslOrder",
|
||||||
|
)
|
||||||
|
if position.protective_order_id:
|
||||||
|
cancelled = self.client.wait_for_spot_order(
|
||||||
|
order_id=position.protective_order_id,
|
||||||
|
symbol=position.symbol,
|
||||||
|
timeout_seconds=min(10.0, self.settings.live_order_fill_timeout_seconds),
|
||||||
|
)
|
||||||
|
status = str((cancelled.get("order") or {}).get("orderStatus", ""))
|
||||||
|
if status and status != "Cancelled":
|
||||||
|
raise BrokerError(f"protective order was not cancelled, status={status}")
|
||||||
|
|
||||||
|
client_order_id = f"tb-sell-{uuid4().hex[:18]}"
|
||||||
|
self.storage.upsert_order(
|
||||||
|
client_order_id=client_order_id,
|
||||||
|
symbol=position.symbol,
|
||||||
|
side="Sell",
|
||||||
|
order_kind="MARKET",
|
||||||
|
status="PENDING_SUBMIT",
|
||||||
|
requested_qty=position.qty,
|
||||||
|
raw={"position_id": position.id, "reason": reason},
|
||||||
|
)
|
||||||
response = self.client.place_spot_market_order(
|
response = self.client.place_spot_market_order(
|
||||||
symbol=position.symbol,
|
symbol=position.symbol,
|
||||||
side="Sell",
|
side="Sell",
|
||||||
qty=position.qty,
|
qty=position.qty,
|
||||||
market_unit="baseCoin",
|
market_unit="baseCoin",
|
||||||
order_link_id=f"tb-sell-{uuid4().hex[:18]}",
|
order_link_id=client_order_id,
|
||||||
|
)
|
||||||
|
order_id = str(response.get("orderId", ""))
|
||||||
|
if not order_id:
|
||||||
|
raise BrokerError("Bybit did not return orderId for live SELL")
|
||||||
|
result = self.client.wait_for_spot_order(
|
||||||
|
order_id=order_id,
|
||||||
|
symbol=position.symbol,
|
||||||
|
timeout_seconds=self.settings.live_order_fill_timeout_seconds,
|
||||||
|
)
|
||||||
|
fill = _execution_fill(result, side="Sell", instrument=None)
|
||||||
|
self._save_order_fill(client_order_id, order_id, position.symbol, "Sell", position.qty, result, fill)
|
||||||
|
if fill["qty"] <= 0 or fill["value"] <= 0:
|
||||||
|
self.reconciliation_state["blocking"] = True
|
||||||
|
raise BrokerError(f"live SELL was not filled, status={fill['status']}")
|
||||||
|
return self._record_live_sell(position, reason, fill)
|
||||||
|
|
||||||
|
def _record_live_buy(self, signal: Signal, ticker: Ticker, fill: dict[str, Any]) -> Position:
|
||||||
|
qty = float(fill["net_qty"])
|
||||||
|
value = float(fill["value"])
|
||||||
|
price = value / max(float(fill["qty"]), 1e-12)
|
||||||
|
fee_usdt = float(fill["fee_usdt"])
|
||||||
|
stop_loss_percent = self._signal_percent(
|
||||||
|
signal, "stop_loss_percent", self.settings.stop_loss_percent, 0.003, 0.08
|
||||||
|
)
|
||||||
|
take_profit_percent = self._signal_percent(
|
||||||
|
signal, "take_profit_percent", self.settings.take_profit_percent, 0.003, 0.20
|
||||||
|
)
|
||||||
|
position = Position(
|
||||||
|
id=None,
|
||||||
|
symbol=ticker.symbol,
|
||||||
|
qty=qty,
|
||||||
|
entry_price=price,
|
||||||
|
notional_usdt=value,
|
||||||
|
entry_fee_usdt=fee_usdt,
|
||||||
|
stop_loss=price * (1 - stop_loss_percent),
|
||||||
|
take_profit=price * (1 + take_profit_percent),
|
||||||
|
highest_price=price,
|
||||||
|
entry_reason=signal.reason,
|
||||||
|
entry_confidence=signal.confidence,
|
||||||
|
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
|
||||||
|
entry_diagnostics=signal.diagnostics,
|
||||||
|
mode="live",
|
||||||
|
)
|
||||||
|
position.id = self.storage.insert_position(position)
|
||||||
|
self.positions.append(position)
|
||||||
|
self._record_entry_timestamp()
|
||||||
|
self.cash = max(0.0, self.cash - value - float(fill["quote_fee"]))
|
||||||
|
self.storage.insert_trade(
|
||||||
|
Trade(
|
||||||
|
id=None,
|
||||||
|
symbol=ticker.symbol,
|
||||||
|
side="BUY",
|
||||||
|
qty=qty,
|
||||||
|
entry_price=price,
|
||||||
|
fee_usdt=fee_usdt,
|
||||||
|
net_pnl=-fee_usdt,
|
||||||
|
reason=signal.reason,
|
||||||
|
entry_pattern=position.entry_pattern,
|
||||||
|
entry_confidence=position.entry_confidence,
|
||||||
|
entry_diagnostics=position.entry_diagnostics,
|
||||||
|
opened_at=position.opened_at,
|
||||||
|
mode="live",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.storage.event(
|
self.storage.event(
|
||||||
f"{position.symbol}: реальная продажа отправлена orderId={response.get('orderId')} причина={reason}"
|
f"{ticker.symbol}: live BUY filled qty={qty:.8f} avg={price:.8f} value={value:.4f}"
|
||||||
)
|
)
|
||||||
return self._record_sell(position, ticker, reason, "реальная продажа, локальная запись")
|
return position
|
||||||
|
|
||||||
|
def _record_live_sell(self, position: Position, reason: str, fill: dict[str, Any]) -> Trade:
|
||||||
|
sold_qty = min(position.qty, float(fill["qty"]))
|
||||||
|
value = float(fill["value"])
|
||||||
|
price = value / max(float(fill["qty"]), 1e-12)
|
||||||
|
exit_fee = float(fill["fee_usdt"])
|
||||||
|
ratio = min(1.0, sold_qty / max(position.qty, 1e-12))
|
||||||
|
allocated_entry_fee = position.entry_fee_usdt * ratio
|
||||||
|
gross_pnl = (price - position.entry_price) * sold_qty
|
||||||
|
net_pnl = gross_pnl - allocated_entry_fee - exit_fee
|
||||||
|
self.cash += value - float(fill["quote_fee"])
|
||||||
|
remaining_qty = max(0.0, position.qty - sold_qty)
|
||||||
|
if remaining_qty <= max(1e-12, position.qty * 1e-6):
|
||||||
|
if position.id is not None:
|
||||||
|
self.storage.close_position(position.id)
|
||||||
|
self.positions = [item for item in self.positions if item.id != position.id]
|
||||||
|
else:
|
||||||
|
remaining_ratio = remaining_qty / position.qty
|
||||||
|
position.qty = remaining_qty
|
||||||
|
position.notional_usdt *= remaining_ratio
|
||||||
|
position.entry_fee_usdt *= remaining_ratio
|
||||||
|
position.protective_order_id = ""
|
||||||
|
position.protective_order_link_id = ""
|
||||||
|
if position.id is not None:
|
||||||
|
self.storage.update_position_after_partial_sell(
|
||||||
|
position.id,
|
||||||
|
qty=position.qty,
|
||||||
|
notional_usdt=position.notional_usdt,
|
||||||
|
entry_fee_usdt=position.entry_fee_usdt,
|
||||||
|
)
|
||||||
|
self.reconciliation_state["blocking"] = True
|
||||||
|
trade = Trade(
|
||||||
|
id=None,
|
||||||
|
symbol=position.symbol,
|
||||||
|
side="SELL",
|
||||||
|
qty=sold_qty,
|
||||||
|
entry_price=position.entry_price,
|
||||||
|
exit_price=price,
|
||||||
|
gross_pnl=gross_pnl,
|
||||||
|
fee_usdt=allocated_entry_fee + exit_fee,
|
||||||
|
net_pnl=net_pnl,
|
||||||
|
reason=reason,
|
||||||
|
entry_pattern=position.entry_pattern,
|
||||||
|
entry_confidence=position.entry_confidence,
|
||||||
|
entry_diagnostics=position.entry_diagnostics,
|
||||||
|
opened_at=position.opened_at,
|
||||||
|
closed_at=utc_now(),
|
||||||
|
mode="live",
|
||||||
|
)
|
||||||
|
trade.id = self.storage.insert_trade(trade)
|
||||||
|
self.storage.event(
|
||||||
|
f"{position.symbol}: live SELL filled qty={sold_qty:.8f} avg={price:.8f} pnl={net_pnl:.4f} reason={reason}"
|
||||||
|
)
|
||||||
|
return trade
|
||||||
|
|
||||||
|
def _place_protective_stop(self, position: Position) -> None:
|
||||||
|
link_id = f"tb-stop-{uuid4().hex[:17]}"
|
||||||
|
response = self.client.place_spot_protective_stop(
|
||||||
|
symbol=position.symbol,
|
||||||
|
qty=position.qty,
|
||||||
|
trigger_price=position.stop_loss,
|
||||||
|
order_link_id=link_id,
|
||||||
|
)
|
||||||
|
order_id = str(response.get("orderId", ""))
|
||||||
|
if not order_id:
|
||||||
|
raise BrokerError("Bybit did not return orderId for protective stop")
|
||||||
|
position.protective_order_id = order_id
|
||||||
|
position.protective_order_link_id = link_id
|
||||||
|
if position.id is not None:
|
||||||
|
self.storage.update_position_protective_order(position.id, order_id, link_id)
|
||||||
|
self.storage.upsert_order(
|
||||||
|
client_order_id=link_id,
|
||||||
|
exchange_order_id=order_id,
|
||||||
|
symbol=position.symbol,
|
||||||
|
side="Sell",
|
||||||
|
order_kind="PROTECTIVE_STOP",
|
||||||
|
status="ACCEPTED",
|
||||||
|
requested_qty=position.qty,
|
||||||
|
raw=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _save_order_fill(
|
||||||
|
self,
|
||||||
|
client_order_id: str,
|
||||||
|
order_id: str,
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
requested: float,
|
||||||
|
result: dict[str, Any],
|
||||||
|
fill: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
self.storage.upsert_order(
|
||||||
|
client_order_id=client_order_id,
|
||||||
|
exchange_order_id=order_id,
|
||||||
|
symbol=symbol,
|
||||||
|
side=side,
|
||||||
|
order_kind="MARKET",
|
||||||
|
status=str(fill["status"]),
|
||||||
|
requested_qty=requested if side == "Sell" else 0.0,
|
||||||
|
requested_notional=requested if side == "Buy" else 0.0,
|
||||||
|
executed_qty=float(fill["qty"]),
|
||||||
|
executed_value=float(fill["value"]),
|
||||||
|
fee_usdt=float(fill["fee_usdt"]),
|
||||||
|
raw=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wallet_balances(wallet: dict[str, Any]) -> dict[str, dict[str, float]]:
|
||||||
|
accounts = wallet.get("list")
|
||||||
|
if not isinstance(accounts, list) or not accounts:
|
||||||
|
return {}
|
||||||
|
coins = accounts[0].get("coin") if isinstance(accounts[0], dict) else None
|
||||||
|
if not isinstance(coins, list):
|
||||||
|
return {}
|
||||||
|
result: dict[str, dict[str, float]] = {}
|
||||||
|
for row in coins:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
coin = str(row.get("coin", "")).upper()
|
||||||
|
if not coin:
|
||||||
|
continue
|
||||||
|
result[coin] = {
|
||||||
|
"wallet_balance": _safe_float(row.get("walletBalance")),
|
||||||
|
"equity": _safe_float(row.get("equity")),
|
||||||
|
"locked": _safe_float(row.get("locked")),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _execution_fill(
|
||||||
|
result: dict[str, Any],
|
||||||
|
*,
|
||||||
|
side: str,
|
||||||
|
instrument: Instrument | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
order = result.get("order") if isinstance(result.get("order"), dict) else {}
|
||||||
|
executions = result.get("executions") if isinstance(result.get("executions"), list) else []
|
||||||
|
qty = 0.0
|
||||||
|
value = 0.0
|
||||||
|
quote_fee = 0.0
|
||||||
|
base_fee = 0.0
|
||||||
|
fee_usdt = 0.0
|
||||||
|
base_coin = instrument.base_coin.upper() if instrument and instrument.base_coin else ""
|
||||||
|
for row in executions:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
exec_qty = _safe_float(row.get("execQty"))
|
||||||
|
exec_value = _safe_float(row.get("execValue"))
|
||||||
|
exec_price = _safe_float(row.get("execPrice"))
|
||||||
|
fee = max(0.0, _safe_float(row.get("execFee")))
|
||||||
|
fee_currency = str(row.get("feeCurrency", "")).upper()
|
||||||
|
if not base_coin:
|
||||||
|
symbol = str(row.get("symbol", ""))
|
||||||
|
base_coin = symbol.removesuffix("USDT") if symbol.endswith("USDT") else ""
|
||||||
|
qty += exec_qty
|
||||||
|
value += exec_value or exec_qty * exec_price
|
||||||
|
if fee_currency == "USDT" or not fee_currency:
|
||||||
|
quote_fee += fee
|
||||||
|
fee_usdt += fee
|
||||||
|
elif fee_currency == base_coin:
|
||||||
|
base_fee += fee
|
||||||
|
fee_usdt += fee * exec_price
|
||||||
|
else:
|
||||||
|
fee_usdt += fee * exec_price
|
||||||
|
if qty <= 0:
|
||||||
|
qty = _safe_float(order.get("cumExecQty"))
|
||||||
|
if value <= 0:
|
||||||
|
value = _safe_float(order.get("cumExecValue"))
|
||||||
|
if value <= 0 and qty > 0:
|
||||||
|
value = qty * _safe_float(order.get("avgPrice"))
|
||||||
|
net_qty = max(0.0, qty - base_fee) if side == "Buy" else qty
|
||||||
|
return {
|
||||||
|
"status": str(order.get("orderStatus", "Unknown")),
|
||||||
|
"qty": qty,
|
||||||
|
"net_qty": net_qty,
|
||||||
|
"value": value,
|
||||||
|
"quote_fee": quote_fee,
|
||||||
|
"base_fee": base_fee,
|
||||||
|
"fee_usdt": fee_usdt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def prices_from_tickers(tickers: Iterable[Ticker]) -> dict[str, float]:
|
def prices_from_tickers(tickers: Iterable[Ticker]) -> dict[str, float]:
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ class TradeLearner:
|
|||||||
self.storage.set_runtime("learning_state", self.state.as_dict())
|
self.storage.set_runtime("learning_state", self.state.as_dict())
|
||||||
return self.state
|
return self.state
|
||||||
|
|
||||||
trades = self.storage.closed_trades(self.settings.learning_lookback_trades)
|
trades = self.storage.closed_trades(
|
||||||
|
self.settings.learning_lookback_trades,
|
||||||
|
mode=self.settings.trading_mode,
|
||||||
|
)
|
||||||
total_net = sum(float(trade.get("net_pnl") or 0.0) for trade in trades)
|
total_net = sum(float(trade.get("net_pnl") or 0.0) for trade in trades)
|
||||||
wins = sum(1 for trade in trades if float(trade.get("net_pnl") or 0.0) > 0)
|
wins = sum(1 for trade in trades if float(trade.get("net_pnl") or 0.0) > 0)
|
||||||
symbol_stats = _group_stats(trades, "symbol")
|
symbol_stats = _group_stats(trades, "symbol")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@@ -15,7 +16,12 @@ def main() -> None:
|
|||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.FileHandler(settings.log_path, encoding="utf-8"),
|
RotatingFileHandler(
|
||||||
|
settings.log_path,
|
||||||
|
maxBytes=10 * 1024 * 1024,
|
||||||
|
backupCount=5,
|
||||||
|
encoding="utf-8",
|
||||||
|
),
|
||||||
logging.StreamHandler(),
|
logging.StreamHandler(),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -56,6 +57,9 @@ class MarketData:
|
|||||||
self.last_ws_message_at: datetime | None = None
|
self.last_ws_message_at: datetime | None = None
|
||||||
self.ws_connected = False
|
self.ws_connected = False
|
||||||
self._stop_event = asyncio.Event()
|
self._stop_event = asyncio.Event()
|
||||||
|
self._refresh_lock = threading.Lock()
|
||||||
|
self.rest_error_count = 0
|
||||||
|
self.last_rest_error = ""
|
||||||
|
|
||||||
async def bootstrap(self) -> None:
|
async def bootstrap(self) -> None:
|
||||||
self.instruments = await asyncio.to_thread(self.client.instruments)
|
self.instruments = await asyncio.to_thread(self.client.instruments)
|
||||||
@@ -76,15 +80,19 @@ class MarketData:
|
|||||||
if symbol in self.instruments
|
if symbol in self.instruments
|
||||||
]
|
]
|
||||||
self.storage.event("Торговые пары: " + ", ".join(self.symbols))
|
self.storage.event("Торговые пары: " + ", ".join(self.symbols))
|
||||||
await asyncio.to_thread(self.refresh_rest)
|
await asyncio.to_thread(self.refresh_rest, True)
|
||||||
|
|
||||||
def refresh_rest(self) -> None:
|
def refresh_rest(self, force_candles: bool = False) -> None:
|
||||||
|
if not self._refresh_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
try:
|
||||||
ticker_map = {ticker.symbol: ticker for ticker in self.client.spot_tickers()}
|
ticker_map = {ticker.symbol: ticker for ticker in self.client.spot_tickers()}
|
||||||
for symbol in self.symbols:
|
for symbol in self.symbols:
|
||||||
ticker = ticker_map.get(symbol)
|
ticker = ticker_map.get(symbol)
|
||||||
if ticker:
|
if ticker:
|
||||||
self.tickers[symbol] = ticker
|
self.tickers[symbol] = ticker
|
||||||
try:
|
try:
|
||||||
|
if force_candles or _candles_due(self.candles.get(symbol, []), self.settings.base_interval):
|
||||||
candles = self.client.klines(
|
candles = self.client.klines(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
interval=self.settings.base_interval,
|
interval=self.settings.base_interval,
|
||||||
@@ -93,6 +101,9 @@ class MarketData:
|
|||||||
candles = _closed_candles(candles, self.settings.base_interval)
|
candles = _closed_candles(candles, self.settings.base_interval)
|
||||||
add_indicators(candles)
|
add_indicators(candles)
|
||||||
self.candles[symbol] = candles
|
self.candles[symbol] = candles
|
||||||
|
if force_candles or _candles_due(
|
||||||
|
self.trend_candles.get(symbol, []), self.settings.trend_interval
|
||||||
|
):
|
||||||
trend_candles = self.client.klines(
|
trend_candles = self.client.klines(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
interval=self.settings.trend_interval,
|
interval=self.settings.trend_interval,
|
||||||
@@ -115,8 +126,14 @@ class MarketData:
|
|||||||
change_24h=current.change_24h,
|
change_24h=current.change_24h,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
self.rest_error_count += 1
|
||||||
|
self.last_rest_error = str(exc)
|
||||||
self.storage.event(f"{symbol}: ошибка обновления REST данных: {exc}", "ERROR")
|
self.storage.event(f"{symbol}: ошибка обновления REST данных: {exc}", "ERROR")
|
||||||
self.last_rest_refresh_at = utc_now()
|
self.last_rest_refresh_at = utc_now()
|
||||||
|
if ticker_map:
|
||||||
|
self.last_rest_error = ""
|
||||||
|
finally:
|
||||||
|
self._refresh_lock.release()
|
||||||
|
|
||||||
async def websocket_loop(self) -> None:
|
async def websocket_loop(self) -> None:
|
||||||
if not self.settings.websocket_enabled:
|
if not self.settings.websocket_enabled:
|
||||||
@@ -227,10 +244,36 @@ class MarketData:
|
|||||||
def prices(self) -> dict[str, float]:
|
def prices(self) -> dict[str, float]:
|
||||||
return {symbol: ticker.last_price for symbol, ticker in self.tickers.items()}
|
return {symbol: ticker.last_price for symbol, ticker in self.tickers.items()}
|
||||||
|
|
||||||
|
def symbol_freshness(self, symbol: str) -> dict[str, Any]:
|
||||||
|
ticker = self.tickers.get(symbol)
|
||||||
|
candles = self.candles.get(symbol, [])
|
||||||
|
ticker_age = (utc_now() - ticker.updated_at).total_seconds() if ticker else None
|
||||||
|
interval_ms = _interval_ms(self.settings.base_interval)
|
||||||
|
candle_age = (
|
||||||
|
max(0.0, (utc_now().timestamp() * 1000 - candles[-1].timestamp) / 1000)
|
||||||
|
if candles
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
ticker_ok = ticker_age is not None and ticker_age <= self.settings.market_ticker_max_age_seconds
|
||||||
|
candle_ok = bool(
|
||||||
|
candle_age is not None
|
||||||
|
and interval_ms > 0
|
||||||
|
and candle_age <= (interval_ms / 1000) * 2.5
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": bool(ticker_ok and candle_ok),
|
||||||
|
"ticker_ok": ticker_ok,
|
||||||
|
"candle_ok": candle_ok,
|
||||||
|
"ticker_age_seconds": round(ticker_age, 3) if ticker_age is not None else None,
|
||||||
|
"candle_age_seconds": round(candle_age, 3) if candle_age is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, Any]:
|
def snapshot(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"symbols": self.symbols,
|
"symbols": self.symbols,
|
||||||
"ws_connected": self.ws_connected,
|
"ws_connected": self.ws_connected,
|
||||||
|
"rest_error_count": self.rest_error_count,
|
||||||
|
"last_rest_error": self.last_rest_error,
|
||||||
"quality": market_quality_snapshot(
|
"quality": market_quality_snapshot(
|
||||||
symbols=self.symbols,
|
symbols=self.symbols,
|
||||||
candles_by_symbol=self.candles,
|
candles_by_symbol=self.candles,
|
||||||
@@ -291,3 +334,14 @@ def _interval_ms(interval: str) -> int:
|
|||||||
if normalized.isdigit():
|
if normalized.isdigit():
|
||||||
return int(normalized) * 60 * 1000
|
return int(normalized) * 60 * 1000
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _candles_due(candles: list[Candle], interval: str, now_ms: int | None = None) -> bool:
|
||||||
|
if not candles:
|
||||||
|
return True
|
||||||
|
interval_ms = _interval_ms(interval)
|
||||||
|
if interval_ms <= 0:
|
||||||
|
return True
|
||||||
|
now_ms = now_ms if now_ms is not None else int(utc_now().timestamp() * 1000)
|
||||||
|
expected_latest_start = (now_ms // interval_ms - 1) * interval_ms
|
||||||
|
return candles[-1].timestamp < expected_latest_start
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ class Position:
|
|||||||
entry_confidence: float = 0.0
|
entry_confidence: float = 0.0
|
||||||
entry_pattern: str = ""
|
entry_pattern: str = ""
|
||||||
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
|
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
|
||||||
|
protective_order_id: str = ""
|
||||||
|
protective_order_link_id: str = ""
|
||||||
|
mode: str = "paper"
|
||||||
|
|
||||||
def mark_price(self, price: float) -> float:
|
def mark_price(self, price: float) -> float:
|
||||||
return self.qty * price
|
return self.qty * price
|
||||||
@@ -131,6 +134,7 @@ class Trade:
|
|||||||
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
|
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
|
||||||
opened_at: datetime | None = None
|
opened_at: datetime | None = None
|
||||||
closed_at: datetime | None = None
|
closed_at: datetime | None = None
|
||||||
|
mode: str = "paper"
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
data = asdict(self)
|
data = asdict(self)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ def reconciliation_snapshot(
|
|||||||
client: BybitClient,
|
client: BybitClient,
|
||||||
instruments: dict[str, Instrument],
|
instruments: dict[str, Instrument],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
local_positions = storage.open_positions()
|
local_positions = storage.open_positions(settings.trading_mode)
|
||||||
local = [
|
local = [
|
||||||
{
|
{
|
||||||
"id": position.id,
|
"id": position.id,
|
||||||
@@ -23,6 +23,7 @@ def reconciliation_snapshot(
|
|||||||
"qty": position.qty,
|
"qty": position.qty,
|
||||||
"entry_price": position.entry_price,
|
"entry_price": position.entry_price,
|
||||||
"notional_usdt": position.notional_usdt,
|
"notional_usdt": position.notional_usdt,
|
||||||
|
"protective_order_id": position.protective_order_id,
|
||||||
}
|
}
|
||||||
for position in local_positions
|
for position in local_positions
|
||||||
]
|
]
|
||||||
|
|||||||
+359
-26
@@ -2,23 +2,67 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
from crypto_spot_bot.models import Position, Signal, Trade, utc_now
|
from crypto_spot_bot.models import Position, Signal, Trade, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
MAX_SIGNAL_DIAGNOSTICS_BYTES = 4 * 1024
|
||||||
|
PRUNE_BATCH_SIZE = 5000
|
||||||
|
MAX_RUNTIME_ROWS = {
|
||||||
|
"signals": 50_000,
|
||||||
|
"equity": 100_000,
|
||||||
|
"events": 20_000,
|
||||||
|
"llm_advice": 20_000,
|
||||||
|
}
|
||||||
|
_STORED_FORECAST_KEYS = {
|
||||||
|
"enabled",
|
||||||
|
"usable",
|
||||||
|
"model",
|
||||||
|
"volatility_model",
|
||||||
|
"expected_return_percent",
|
||||||
|
"expected_price",
|
||||||
|
"volatility_percent",
|
||||||
|
"probability_up",
|
||||||
|
"confidence_adjustment",
|
||||||
|
"block_entry",
|
||||||
|
"validation_mae_percent",
|
||||||
|
"baseline_mae_percent",
|
||||||
|
"skill",
|
||||||
|
"horizon",
|
||||||
|
"reason",
|
||||||
|
"expected_gross_return_percent",
|
||||||
|
"quantile_10_percent",
|
||||||
|
"quantile_50_percent",
|
||||||
|
"quantile_90_percent",
|
||||||
|
"conservative_return_percent",
|
||||||
|
"target_transform",
|
||||||
|
"horizon_forecasts",
|
||||||
|
"candidates",
|
||||||
|
"quality_gate_passed",
|
||||||
|
"model_created_at",
|
||||||
|
"model_age_hours",
|
||||||
|
"model_fresh",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Storage:
|
class Storage:
|
||||||
def __init__(self, path: str | Path):
|
def __init__(self, path: str | Path):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._last_hold_signal: dict[tuple[str, str], float] = {}
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def connect(self) -> Iterator[sqlite3.Connection]:
|
def connect(self) -> Iterator[sqlite3.Connection]:
|
||||||
conn = sqlite3.connect(self.path)
|
conn = sqlite3.connect(self.path)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -27,6 +71,10 @@ class Storage:
|
|||||||
|
|
||||||
def init_schema(self) -> None:
|
def init_schema(self) -> None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
|
# New runtime databases reclaim deleted telemetry pages incrementally.
|
||||||
|
# Existing databases keep their current mode until compacted once.
|
||||||
|
conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.executescript(
|
conn.executescript(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS positions (
|
CREATE TABLE IF NOT EXISTS positions (
|
||||||
@@ -44,6 +92,9 @@ class Storage:
|
|||||||
entry_confidence REAL NOT NULL DEFAULT 0,
|
entry_confidence REAL NOT NULL DEFAULT 0,
|
||||||
entry_pattern TEXT NOT NULL DEFAULT '',
|
entry_pattern TEXT NOT NULL DEFAULT '',
|
||||||
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
|
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
protective_order_id TEXT NOT NULL DEFAULT '',
|
||||||
|
protective_order_link_id TEXT NOT NULL DEFAULT '',
|
||||||
|
mode TEXT NOT NULL DEFAULT 'paper',
|
||||||
status TEXT NOT NULL DEFAULT 'OPEN'
|
status TEXT NOT NULL DEFAULT 'OPEN'
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS trades (
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
@@ -61,7 +112,8 @@ class Storage:
|
|||||||
entry_confidence REAL NOT NULL DEFAULT 0,
|
entry_confidence REAL NOT NULL DEFAULT 0,
|
||||||
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
|
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
|
||||||
opened_at TEXT,
|
opened_at TEXT,
|
||||||
closed_at TEXT
|
closed_at TEXT,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'paper'
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS signals (
|
CREATE TABLE IF NOT EXISTS signals (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -78,7 +130,8 @@ class Storage:
|
|||||||
cash REAL NOT NULL,
|
cash REAL NOT NULL,
|
||||||
exposure REAL NOT NULL,
|
exposure REAL NOT NULL,
|
||||||
drawdown REAL NOT NULL,
|
drawdown REAL NOT NULL,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'paper'
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -101,6 +154,35 @@ class Storage:
|
|||||||
error TEXT NOT NULL DEFAULT '',
|
error TEXT NOT NULL DEFAULT '',
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_order_id TEXT NOT NULL UNIQUE,
|
||||||
|
exchange_order_id TEXT NOT NULL DEFAULT '',
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
side TEXT NOT NULL,
|
||||||
|
order_kind TEXT NOT NULL DEFAULT 'MARKET',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
requested_qty REAL NOT NULL DEFAULT 0,
|
||||||
|
requested_notional REAL NOT NULL DEFAULT 0,
|
||||||
|
executed_qty REAL NOT NULL DEFAULT 0,
|
||||||
|
executed_value REAL NOT NULL DEFAULT 0,
|
||||||
|
fee_usdt REAL NOT NULL DEFAULT 0,
|
||||||
|
raw_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_positions_status_opened
|
||||||
|
ON positions(status, opened_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trades_closed
|
||||||
|
ON trades(side, closed_at, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signals_symbol_created
|
||||||
|
ON signals(symbol, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_equity_created
|
||||||
|
ON equity(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_created
|
||||||
|
ON events(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_status_updated
|
||||||
|
ON orders(status, updated_at DESC);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
columns = {
|
columns = {
|
||||||
@@ -116,6 +198,9 @@ class Storage:
|
|||||||
"entry_confidence": "REAL NOT NULL DEFAULT 0",
|
"entry_confidence": "REAL NOT NULL DEFAULT 0",
|
||||||
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
|
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
|
||||||
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
|
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||||
|
"protective_order_id": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"protective_order_link_id": "TEXT NOT NULL DEFAULT ''",
|
||||||
|
"mode": "TEXT NOT NULL DEFAULT 'paper'",
|
||||||
}.items():
|
}.items():
|
||||||
if column not in columns:
|
if column not in columns:
|
||||||
conn.execute(f"ALTER TABLE positions ADD COLUMN {column} {definition}")
|
conn.execute(f"ALTER TABLE positions ADD COLUMN {column} {definition}")
|
||||||
@@ -127,9 +212,19 @@ class Storage:
|
|||||||
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
|
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
|
||||||
"entry_confidence": "REAL NOT NULL DEFAULT 0",
|
"entry_confidence": "REAL NOT NULL DEFAULT 0",
|
||||||
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
|
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||||
|
"mode": "TEXT NOT NULL DEFAULT 'paper'",
|
||||||
}.items():
|
}.items():
|
||||||
if column not in trade_columns:
|
if column not in trade_columns:
|
||||||
conn.execute(f"ALTER TABLE trades ADD COLUMN {column} {definition}")
|
conn.execute(f"ALTER TABLE trades ADD COLUMN {column} {definition}")
|
||||||
|
equity_columns = {
|
||||||
|
row["name"]
|
||||||
|
for row in conn.execute("PRAGMA table_info(equity)").fetchall()
|
||||||
|
}
|
||||||
|
if "mode" not in equity_columns:
|
||||||
|
conn.execute("ALTER TABLE equity ADD COLUMN mode TEXT NOT NULL DEFAULT 'paper'")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_equity_mode_created ON equity(mode, created_at DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
def insert_position(self, position: Position) -> int:
|
def insert_position(self, position: Position) -> int:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
@@ -138,8 +233,9 @@ class Storage:
|
|||||||
INSERT INTO positions (
|
INSERT INTO positions (
|
||||||
symbol, qty, entry_price, notional_usdt, entry_fee_usdt, stop_loss,
|
symbol, qty, entry_price, notional_usdt, entry_fee_usdt, stop_loss,
|
||||||
take_profit, highest_price, opened_at, entry_reason,
|
take_profit, highest_price, opened_at, entry_reason,
|
||||||
entry_confidence, entry_pattern, entry_diagnostics_json, status
|
entry_confidence, entry_pattern, entry_diagnostics_json,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
|
protective_order_id, protective_order_link_id, mode, status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
position.symbol,
|
position.symbol,
|
||||||
@@ -155,6 +251,9 @@ class Storage:
|
|||||||
position.entry_confidence,
|
position.entry_confidence,
|
||||||
position.entry_pattern,
|
position.entry_pattern,
|
||||||
json.dumps(position.entry_diagnostics, ensure_ascii=False),
|
json.dumps(position.entry_diagnostics, ensure_ascii=False),
|
||||||
|
position.protective_order_id,
|
||||||
|
position.protective_order_link_id,
|
||||||
|
position.mode,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return int(cur.lastrowid)
|
return int(cur.lastrowid)
|
||||||
@@ -170,8 +269,49 @@ class Storage:
|
|||||||
(highest_price, position_id),
|
(highest_price, position_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
def open_positions(self) -> list[Position]:
|
def update_position_protective_order(
|
||||||
|
self,
|
||||||
|
position_id: int,
|
||||||
|
order_id: str,
|
||||||
|
order_link_id: str,
|
||||||
|
) -> None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE positions
|
||||||
|
SET protective_order_id=?, protective_order_link_id=?
|
||||||
|
WHERE id=? AND status='OPEN'
|
||||||
|
""",
|
||||||
|
(order_id, order_link_id, position_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_position_after_partial_sell(
|
||||||
|
self,
|
||||||
|
position_id: int,
|
||||||
|
*,
|
||||||
|
qty: float,
|
||||||
|
notional_usdt: float,
|
||||||
|
entry_fee_usdt: float,
|
||||||
|
) -> None:
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE positions
|
||||||
|
SET qty=?, notional_usdt=?, entry_fee_usdt=?,
|
||||||
|
protective_order_id='', protective_order_link_id=''
|
||||||
|
WHERE id=? AND status='OPEN'
|
||||||
|
""",
|
||||||
|
(qty, notional_usdt, entry_fee_usdt, position_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_positions(self, mode: str | None = None) -> list[Position]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
if mode:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM positions WHERE status='OPEN' AND mode=? ORDER BY opened_at",
|
||||||
|
(mode,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM positions WHERE status='OPEN' ORDER BY opened_at"
|
"SELECT * FROM positions WHERE status='OPEN' ORDER BY opened_at"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -191,6 +331,9 @@ class Storage:
|
|||||||
entry_confidence=float(row["entry_confidence"]),
|
entry_confidence=float(row["entry_confidence"]),
|
||||||
entry_pattern=row["entry_pattern"],
|
entry_pattern=row["entry_pattern"],
|
||||||
entry_diagnostics=_json_or_default(row["entry_diagnostics_json"], {}),
|
entry_diagnostics=_json_or_default(row["entry_diagnostics_json"], {}),
|
||||||
|
protective_order_id=row["protective_order_id"],
|
||||||
|
protective_order_link_id=row["protective_order_link_id"],
|
||||||
|
mode=row["mode"],
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
@@ -203,7 +346,8 @@ class Storage:
|
|||||||
symbol, side, qty, entry_price, exit_price, gross_pnl,
|
symbol, side, qty, entry_price, exit_price, gross_pnl,
|
||||||
fee_usdt, net_pnl, reason, entry_pattern, entry_confidence,
|
fee_usdt, net_pnl, reason, entry_pattern, entry_confidence,
|
||||||
entry_diagnostics_json, opened_at, closed_at
|
entry_diagnostics_json, opened_at, closed_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
, mode
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
trade.symbol,
|
trade.symbol,
|
||||||
@@ -220,32 +364,41 @@ class Storage:
|
|||||||
json.dumps(trade.entry_diagnostics, ensure_ascii=False),
|
json.dumps(trade.entry_diagnostics, ensure_ascii=False),
|
||||||
trade.opened_at.isoformat() if trade.opened_at else None,
|
trade.opened_at.isoformat() if trade.opened_at else None,
|
||||||
trade.closed_at.isoformat() if trade.closed_at else None,
|
trade.closed_at.isoformat() if trade.closed_at else None,
|
||||||
|
trade.mode,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return int(cur.lastrowid)
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
def recent_trades(self, limit: int = 50) -> list[dict[str, Any]]:
|
def recent_trades(self, limit: int = 50, mode: str | None = None) -> list[dict[str, Any]]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
|
if mode:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM trades WHERE mode=? ORDER BY id DESC LIMIT ?",
|
||||||
|
(mode, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
rows = conn.execute("SELECT * FROM trades ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
rows = conn.execute("SELECT * FROM trades ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def closed_trades(self, limit: int = 200) -> list[dict[str, Any]]:
|
def closed_trades(self, limit: int = 200, mode: str | None = None) -> list[dict[str, Any]]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
rows = conn.execute(
|
query = """
|
||||||
"""
|
|
||||||
SELECT * FROM trades
|
SELECT * FROM trades
|
||||||
WHERE side='SELL' AND closed_at IS NOT NULL
|
WHERE side='SELL' AND closed_at IS NOT NULL
|
||||||
ORDER BY id DESC
|
"""
|
||||||
LIMIT ?
|
params: tuple[Any, ...]
|
||||||
""",
|
if mode:
|
||||||
(limit,),
|
query += " AND mode=?"
|
||||||
).fetchall()
|
params = (mode, limit)
|
||||||
|
else:
|
||||||
|
params = (limit,)
|
||||||
|
query += " ORDER BY id DESC LIMIT ?"
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def closed_trade_summary(self) -> dict[str, Any]:
|
def closed_trade_summary(self, mode: str | None = None) -> dict[str, Any]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
row = conn.execute(
|
query = """
|
||||||
"""
|
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS trades,
|
COUNT(*) AS trades,
|
||||||
COALESCE(SUM(net_pnl), 0) AS net_pnl,
|
COALESCE(SUM(net_pnl), 0) AS net_pnl,
|
||||||
@@ -256,7 +409,11 @@ class Storage:
|
|||||||
FROM trades
|
FROM trades
|
||||||
WHERE side='SELL' AND closed_at IS NOT NULL
|
WHERE side='SELL' AND closed_at IS NOT NULL
|
||||||
"""
|
"""
|
||||||
).fetchone()
|
params: tuple[Any, ...] = ()
|
||||||
|
if mode:
|
||||||
|
query += " AND mode=?"
|
||||||
|
params = (mode,)
|
||||||
|
row = conn.execute(query, params).fetchone()
|
||||||
trades = int(row["trades"] if row else 0)
|
trades = int(row["trades"] if row else 0)
|
||||||
wins = int(row["wins"] if row else 0)
|
wins = int(row["wins"] if row else 0)
|
||||||
losses = int(row["losses"] if row else 0)
|
losses = int(row["losses"] if row else 0)
|
||||||
@@ -270,7 +427,15 @@ class Storage:
|
|||||||
"win_rate": round(wins / trades, 4) if trades else 0.0,
|
"win_rate": round(wins / trades, 4) if trades else 0.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def insert_signal(self, signal: Signal) -> None:
|
def insert_signal(self, signal: Signal, hold_sample_seconds: int = 0) -> bool:
|
||||||
|
if signal.action == "HOLD" and hold_sample_seconds > 0:
|
||||||
|
fingerprint = f"{signal.action}\0{signal.reason}"
|
||||||
|
now = time.monotonic()
|
||||||
|
sample_key = (signal.symbol, fingerprint)
|
||||||
|
previous = self._last_hold_signal.get(sample_key)
|
||||||
|
if previous is not None and now - previous < hold_sample_seconds:
|
||||||
|
return False
|
||||||
|
self._last_hold_signal[sample_key] = now
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -282,25 +447,39 @@ class Storage:
|
|||||||
signal.action,
|
signal.action,
|
||||||
signal.confidence,
|
signal.confidence,
|
||||||
signal.reason,
|
signal.reason,
|
||||||
json.dumps(signal.diagnostics, ensure_ascii=False),
|
_signal_diagnostics_json(signal.diagnostics),
|
||||||
signal.created_at.isoformat(),
|
signal.created_at.isoformat(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
def recent_signals(self, limit: int = 80) -> list[dict[str, Any]]:
|
def recent_signals(self, limit: int = 80) -> list[dict[str, Any]]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def insert_equity(self, equity: float, cash: float, exposure: float, drawdown: float) -> None:
|
def insert_equity(
|
||||||
|
self,
|
||||||
|
equity: float,
|
||||||
|
cash: float,
|
||||||
|
exposure: float,
|
||||||
|
drawdown: float,
|
||||||
|
mode: str = "paper",
|
||||||
|
) -> None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO equity (equity, cash, exposure, drawdown, created_at) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO equity (equity, cash, exposure, drawdown, created_at, mode) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(equity, cash, exposure, drawdown, utc_now().isoformat()),
|
(equity, cash, exposure, drawdown, utc_now().isoformat(), mode),
|
||||||
)
|
)
|
||||||
|
|
||||||
def latest_equity(self) -> dict[str, Any] | None:
|
def latest_equity(self, mode: str | None = None) -> dict[str, Any] | None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
|
if mode:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM equity WHERE mode=? ORDER BY id DESC LIMIT 1",
|
||||||
|
(mode,),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
row = conn.execute("SELECT * FROM equity ORDER BY id DESC LIMIT 1").fetchone()
|
row = conn.execute("SELECT * FROM equity ORDER BY id DESC LIMIT 1").fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
@@ -376,12 +555,166 @@ class Storage:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
def upsert_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client_order_id: str,
|
||||||
|
exchange_order_id: str = "",
|
||||||
|
symbol: str,
|
||||||
|
side: str,
|
||||||
|
order_kind: str,
|
||||||
|
status: str,
|
||||||
|
requested_qty: float = 0.0,
|
||||||
|
requested_notional: float = 0.0,
|
||||||
|
executed_qty: float = 0.0,
|
||||||
|
executed_value: float = 0.0,
|
||||||
|
fee_usdt: float = 0.0,
|
||||||
|
raw: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
now = utc_now().isoformat()
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO orders (
|
||||||
|
client_order_id, exchange_order_id, symbol, side, order_kind,
|
||||||
|
status, requested_qty, requested_notional, executed_qty,
|
||||||
|
executed_value, fee_usdt, raw_json, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(client_order_id) DO UPDATE SET
|
||||||
|
exchange_order_id=excluded.exchange_order_id,
|
||||||
|
status=excluded.status,
|
||||||
|
executed_qty=excluded.executed_qty,
|
||||||
|
executed_value=excluded.executed_value,
|
||||||
|
fee_usdt=excluded.fee_usdt,
|
||||||
|
raw_json=excluded.raw_json,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
client_order_id,
|
||||||
|
exchange_order_id,
|
||||||
|
symbol,
|
||||||
|
side,
|
||||||
|
order_kind,
|
||||||
|
status,
|
||||||
|
requested_qty,
|
||||||
|
requested_notional,
|
||||||
|
executed_qty,
|
||||||
|
executed_value,
|
||||||
|
fee_usdt,
|
||||||
|
json.dumps(raw or {}, ensure_ascii=False),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def recent_orders(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM orders ORDER BY id DESC LIMIT ?",
|
||||||
|
(max(1, min(limit, 500)),),
|
||||||
|
).fetchall()
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["raw"] = _json_or_default(item.pop("raw_json", "{}"), {})
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def pending_orders(self) -> list[dict[str, Any]]:
|
||||||
|
terminal = ("Filled", "Cancelled", "Rejected", "PartiallyFilledCanceled", "Deactivated")
|
||||||
|
placeholders = ",".join("?" for _ in terminal)
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM orders WHERE status NOT IN ({placeholders}) ORDER BY id",
|
||||||
|
terminal,
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def prune(self, retention_days: int) -> dict[str, int]:
|
||||||
|
if retention_days <= 0:
|
||||||
|
return {}
|
||||||
|
cutoff = (utc_now() - timedelta(days=retention_days)).isoformat()
|
||||||
|
deleted: dict[str, int] = {}
|
||||||
|
for table in ("signals", "equity", "events", "llm_advice"):
|
||||||
|
with self.connect() as conn:
|
||||||
|
max_id_row = conn.execute(f"SELECT MAX(id) AS value FROM {table}").fetchone()
|
||||||
|
max_id = int(max_id_row["value"] or 0) if max_id_row else 0
|
||||||
|
cap_boundary = max(0, max_id - MAX_RUNTIME_ROWS[table])
|
||||||
|
removed = 0
|
||||||
|
if cap_boundary > 0:
|
||||||
|
cursor = conn.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM {table}
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM {table}
|
||||||
|
WHERE id <= ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(cap_boundary, PRUNE_BATCH_SIZE),
|
||||||
|
)
|
||||||
|
removed = max(0, int(cursor.rowcount))
|
||||||
|
remaining = max(0, PRUNE_BATCH_SIZE - removed)
|
||||||
|
if remaining:
|
||||||
|
cursor = conn.execute(
|
||||||
|
f"""
|
||||||
|
DELETE FROM {table}
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM {table}
|
||||||
|
WHERE created_at < ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(cutoff, remaining),
|
||||||
|
)
|
||||||
|
removed += max(0, int(cursor.rowcount))
|
||||||
|
deleted[table] = removed
|
||||||
|
conn.execute("PRAGMA incremental_vacuum(512)")
|
||||||
|
return deleted
|
||||||
|
|
||||||
def clear_all(self) -> None:
|
def clear_all(self) -> None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
for table in ("positions", "trades", "signals", "equity", "events", "runtime", "llm_advice"):
|
for table in ("positions", "trades", "signals", "equity", "events", "runtime", "llm_advice", "orders"):
|
||||||
conn.execute(f"DELETE FROM {table}")
|
conn.execute(f"DELETE FROM {table}")
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_diagnostics_json(diagnostics: dict[str, Any]) -> str:
|
||||||
|
compact = dict(diagnostics)
|
||||||
|
forecast = compact.get("forecast")
|
||||||
|
if isinstance(forecast, dict):
|
||||||
|
compact["forecast"] = {
|
||||||
|
key: value for key, value in forecast.items() if key in _STORED_FORECAST_KEYS
|
||||||
|
}
|
||||||
|
encoded = json.dumps(compact, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
size = len(encoded.encode("utf-8"))
|
||||||
|
if size <= MAX_SIGNAL_DIAGNOSTICS_BYTES:
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
fallback = {
|
||||||
|
"truncated": True,
|
||||||
|
"original_size_bytes": size,
|
||||||
|
"strategy_mode": compact.get("strategy_mode"),
|
||||||
|
"trade_mode": compact.get("trade_mode"),
|
||||||
|
"checks": compact.get("checks", {}),
|
||||||
|
"forecast": compact.get("forecast", {}),
|
||||||
|
}
|
||||||
|
encoded = json.dumps(fallback, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if len(encoded.encode("utf-8")) <= MAX_SIGNAL_DIAGNOSTICS_BYTES:
|
||||||
|
return encoded
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"truncated": True,
|
||||||
|
"original_size_bytes": size,
|
||||||
|
"strategy_mode": compact.get("strategy_mode"),
|
||||||
|
"trade_mode": compact.get("trade_mode"),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _json_or_default(value: str, default: Any) -> Any:
|
def _json_or_default(value: str, default: Any) -> Any:
|
||||||
try:
|
try:
|
||||||
return json.loads(value)
|
return json.loads(value)
|
||||||
|
|||||||
+154
-18
@@ -25,6 +25,35 @@ class SpotStrategy:
|
|||||||
trend_candles: list[Candle] | None = None,
|
trend_candles: list[Candle] | None = None,
|
||||||
) -> Signal:
|
) -> Signal:
|
||||||
if self.settings.strategy_mode == "torch_forecast":
|
if self.settings.strategy_mode == "torch_forecast":
|
||||||
|
fallback_reasons = torch_model_readiness_reasons(self.settings, forecast or {})
|
||||||
|
if self.settings.time_series_trend_fallback_enabled and fallback_reasons:
|
||||||
|
fallback = _trend_macd_entry_signal(
|
||||||
|
settings=self.settings,
|
||||||
|
symbol=symbol,
|
||||||
|
candles=candles,
|
||||||
|
trend_candles=trend_candles or [],
|
||||||
|
ticker=ticker,
|
||||||
|
open_positions_for_symbol=open_positions_for_symbol,
|
||||||
|
account=account,
|
||||||
|
)
|
||||||
|
diagnostics = dict(fallback.diagnostics)
|
||||||
|
diagnostics.update(
|
||||||
|
{
|
||||||
|
"strategy_mode": "torch_forecast",
|
||||||
|
"trade_mode": "TREND_MACD_FALLBACK",
|
||||||
|
"entry_path": "trend_macd_fallback",
|
||||||
|
"forecast_fallback_active": True,
|
||||||
|
"forecast_fallback_reasons": fallback_reasons,
|
||||||
|
"forecast": forecast or {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Signal(
|
||||||
|
fallback.symbol,
|
||||||
|
fallback.action,
|
||||||
|
fallback.confidence,
|
||||||
|
f"torch_forecast fallback: {fallback.reason}",
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
return _torch_forecast_entry_signal(
|
return _torch_forecast_entry_signal(
|
||||||
settings=self.settings,
|
settings=self.settings,
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
@@ -368,6 +397,24 @@ class SpotStrategy:
|
|||||||
forecast: dict | None = None,
|
forecast: dict | None = None,
|
||||||
) -> Signal:
|
) -> Signal:
|
||||||
if self.settings.strategy_mode == "torch_forecast":
|
if self.settings.strategy_mode == "torch_forecast":
|
||||||
|
if str(position.entry_diagnostics.get("entry_path", "")) == "trend_macd_fallback":
|
||||||
|
fallback = _trend_macd_exit_signal(self.settings, position, candles, ticker)
|
||||||
|
diagnostics = dict(fallback.diagnostics)
|
||||||
|
diagnostics.update(
|
||||||
|
{
|
||||||
|
"strategy_mode": "torch_forecast",
|
||||||
|
"trade_mode": "TREND_MACD_FALLBACK",
|
||||||
|
"entry_path": "trend_macd_fallback",
|
||||||
|
"forecast_fallback_active": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Signal(
|
||||||
|
fallback.symbol,
|
||||||
|
fallback.action,
|
||||||
|
fallback.confidence,
|
||||||
|
f"torch_forecast fallback: {fallback.reason}",
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
return _torch_forecast_exit_signal(self.settings, position, candles, ticker, forecast or {})
|
return _torch_forecast_exit_signal(self.settings, position, candles, ticker, forecast or {})
|
||||||
if self.settings.strategy_mode == "trend_macd":
|
if self.settings.strategy_mode == "trend_macd":
|
||||||
return _trend_macd_exit_signal(self.settings, position, candles, ticker)
|
return _trend_macd_exit_signal(self.settings, position, candles, ticker)
|
||||||
@@ -394,6 +441,7 @@ class SpotStrategy:
|
|||||||
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,
|
||||||
@@ -408,6 +456,7 @@ 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 effective_stop_loss is not None and price <= effective_stop_loss:
|
if effective_stop_loss is not None and price <= effective_stop_loss:
|
||||||
@@ -438,6 +487,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
|
||||||
@@ -639,10 +689,14 @@ def _torch_forecast_entry_signal(
|
|||||||
sizing = _torch_forecast_position_sizing(settings, account_context, stop_loss_percent, forecast, symbol)
|
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 = _forecast_probability(forecast)
|
||||||
skill = _safe_float(forecast.get("skill"), 0.0)
|
skill = _safe_float(forecast.get("skill"), 0.0)
|
||||||
min_edge = max(0.0, settings.time_series_min_edge_percent)
|
min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent))
|
||||||
min_probability = _torch_min_probability(settings)
|
min_probability = _clamp(
|
||||||
|
_safe_float(forecast.get("calibrated_min_probability_up"), _torch_min_probability(settings)),
|
||||||
|
0.5,
|
||||||
|
0.95,
|
||||||
|
)
|
||||||
probe_min_edge = max(0.0, min(settings.time_series_probe_min_edge_percent, min_edge))
|
probe_min_edge = max(0.0, min(settings.time_series_probe_min_edge_percent, min_edge))
|
||||||
probe_min_probability = round(
|
probe_min_probability = round(
|
||||||
_clamp(settings.time_series_probe_min_probability_up, min_probability, 0.85),
|
_clamp(settings.time_series_probe_min_probability_up, min_probability, 0.85),
|
||||||
@@ -675,7 +729,20 @@ 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
|
manual_quality_override = settings.time_series_manual_quality_override
|
||||||
|
quality_gate_ok = bool(
|
||||||
|
manual_quality_override
|
||||||
|
or (
|
||||||
|
forecast.get("quality_gate_passed") is True
|
||||||
|
if settings.time_series_require_quality_gate
|
||||||
|
else forecast.get("quality_gate_passed") is not False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model_fresh_ok = (
|
||||||
|
forecast.get("model_fresh") is True
|
||||||
|
if settings.time_series_require_fresh_model
|
||||||
|
else True
|
||||||
|
)
|
||||||
rebound = _torch_rebound_overlay(
|
rebound = _torch_rebound_overlay(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
candles=candles or [],
|
candles=candles or [],
|
||||||
@@ -694,20 +761,22 @@ def _torch_forecast_entry_signal(
|
|||||||
rebound.get("active")
|
rebound.get("active")
|
||||||
and model_ok
|
and model_ok
|
||||||
and quality_gate_ok
|
and quality_gate_ok
|
||||||
|
and model_fresh_ok
|
||||||
and bool(forecast.get("usable", False))
|
and bool(forecast.get("usable", False))
|
||||||
and not bool(forecast.get("block_entry", False))
|
and not bool(forecast.get("block_entry", False))
|
||||||
and expected_return >= 0.0
|
and expected_return >= 0.0
|
||||||
and probability_up >= rebound_model_probability_min
|
and probability_up >= rebound_model_probability_min
|
||||||
and skill > 0.0
|
and skill > 0.0
|
||||||
and confidence >= settings.time_series_min_confidence
|
and confidence >= _safe_float(forecast.get("calibrated_min_confidence"), settings.time_series_min_confidence)
|
||||||
)
|
)
|
||||||
fallback_rebound_entry_ok = bool(
|
fallback_rebound_entry_ok = bool(
|
||||||
settings.time_series_rebound_fallback_enabled
|
settings.time_series_rebound_fallback_enabled
|
||||||
and rebound.get("active")
|
and rebound.get("active")
|
||||||
and missing_torch_model
|
and missing_torch_model
|
||||||
and quality_gate_ok
|
and quality_gate_ok
|
||||||
|
and model_fresh_ok
|
||||||
and not bool(forecast.get("block_entry", False))
|
and not bool(forecast.get("block_entry", False))
|
||||||
and confidence >= settings.time_series_min_confidence
|
and confidence >= _safe_float(forecast.get("calibrated_min_confidence"), settings.time_series_min_confidence)
|
||||||
)
|
)
|
||||||
rebound_entry_ok = model_rebound_entry_ok or fallback_rebound_entry_ok
|
rebound_entry_ok = model_rebound_entry_ok or fallback_rebound_entry_ok
|
||||||
if rebound_entry_ok and position_notional > 0:
|
if rebound_entry_ok and position_notional > 0:
|
||||||
@@ -730,6 +799,7 @@ def _torch_forecast_entry_signal(
|
|||||||
checks = {
|
checks = {
|
||||||
"torch_model_ok": model_ok,
|
"torch_model_ok": model_ok,
|
||||||
"quality_gate_ok": quality_gate_ok,
|
"quality_gate_ok": quality_gate_ok,
|
||||||
|
"model_fresh_ok": model_fresh_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,
|
||||||
@@ -767,6 +837,10 @@ def _torch_forecast_entry_signal(
|
|||||||
"skill": skill,
|
"skill": skill,
|
||||||
"quality_gate": forecast.get("quality_gate", {}),
|
"quality_gate": forecast.get("quality_gate", {}),
|
||||||
"quality_gate_passed": forecast.get("quality_gate_passed"),
|
"quality_gate_passed": forecast.get("quality_gate_passed"),
|
||||||
|
"manual_quality_override": manual_quality_override,
|
||||||
|
"model_created_at": forecast.get("model_created_at", ""),
|
||||||
|
"model_age_hours": forecast.get("model_age_hours"),
|
||||||
|
"model_fresh": forecast.get("model_fresh", False),
|
||||||
"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,
|
||||||
@@ -871,11 +945,16 @@ def _torch_forecast_exit_signal(
|
|||||||
)
|
)
|
||||||
|
|
||||||
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 = _forecast_probability(forecast)
|
||||||
skill = _safe_float(forecast.get("skill"), 0.0)
|
skill = _safe_float(forecast.get("skill"), 0.0)
|
||||||
min_edge = max(0.0, settings.time_series_min_edge_percent)
|
min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent))
|
||||||
min_probability = _torch_min_probability(settings)
|
min_probability = _clamp(
|
||||||
|
_safe_float(forecast.get("calibrated_min_probability_up"), _torch_min_probability(settings)),
|
||||||
|
0.5,
|
||||||
|
0.95,
|
||||||
|
)
|
||||||
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_path = str(position.entry_diagnostics.get("entry_path", ""))
|
||||||
entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", ""))
|
entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", ""))
|
||||||
rebound_fallback_position = entry_path == "rebound_fallback" or entry_edge_mode == "rebound_fallback"
|
rebound_fallback_position = entry_path == "rebound_fallback" or entry_edge_mode == "rebound_fallback"
|
||||||
@@ -899,6 +978,7 @@ 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,
|
||||||
}
|
}
|
||||||
hold_seconds = (utc_now() - position.opened_at).total_seconds()
|
hold_seconds = (utc_now() - position.opened_at).total_seconds()
|
||||||
@@ -909,16 +989,40 @@ def _torch_forecast_exit_signal(
|
|||||||
if price >= position.take_profit:
|
if price >= position.take_profit:
|
||||||
return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics)
|
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:
|
if estimated_exit_net_percent < 0:
|
||||||
diagnostics["atr_exit_blocked_by_cost"] = True
|
diagnostics["atr_exit_blocked_by_cost"] = True
|
||||||
return Signal(
|
return Signal(
|
||||||
position.symbol,
|
position.symbol,
|
||||||
"HOLD",
|
"HOLD",
|
||||||
0.45,
|
0.45,
|
||||||
"torch_forecast: ATR trailing touched, but exit is not worth fees",
|
"torch_forecast: ATR trailing touched, but exit profit is below minimum",
|
||||||
diagnostics,
|
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 (
|
||||||
|
settings.time_series_require_quality_gate
|
||||||
|
and not settings.time_series_manual_quality_override
|
||||||
|
and forecast.get("quality_gate_passed") is not True
|
||||||
|
):
|
||||||
|
diagnostics["forecast_exit_blocked_by_quality_gate"] = True
|
||||||
|
return Signal(
|
||||||
|
position.symbol,
|
||||||
|
"HOLD",
|
||||||
|
0.42,
|
||||||
|
"torch_forecast: hold uses only risk exits while quality gate is unavailable",
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
if settings.time_series_require_fresh_model and forecast.get("model_fresh") is not True:
|
||||||
|
diagnostics["forecast_exit_blocked_by_model_age"] = True
|
||||||
|
return Signal(
|
||||||
|
position.symbol,
|
||||||
|
"HOLD",
|
||||||
|
0.42,
|
||||||
|
"torch_forecast: hold uses only risk exits while model is stale",
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
if not _is_torch_forecast(forecast):
|
if not _is_torch_forecast(forecast):
|
||||||
if rebound_fallback_position:
|
if rebound_fallback_position:
|
||||||
hold_seconds = (utc_now() - position.opened_at).total_seconds()
|
hold_seconds = (utc_now() - position.opened_at).total_seconds()
|
||||||
@@ -956,23 +1060,26 @@ def _torch_forecast_exit_signal(
|
|||||||
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=min_edge,
|
min_edge_percent=min_edge,
|
||||||
|
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
|
||||||
return Signal(position.symbol, action, confidence, reason, diagnostics)
|
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
|
diagnostics["forecast_exit_blocked_by_cost"] = True
|
||||||
return Signal(
|
return Signal(
|
||||||
position.symbol,
|
position.symbol,
|
||||||
"HOLD",
|
"HOLD",
|
||||||
0.44,
|
0.44,
|
||||||
(
|
(
|
||||||
"torch_forecast: forecast weakened, but exit is not worth fees; "
|
"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",
|
||||||
@@ -983,6 +1090,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -991,6 +1100,21 @@ 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 torch_model_readiness_reasons(settings: Settings, forecast: dict) -> list[str]:
|
||||||
|
reasons: list[str] = []
|
||||||
|
if not _is_torch_forecast(forecast):
|
||||||
|
reasons.append("torch_model_unavailable")
|
||||||
|
if (
|
||||||
|
settings.time_series_require_quality_gate
|
||||||
|
and not settings.time_series_manual_quality_override
|
||||||
|
and forecast.get("quality_gate_passed") is not True
|
||||||
|
):
|
||||||
|
reasons.append("quality_gate_not_passed")
|
||||||
|
if settings.time_series_require_fresh_model and forecast.get("model_fresh") is not True:
|
||||||
|
reasons.append("model_not_fresh")
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
def _missing_torch_model(forecast: dict) -> bool:
|
def _missing_torch_model(forecast: dict) -> bool:
|
||||||
model = str(forecast.get("model", "")).strip().lower()
|
model = str(forecast.get("model", "")).strip().lower()
|
||||||
reason = str(forecast.get("reason", "")).lower()
|
reason = str(forecast.get("reason", "")).lower()
|
||||||
@@ -1016,7 +1140,7 @@ def _dynamic_symbol_position_limit(settings: Settings) -> int:
|
|||||||
|
|
||||||
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 = _forecast_probability(forecast)
|
||||||
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
|
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
|
||||||
min_edge = max(0.01, settings.time_series_min_edge_percent)
|
min_edge = max(0.01, settings.time_series_min_edge_percent)
|
||||||
edge_strength = _clamp(expected_return / max(min_edge * 4.0, 0.01), 0.0, 1.0)
|
edge_strength = _clamp(expected_return / max(min_edge * 4.0, 0.01), 0.0, 1.0)
|
||||||
@@ -1044,7 +1168,7 @@ def _torch_forecast_position_sizing(
|
|||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
)
|
)
|
||||||
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 = _forecast_probability(forecast)
|
||||||
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
|
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
|
||||||
min_edge = max(0.01, settings.time_series_min_edge_percent)
|
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)
|
edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15)
|
||||||
@@ -1199,7 +1323,7 @@ def _position_risk_multiplier(forecast: dict | None, adaptive: dict | None) -> f
|
|||||||
multiplier = 1.0
|
multiplier = 1.0
|
||||||
forecast = forecast or {}
|
forecast = forecast or {}
|
||||||
if forecast.get("usable"):
|
if forecast.get("usable"):
|
||||||
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
|
probability_up = _forecast_probability(forecast)
|
||||||
volatility_percent = _safe_float(forecast.get("volatility_percent"), 0.0)
|
volatility_percent = _safe_float(forecast.get("volatility_percent"), 0.0)
|
||||||
if probability_up < 0.52:
|
if probability_up < 0.52:
|
||||||
multiplier *= 0.75
|
multiplier *= 0.75
|
||||||
@@ -1236,7 +1360,7 @@ def _kelly_position(
|
|||||||
probability_source = "confidence"
|
probability_source = "confidence"
|
||||||
probability = confidence_probability
|
probability = confidence_probability
|
||||||
if forecast.get("usable"):
|
if forecast.get("usable"):
|
||||||
probability = _safe_float(forecast.get("probability_up"), confidence_probability)
|
probability = _forecast_probability(forecast, confidence_probability)
|
||||||
probability_source = "forecast"
|
probability_source = "forecast"
|
||||||
probability = _clamp(probability, 0.0, 1.0)
|
probability = _clamp(probability, 0.0, 1.0)
|
||||||
|
|
||||||
@@ -1537,6 +1661,13 @@ def _rebound_state(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _forecast_probability(forecast: dict, default: float = 0.5) -> float:
|
||||||
|
value = forecast.get("probability_take_profit_first")
|
||||||
|
if not isinstance(value, (int, float, str)):
|
||||||
|
value = forecast.get("probability_up")
|
||||||
|
return _clamp(_safe_float(value, default), 0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value: object, default: float = 0.0) -> float:
|
def _safe_float(value: object, default: float = 0.0) -> float:
|
||||||
try:
|
try:
|
||||||
return float(value)
|
return float(value)
|
||||||
@@ -1633,6 +1764,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":
|
||||||
@@ -1649,18 +1784,19 @@ 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
|
||||||
skill = _safe_float(forecast.get("skill"), 0.0)
|
skill = _safe_float(forecast.get("skill"), 0.0)
|
||||||
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 = _forecast_probability(forecast)
|
||||||
min_edge = max(0.0, min_edge_percent)
|
min_edge = max(0.0, min_edge_percent)
|
||||||
strong_negative = skill > 0.02 and expected_return <= -max(min_edge, 0.03) and probability_up <= 0.44
|
strong_negative = skill > 0.02 and expected_return <= -max(min_edge, 0.03) and probability_up <= 0.44
|
||||||
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)
|
||||||
|
|||||||
+292
-12
@@ -4,6 +4,7 @@ import json
|
|||||||
import math
|
import math
|
||||||
from bisect import bisect_right
|
from bisect import bisect_right
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from crypto_spot_bot.config import Settings
|
from crypto_spot_bot.config import Settings
|
||||||
@@ -156,6 +157,13 @@ class TimeSeriesForecast:
|
|||||||
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_passed: bool | None = None
|
||||||
quality_gate: dict[str, Any] = field(default_factory=dict)
|
quality_gate: dict[str, Any] = field(default_factory=dict)
|
||||||
|
model_created_at: str = ""
|
||||||
|
model_age_hours: float | None = None
|
||||||
|
model_fresh: bool = False
|
||||||
|
calibrated_min_edge_percent: float = 0.0
|
||||||
|
calibrated_min_probability_up: float = 0.0
|
||||||
|
calibrated_min_confidence: float = 0.0
|
||||||
|
probability_take_profit_first: float | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return asdict(self)
|
return asdict(self)
|
||||||
@@ -188,8 +196,25 @@ 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()
|
model_created_at, model_age_hours, model_fresh = _model_freshness(
|
||||||
|
artifact,
|
||||||
|
self.settings.time_series_model_max_age_hours,
|
||||||
|
)
|
||||||
|
calibration = self._load_quality_gate()
|
||||||
|
quality_gate = (
|
||||||
|
calibration.get("validation")
|
||||||
|
if isinstance(calibration.get("validation"), dict)
|
||||||
|
else calibration
|
||||||
|
)
|
||||||
quality_gate_passed = _quality_gate_passed(quality_gate)
|
quality_gate_passed = _quality_gate_passed(quality_gate)
|
||||||
|
calibrated = _calibrated_thresholds(
|
||||||
|
calibration,
|
||||||
|
symbol,
|
||||||
|
edge=self.settings.time_series_min_edge_percent,
|
||||||
|
probability=self.settings.time_series_min_probability_up,
|
||||||
|
confidence=self.settings.time_series_min_confidence,
|
||||||
|
)
|
||||||
|
symbol_eligible = _calibration_symbol_eligible(calibration, symbol)
|
||||||
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)
|
||||||
@@ -230,6 +255,7 @@ class TimeSeriesForecaster:
|
|||||||
expected_gross_return = float(selected.get("expected_gross_return", expected_return))
|
expected_gross_return = float(selected.get("expected_gross_return", expected_return))
|
||||||
expected_price = closes[-1] * math.exp(expected_gross_return)
|
expected_price = closes[-1] * math.exp(expected_gross_return)
|
||||||
probability_up = _clamp(float(selected.get("probability_up", 0.5)), 0.0, 1.0)
|
probability_up = _clamp(float(selected.get("probability_up", 0.5)), 0.0, 1.0)
|
||||||
|
target_transform = str(entry.get("target_transform", "net_return_over_volatility"))
|
||||||
model_mae = max(float(selected.get("validation_mae", 0.0)), 1e-9)
|
model_mae = max(float(selected.get("validation_mae", 0.0)), 1e-9)
|
||||||
baseline_mae = max(float(selected.get("baseline_mae", model_mae)), model_mae)
|
baseline_mae = max(float(selected.get("baseline_mae", model_mae)), model_mae)
|
||||||
uncertainty = max(float(selected.get("uncertainty", model_mae)), 1e-9)
|
uncertainty = max(float(selected.get("uncertainty", model_mae)), 1e-9)
|
||||||
@@ -241,7 +267,7 @@ class TimeSeriesForecaster:
|
|||||||
q90_percent = (math.exp(float(selected.get("q90", expected_return))) - 1) * 100
|
q90_percent = (math.exp(float(selected.get("q90", expected_return))) - 1) * 100
|
||||||
skill = _clamp(_float_entry(entry, "skill", 0.0), -1.0, 1.0)
|
skill = _clamp(_float_entry(entry, "skill", 0.0), -1.0, 1.0)
|
||||||
horizon = int(selected.get("horizon", _entry_horizon(entry, self.settings.time_series_forecast_horizon)))
|
horizon = int(selected.get("horizon", _entry_horizon(entry, self.settings.time_series_forecast_horizon)))
|
||||||
min_edge = max(0.0, self.settings.time_series_min_edge_percent)
|
min_edge = calibrated["edge"]
|
||||||
confidence_adjustment = _confidence_adjustment(
|
confidence_adjustment = _confidence_adjustment(
|
||||||
expected_return_percent=expected_return_percent,
|
expected_return_percent=expected_return_percent,
|
||||||
probability_up=probability_up,
|
probability_up=probability_up,
|
||||||
@@ -251,21 +277,32 @@ class TimeSeriesForecaster:
|
|||||||
)
|
)
|
||||||
conservative_return_percent = min(expected_return_percent, q50_percent)
|
conservative_return_percent = min(expected_return_percent, q50_percent)
|
||||||
block_entry = bool(
|
block_entry = bool(
|
||||||
(expected_return_percent <= -min_edge and probability_up <= 0.45)
|
not symbol_eligible
|
||||||
|
or (expected_return_percent <= -min_edge and probability_up <= 0.45)
|
||||||
or (q50_percent <= -min_edge and probability_up <= 0.48)
|
or (q50_percent <= -min_edge and probability_up <= 0.48)
|
||||||
)
|
)
|
||||||
reason = _reason(
|
reason = (
|
||||||
|
_barrier_reason(model, expected_return_percent, probability_up, skill, block_entry)
|
||||||
|
if target_transform == "barrier_net_return"
|
||||||
|
else _reason(
|
||||||
model=model,
|
model=model,
|
||||||
expected_return_percent=expected_return_percent,
|
expected_return_percent=expected_return_percent,
|
||||||
probability_up=probability_up,
|
probability_up=probability_up,
|
||||||
skill=skill,
|
skill=skill,
|
||||||
block_entry=block_entry,
|
block_entry=block_entry,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
if not symbol_eligible:
|
||||||
|
reason = "symbol excluded by train-only calibration"
|
||||||
return TimeSeriesForecast(
|
return TimeSeriesForecast(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
usable=True,
|
usable=True,
|
||||||
model=model,
|
model=model,
|
||||||
volatility_model="probabilistic multi-horizon after-cost quantile",
|
volatility_model=(
|
||||||
|
"TP-before-SL multi-task after-cost model"
|
||||||
|
if target_transform == "barrier_net_return"
|
||||||
|
else "probabilistic multi-horizon after-cost quantile"
|
||||||
|
),
|
||||||
expected_return_percent=round(expected_return_percent, 4),
|
expected_return_percent=round(expected_return_percent, 4),
|
||||||
expected_price=round(expected_price, 8),
|
expected_price=round(expected_price, 8),
|
||||||
volatility_percent=round(volatility_percent, 4),
|
volatility_percent=round(volatility_percent, 4),
|
||||||
@@ -282,12 +319,23 @@ class TimeSeriesForecaster:
|
|||||||
quantile_50_percent=round(q50_percent, 4),
|
quantile_50_percent=round(q50_percent, 4),
|
||||||
quantile_90_percent=round(q90_percent, 4),
|
quantile_90_percent=round(q90_percent, 4),
|
||||||
conservative_return_percent=round(conservative_return_percent, 4),
|
conservative_return_percent=round(conservative_return_percent, 4),
|
||||||
target_transform=str(entry.get("target_transform", "net_return_over_volatility")),
|
target_transform=target_transform,
|
||||||
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_passed=quality_gate_passed,
|
||||||
quality_gate=quality_gate,
|
quality_gate=quality_gate,
|
||||||
|
model_created_at=model_created_at,
|
||||||
|
model_age_hours=model_age_hours,
|
||||||
|
model_fresh=model_fresh,
|
||||||
|
calibrated_min_edge_percent=calibrated["edge"],
|
||||||
|
calibrated_min_probability_up=calibrated["probability"],
|
||||||
|
calibrated_min_confidence=calibrated["confidence"],
|
||||||
|
probability_take_profit_first=(
|
||||||
|
round(probability_up, 4)
|
||||||
|
if target_transform == "barrier_net_return"
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
direct_horizon = _is_direct_horizon(entry)
|
direct_horizon = _is_direct_horizon(entry)
|
||||||
@@ -307,7 +355,7 @@ class TimeSeriesForecaster:
|
|||||||
expected_return_percent = (math.exp(expected_return) - 1) * 100
|
expected_return_percent = (math.exp(expected_return) - 1) * 100
|
||||||
probability_up = _normal_cdf(expected_return / max(uncertainty, 1e-9))
|
probability_up = _normal_cdf(expected_return / max(uncertainty, 1e-9))
|
||||||
skill = _clamp(_float_entry(entry, "skill", 0.0), -1.0, 1.0)
|
skill = _clamp(_float_entry(entry, "skill", 0.0), -1.0, 1.0)
|
||||||
min_edge = max(0.0, self.settings.time_series_min_edge_percent)
|
min_edge = calibrated["edge"]
|
||||||
confidence_adjustment = _confidence_adjustment(
|
confidence_adjustment = _confidence_adjustment(
|
||||||
expected_return_percent=expected_return_percent,
|
expected_return_percent=expected_return_percent,
|
||||||
probability_up=probability_up,
|
probability_up=probability_up,
|
||||||
@@ -315,7 +363,9 @@ class TimeSeriesForecaster:
|
|||||||
min_edge=min_edge,
|
min_edge=min_edge,
|
||||||
max_adjustment=self.settings.time_series_max_adjustment,
|
max_adjustment=self.settings.time_series_max_adjustment,
|
||||||
)
|
)
|
||||||
block_entry = bool(expected_return_percent <= -min_edge and probability_up <= 0.45)
|
block_entry = bool(
|
||||||
|
not symbol_eligible or (expected_return_percent <= -min_edge and probability_up <= 0.45)
|
||||||
|
)
|
||||||
reason = _reason(
|
reason = _reason(
|
||||||
model=model,
|
model=model,
|
||||||
expected_return_percent=expected_return_percent,
|
expected_return_percent=expected_return_percent,
|
||||||
@@ -323,6 +373,8 @@ class TimeSeriesForecaster:
|
|||||||
skill=skill,
|
skill=skill,
|
||||||
block_entry=block_entry,
|
block_entry=block_entry,
|
||||||
)
|
)
|
||||||
|
if not symbol_eligible:
|
||||||
|
reason = "symbol excluded by train-only calibration"
|
||||||
return TimeSeriesForecast(
|
return TimeSeriesForecast(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
usable=True,
|
usable=True,
|
||||||
@@ -350,6 +402,12 @@ class TimeSeriesForecaster:
|
|||||||
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_passed=quality_gate_passed,
|
||||||
quality_gate=quality_gate,
|
quality_gate=quality_gate,
|
||||||
|
model_created_at=model_created_at,
|
||||||
|
model_age_hours=model_age_hours,
|
||||||
|
model_fresh=model_fresh,
|
||||||
|
calibrated_min_edge_percent=calibrated["edge"],
|
||||||
|
calibrated_min_probability_up=calibrated["probability"],
|
||||||
|
calibrated_min_confidence=calibrated["confidence"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def _load_lstm_artifact(self) -> dict[str, Any]:
|
def _load_lstm_artifact(self) -> dict[str, Any]:
|
||||||
@@ -386,8 +444,7 @@ class TimeSeriesForecaster:
|
|||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
data = {}
|
data = {}
|
||||||
validation = data.get("validation") if isinstance(data, dict) else {}
|
self._quality_gate = data if isinstance(data, dict) else {}
|
||||||
self._quality_gate = validation if isinstance(validation, dict) else {}
|
|
||||||
self._calibration_mtime = stat.st_mtime
|
self._calibration_mtime = stat.st_mtime
|
||||||
return self._quality_gate
|
return self._quality_gate
|
||||||
|
|
||||||
@@ -420,12 +477,18 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
|
|||||||
candidates=[],
|
candidates=[],
|
||||||
quality_gate_passed=None,
|
quality_gate_passed=None,
|
||||||
quality_gate={},
|
quality_gate={},
|
||||||
|
model_created_at="",
|
||||||
|
model_age_hours=None,
|
||||||
|
model_fresh=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _quality_gate_passed(quality_gate: dict[str, Any]) -> bool | None:
|
def _quality_gate_passed(quality_gate: dict[str, Any]) -> bool | None:
|
||||||
if not quality_gate:
|
if not quality_gate:
|
||||||
return None
|
return None
|
||||||
|
validation = quality_gate.get("validation")
|
||||||
|
if isinstance(validation, dict):
|
||||||
|
return _quality_gate_passed(validation)
|
||||||
if "passed" in quality_gate:
|
if "passed" in quality_gate:
|
||||||
return bool(quality_gate.get("passed"))
|
return bool(quality_gate.get("passed"))
|
||||||
status = str(quality_gate.get("status", "")).strip().lower()
|
status = str(quality_gate.get("status", "")).strip().lower()
|
||||||
@@ -436,6 +499,50 @@ def _quality_gate_passed(quality_gate: dict[str, Any]) -> bool | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _calibrated_thresholds(
|
||||||
|
calibration: dict[str, Any],
|
||||||
|
symbol: str | None,
|
||||||
|
*,
|
||||||
|
edge: float,
|
||||||
|
probability: float,
|
||||||
|
confidence: float,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
recommended = calibration.get("recommended") if isinstance(calibration, dict) else None
|
||||||
|
per_symbol = calibration.get("symbol_recommendations") if isinstance(calibration, dict) else None
|
||||||
|
if symbol and isinstance(per_symbol, dict) and isinstance(per_symbol.get(symbol.upper()), dict):
|
||||||
|
recommended = per_symbol[symbol.upper()]
|
||||||
|
row = recommended if isinstance(recommended, dict) else {}
|
||||||
|
return {
|
||||||
|
"edge": max(0.0, float(row.get("edge", edge) or edge)),
|
||||||
|
"probability": _clamp(float(row.get("probability", probability) or probability), 0.5, 0.95),
|
||||||
|
"confidence": _clamp(float(row.get("confidence", confidence) or confidence), 0.0, 1.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calibration_symbol_eligible(calibration: dict[str, Any], symbol: str | None) -> bool:
|
||||||
|
if not isinstance(calibration, dict) or "eligible_symbols" not in calibration:
|
||||||
|
return True
|
||||||
|
eligible = calibration.get("eligible_symbols")
|
||||||
|
if not isinstance(eligible, list) or not symbol:
|
||||||
|
return False
|
||||||
|
allowed = {str(value).strip().upper() for value in eligible if str(value).strip()}
|
||||||
|
return symbol.strip().upper() in allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _model_freshness(artifact: dict[str, Any], max_age_hours: float) -> tuple[str, float | None, bool]:
|
||||||
|
raw = str(artifact.get("created_at", "")).strip() if isinstance(artifact, dict) else ""
|
||||||
|
if not raw:
|
||||||
|
return "", None, False
|
||||||
|
try:
|
||||||
|
created_at = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return raw, None, False
|
||||||
|
if created_at.tzinfo is None:
|
||||||
|
created_at = created_at.replace(tzinfo=UTC)
|
||||||
|
age_hours = max(0.0, (datetime.now(UTC) - created_at.astimezone(UTC)).total_seconds() / 3600)
|
||||||
|
return raw, round(age_hours, 4), age_hours <= max(0.1, max_age_hours)
|
||||||
|
|
||||||
|
|
||||||
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))]
|
||||||
|
|
||||||
@@ -494,6 +601,8 @@ def _feature_context(
|
|||||||
def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle, context: dict[str, Any]) -> float:
|
def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle, context: dict[str, Any]) -> float:
|
||||||
close = max(float(candle.close), 1e-12)
|
close = max(float(candle.close), 1e-12)
|
||||||
previous = candles[index - 1] if index >= 1 else candle
|
previous = candles[index - 1] if index >= 1 else candle
|
||||||
|
if name.startswith("symbol_is_"):
|
||||||
|
return 1.0 if context.get("symbol") == name.removeprefix("symbol_is_").upper() else 0.0
|
||||||
if name == "return_1":
|
if name == "return_1":
|
||||||
return _log_change(candle.close, previous.close)
|
return _log_change(candle.close, previous.close)
|
||||||
if name == "return_3":
|
if name == "return_3":
|
||||||
@@ -922,7 +1031,12 @@ def _torch_recurrent_entry(symbol: str | None, artifact: dict[str, Any]) -> dict
|
|||||||
entry = default if isinstance(default, dict) else None
|
entry = default if isinstance(default, dict) else None
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
return None
|
return None
|
||||||
if not isinstance(entry.get("state_dict"), dict):
|
members = entry.get("ensemble_members")
|
||||||
|
has_member_state = isinstance(members, list) and any(
|
||||||
|
isinstance(member, dict) and isinstance(member.get("state_dict"), dict)
|
||||||
|
for member in members
|
||||||
|
)
|
||||||
|
if not isinstance(entry.get("state_dict"), dict) and not has_member_state:
|
||||||
return None
|
return None
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
@@ -959,6 +1073,31 @@ def _torch_recurrent_predict(
|
|||||||
model_name = _torch_recurrent_model_name(symbol, artifact)
|
model_name = _torch_recurrent_model_name(symbol, artifact)
|
||||||
if not entry or not model_name:
|
if not entry or not model_name:
|
||||||
return None
|
return None
|
||||||
|
ensemble_members = entry.get("ensemble_members")
|
||||||
|
if isinstance(ensemble_members, list) and ensemble_members:
|
||||||
|
predictions: list[float | dict[str, Any]] = []
|
||||||
|
for member in ensemble_members:
|
||||||
|
if not isinstance(member, dict):
|
||||||
|
continue
|
||||||
|
member_entry = {**entry, **member}
|
||||||
|
member_entry.pop("ensemble_members", None)
|
||||||
|
member_entry.pop("ensemble_size", None)
|
||||||
|
member_artifact: dict[str, Any] = {"type": "pytorch_recurrent_forecaster"}
|
||||||
|
if symbol:
|
||||||
|
member_artifact["symbols"] = {symbol.upper(): member_entry}
|
||||||
|
else:
|
||||||
|
member_artifact["default"] = member_entry
|
||||||
|
prediction = _torch_recurrent_predict(
|
||||||
|
returns,
|
||||||
|
symbol,
|
||||||
|
member_artifact,
|
||||||
|
feature_rows=feature_rows,
|
||||||
|
closes=closes,
|
||||||
|
candles=candles,
|
||||||
|
)
|
||||||
|
if isinstance(prediction, (int, float, dict)):
|
||||||
|
predictions.append(prediction)
|
||||||
|
return _average_ensemble_predictions(predictions)
|
||||||
lookback = int(_clamp(_float_entry(entry, "lookback", 0.0), 4.0, 512.0))
|
lookback = int(_clamp(_float_entry(entry, "lookback", 0.0), 4.0, 512.0))
|
||||||
hidden_size = int(_clamp(_float_entry(entry, "hidden_size", 0.0), 1.0, 512.0))
|
hidden_size = int(_clamp(_float_entry(entry, "hidden_size", 0.0), 1.0, 512.0))
|
||||||
num_layers = int(_clamp(_float_entry(entry, "num_layers", 1.0), 1.0, 8.0))
|
num_layers = int(_clamp(_float_entry(entry, "num_layers", 1.0), 1.0, 8.0))
|
||||||
@@ -1019,8 +1158,76 @@ def _torch_recurrent_predict(
|
|||||||
return _clamp(prediction, -cap, cap)
|
return _clamp(prediction, -cap, cap)
|
||||||
|
|
||||||
|
|
||||||
|
def _average_ensemble_predictions(predictions: list[float | dict[str, Any]]) -> float | dict[str, Any] | None:
|
||||||
|
if not predictions:
|
||||||
|
return None
|
||||||
|
numeric = [float(value) for value in predictions if isinstance(value, (int, float))]
|
||||||
|
if numeric:
|
||||||
|
return sum(numeric) / len(numeric)
|
||||||
|
mappings = [value for value in predictions if isinstance(value, dict)]
|
||||||
|
if not mappings:
|
||||||
|
return None
|
||||||
|
first = mappings[0]
|
||||||
|
output: dict[str, Any] = {}
|
||||||
|
for key, value in first.items():
|
||||||
|
if key == "horizons" and isinstance(value, dict):
|
||||||
|
horizons: dict[str, Any] = {}
|
||||||
|
for horizon, row in value.items():
|
||||||
|
rows = [item.get("horizons", {}).get(horizon) for item in mappings]
|
||||||
|
rows = [item for item in rows if isinstance(item, dict)]
|
||||||
|
if rows:
|
||||||
|
horizons[horizon] = _average_ensemble_predictions(rows)
|
||||||
|
output[key] = horizons
|
||||||
|
continue
|
||||||
|
values = [item.get(key) for item in mappings]
|
||||||
|
finite = [float(item) for item in values if isinstance(item, (int, float)) and math.isfinite(float(item))]
|
||||||
|
output[key] = sum(finite) / len(finite) if finite else value
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
def _torch_head_outputs(context: list[float], entry: dict[str, Any], hidden_size: int) -> list[float]:
|
def _torch_head_outputs(context: list[float], entry: dict[str, Any], hidden_size: int) -> list[float]:
|
||||||
context = _apply_context_norm(context, entry)
|
context = _apply_context_norm(context, entry)
|
||||||
|
if entry.get("multitask_head") is True:
|
||||||
|
hidden_matrix = _float_matrix(entry.get("head_hidden_weight"))
|
||||||
|
hidden_bias = _float_vector(entry.get("head_hidden_bias"))
|
||||||
|
if not hidden_matrix or len(hidden_bias) != len(hidden_matrix):
|
||||||
|
return []
|
||||||
|
shared = [
|
||||||
|
_gelu(_dot(row, context) + hidden_bias[index])
|
||||||
|
for index, row in enumerate(hidden_matrix)
|
||||||
|
if len(row) == hidden_size
|
||||||
|
]
|
||||||
|
if len(shared) != len(hidden_matrix):
|
||||||
|
return []
|
||||||
|
return_matrix = _float_matrix(entry.get("return_head_weight"))
|
||||||
|
return_bias = _float_vector(entry.get("return_head_bias"))
|
||||||
|
event_matrix = _float_matrix(entry.get("event_head_weight"))
|
||||||
|
event_bias = _float_vector(entry.get("event_head_bias"))
|
||||||
|
if (
|
||||||
|
not return_matrix
|
||||||
|
or len(return_bias) != len(return_matrix)
|
||||||
|
or not event_matrix
|
||||||
|
or len(event_bias) != len(event_matrix)
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
return_values = [
|
||||||
|
_dot(row, shared) + return_bias[index]
|
||||||
|
for index, row in enumerate(return_matrix)
|
||||||
|
if len(row) == len(shared)
|
||||||
|
]
|
||||||
|
event_values = [
|
||||||
|
_dot(row, shared) + event_bias[index]
|
||||||
|
for index, row in enumerate(event_matrix)
|
||||||
|
if len(row) == len(shared)
|
||||||
|
]
|
||||||
|
if len(return_values) != len(event_values) * 4:
|
||||||
|
return []
|
||||||
|
outputs: list[float] = []
|
||||||
|
for horizon_index, event_value in enumerate(event_values):
|
||||||
|
base = horizon_index * 4
|
||||||
|
outputs.extend(return_values[base : base + 4])
|
||||||
|
outputs.append(event_value)
|
||||||
|
return outputs
|
||||||
raw_weight = entry.get("head_weight")
|
raw_weight = entry.get("head_weight")
|
||||||
if isinstance(raw_weight, list) and raw_weight and isinstance(raw_weight[0], list):
|
if isinstance(raw_weight, list) and raw_weight and isinstance(raw_weight[0], list):
|
||||||
matrix = _float_matrix(raw_weight)
|
matrix = _float_matrix(raw_weight)
|
||||||
@@ -1091,8 +1298,18 @@ def _decode_multi_horizon_prediction(
|
|||||||
|
|
||||||
expected = decode("mean")
|
expected = decode("mean")
|
||||||
q_values = sorted([decode("q10", expected), decode("q50", expected), decode("q90", expected)])
|
q_values = sorted([decode("q10", expected), decode("q50", expected), decode("q90", expected)])
|
||||||
probability_up = _sigmoid(float(values.get("logit_up", 0.0)))
|
probability_up = _sigmoid(
|
||||||
|
float(values.get("logit_tp_first", values.get("logit_up", 0.0)))
|
||||||
|
)
|
||||||
cap = _prediction_cap(closes, horizon, round_trip_cost)
|
cap = _prediction_cap(closes, horizon, round_trip_cost)
|
||||||
|
if str(entry.get("target_transform", "")) == "barrier_net_return":
|
||||||
|
stop_percent = _clamp(_float_entry(entry, "target_stop_loss_percent", 0.04), 0.003, 0.08)
|
||||||
|
take_percent = _clamp(_float_entry(entry, "target_take_profit_percent", 0.035), 0.003, 0.20)
|
||||||
|
cap = max(
|
||||||
|
cap,
|
||||||
|
abs(math.log(1.0 - stop_percent) - round_trip_cost),
|
||||||
|
abs(math.log(1.0 + take_percent) - round_trip_cost),
|
||||||
|
)
|
||||||
expected = _clamp(expected, -cap, cap)
|
expected = _clamp(expected, -cap, cap)
|
||||||
q10 = _clamp(q_values[0], -cap, cap)
|
q10 = _clamp(q_values[0], -cap, cap)
|
||||||
q50 = _clamp(q_values[1], -cap, cap)
|
q50 = _clamp(q_values[1], -cap, cap)
|
||||||
@@ -1116,6 +1333,11 @@ def _decode_multi_horizon_prediction(
|
|||||||
"q50": q50,
|
"q50": q50,
|
||||||
"q90": q90,
|
"q90": q90,
|
||||||
"probability_up": probability_up,
|
"probability_up": probability_up,
|
||||||
|
"probability_take_profit_first": (
|
||||||
|
probability_up
|
||||||
|
if str(entry.get("target_transform", "")) == "barrier_net_return"
|
||||||
|
else None
|
||||||
|
),
|
||||||
"volatility_scale": vol_scale,
|
"volatility_scale": vol_scale,
|
||||||
"validation_mae": mae,
|
"validation_mae": mae,
|
||||||
"baseline_mae": base_mae,
|
"baseline_mae": base_mae,
|
||||||
@@ -1546,6 +1768,10 @@ def _public_horizon_forecasts(prediction: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"quantile_50_percent": round((math.exp(float(row.get("q50", 0.0))) - 1) * 100, 4),
|
"quantile_50_percent": round((math.exp(float(row.get("q50", 0.0))) - 1) * 100, 4),
|
||||||
"quantile_90_percent": round((math.exp(float(row.get("q90", 0.0))) - 1) * 100, 4),
|
"quantile_90_percent": round((math.exp(float(row.get("q90", 0.0))) - 1) * 100, 4),
|
||||||
}
|
}
|
||||||
|
if isinstance(row.get("probability_take_profit_first"), (int, float)):
|
||||||
|
public[key]["probability_take_profit_first"] = round(
|
||||||
|
_clamp(float(row["probability_take_profit_first"]), 0.0, 1.0), 4
|
||||||
|
)
|
||||||
return public
|
return public
|
||||||
|
|
||||||
|
|
||||||
@@ -1577,6 +1803,10 @@ def _dot(left: list[float], right: list[float]) -> float:
|
|||||||
return sum(left[index] * right[index] for index in range(min(len(left), len(right))))
|
return sum(left[index] * right[index] for index in range(min(len(left), len(right))))
|
||||||
|
|
||||||
|
|
||||||
|
def _gelu(value: float) -> float:
|
||||||
|
return 0.5 * value * (1.0 + math.erf(value / math.sqrt(2.0)))
|
||||||
|
|
||||||
|
|
||||||
def _return_scale(returns: list[float]) -> float:
|
def _return_scale(returns: list[float]) -> float:
|
||||||
recent = returns[-120:] if len(returns) > 120 else returns
|
recent = returns[-120:] if len(returns) > 120 else returns
|
||||||
values = sorted(abs(value) for value in recent if math.isfinite(value))
|
values = sorted(abs(value) for value in recent if math.isfinite(value))
|
||||||
@@ -1621,6 +1851,41 @@ def _prediction_cap(closes: list[float], horizon: int, round_trip_cost: float) -
|
|||||||
return max(base * 1.5 + round_trip_cost, 0.0005)
|
return max(base * 1.5 + round_trip_cost, 0.0005)
|
||||||
|
|
||||||
|
|
||||||
|
def _barrier_outcome(
|
||||||
|
candles: list[Candle],
|
||||||
|
*,
|
||||||
|
end_index: int,
|
||||||
|
horizon: int,
|
||||||
|
stop_loss_percent: float,
|
||||||
|
take_profit_percent: float,
|
||||||
|
round_trip_cost: float,
|
||||||
|
) -> tuple[float, float] | None:
|
||||||
|
"""Return after-cost log PnL and whether TP was reached before SL."""
|
||||||
|
entry_index = end_index + 1
|
||||||
|
exit_index = end_index + max(1, horizon)
|
||||||
|
if entry_index >= len(candles) or exit_index >= len(candles):
|
||||||
|
return None
|
||||||
|
entry = float(candles[entry_index].open)
|
||||||
|
if entry <= 0:
|
||||||
|
return None
|
||||||
|
stop = entry * (1.0 - _clamp(stop_loss_percent, 0.003, 0.08))
|
||||||
|
take = entry * (1.0 + _clamp(take_profit_percent, 0.003, 0.20))
|
||||||
|
for index in range(entry_index, exit_index + 1):
|
||||||
|
candle = candles[index]
|
||||||
|
stop_hit = float(candle.low) <= stop
|
||||||
|
take_hit = float(candle.high) >= take
|
||||||
|
# OHLC data cannot reveal intrabar ordering, so ties are resolved
|
||||||
|
# conservatively as stop-loss first.
|
||||||
|
if stop_hit:
|
||||||
|
return math.log(stop / entry) - round_trip_cost, 0.0
|
||||||
|
if take_hit:
|
||||||
|
return math.log(take / entry) - round_trip_cost, 1.0
|
||||||
|
terminal = float(candles[exit_index].close)
|
||||||
|
if terminal <= 0:
|
||||||
|
return None
|
||||||
|
return math.log(terminal / entry) - round_trip_cost, 0.0
|
||||||
|
|
||||||
|
|
||||||
def _sigmoid(value: float) -> float:
|
def _sigmoid(value: float) -> float:
|
||||||
if value >= 40:
|
if value >= 40:
|
||||||
return 1.0
|
return 1.0
|
||||||
@@ -1662,6 +1927,21 @@ def _reason(
|
|||||||
return f"model {model}: forecast {expected_return_percent:.3f}%, P(up)={probability_up:.2f}, skill={skill:.3f}"
|
return f"model {model}: forecast {expected_return_percent:.3f}%, P(up)={probability_up:.2f}, skill={skill:.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _barrier_reason(
|
||||||
|
model: str,
|
||||||
|
expected_return_percent: float,
|
||||||
|
probability_take_profit_first: float,
|
||||||
|
skill: float,
|
||||||
|
block_entry: bool,
|
||||||
|
) -> str:
|
||||||
|
state = "entry blocked" if block_entry else "entry evaluated"
|
||||||
|
return (
|
||||||
|
f"model {model}: expected net {expected_return_percent:.3f}%, "
|
||||||
|
f"P(TP before SL)={probability_take_profit_first:.2f}, "
|
||||||
|
f"skill={skill:.3f}; {state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normal_cdf(value: float) -> float:
|
def _normal_cdf(value: float) -> float:
|
||||||
return 0.5 * (1 + math.erf(value / math.sqrt(2)))
|
return 0.5 * (1 + math.erf(value / math.sqrt(2)))
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -20,6 +22,12 @@ ALLOWED_TRAINING_ARTIFACTS = {
|
|||||||
}
|
}
|
||||||
RUNNING_TIMEOUT = timedelta(hours=12)
|
RUNNING_TIMEOUT = timedelta(hours=12)
|
||||||
ONLINE_WINDOW = timedelta(minutes=3)
|
ONLINE_WINDOW = timedelta(minutes=3)
|
||||||
|
MAX_ARTIFACT_CHUNK_BYTES = 1024 * 1024
|
||||||
|
# Independent per-symbol ensembles are intentionally larger than pooled models.
|
||||||
|
# Keep a bounded limit, but leave enough room for the supported 12-symbol bundle.
|
||||||
|
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
||||||
|
MAX_ARTIFACT_CHUNKS = 1024
|
||||||
|
REQUIRED_MODEL_BUNDLE = set(ALLOWED_TRAINING_ARTIFACTS)
|
||||||
|
|
||||||
|
|
||||||
class TrainingCoordinator:
|
class TrainingCoordinator:
|
||||||
@@ -91,6 +99,7 @@ class TrainingCoordinator:
|
|||||||
return {"claimed": True, "job": job, "status": self._public_status(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]:
|
def save_artifact_chunk(self, job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
job_id = _valid_job_id(job_id)
|
||||||
name = Path(str(payload.get("name") or "")).name
|
name = Path(str(payload.get("name") or "")).name
|
||||||
if name not in ALLOWED_TRAINING_ARTIFACTS:
|
if name not in ALLOWED_TRAINING_ARTIFACTS:
|
||||||
raise ValueError(f"artifact is not allowed: {name}")
|
raise ValueError(f"artifact is not allowed: {name}")
|
||||||
@@ -99,58 +108,87 @@ class TrainingCoordinator:
|
|||||||
sha256 = str(payload.get("sha256") or "").strip().lower()
|
sha256 = str(payload.get("sha256") or "").strip().lower()
|
||||||
if index < 0 or total <= 0 or index >= total:
|
if index < 0 or total <= 0 or index >= total:
|
||||||
raise ValueError("invalid artifact chunk index")
|
raise ValueError("invalid artifact chunk index")
|
||||||
if not sha256:
|
if total > MAX_ARTIFACT_CHUNKS:
|
||||||
raise ValueError("artifact sha256 is required")
|
raise ValueError("artifact has too many chunks")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{64}", sha256):
|
||||||
|
raise ValueError("artifact sha256 is invalid")
|
||||||
try:
|
try:
|
||||||
chunk = base64.b64decode(str(payload.get("data_base64") or ""), validate=True)
|
chunk = base64.b64decode(str(payload.get("data_base64") or ""), validate=True)
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
raise ValueError("invalid artifact chunk payload") from exc
|
raise ValueError("invalid artifact chunk payload") from exc
|
||||||
|
if not chunk or len(chunk) > MAX_ARTIFACT_CHUNK_BYTES:
|
||||||
|
raise ValueError("artifact chunk size is invalid")
|
||||||
|
|
||||||
chunk_dir = self.upload_root / job_id / name
|
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 job.get("status") != "running" or not job.get("claimed_by"):
|
||||||
|
raise ValueError("training job is not claimed and running")
|
||||||
|
uploads = job.setdefault("uploads", {})
|
||||||
|
upload = uploads.setdefault(name, {"sha256": sha256, "total": total})
|
||||||
|
if upload.get("sha256") != sha256 or int(upload.get("total", 0)) != total:
|
||||||
|
raise ValueError("artifact upload metadata changed during upload")
|
||||||
|
|
||||||
|
chunk_dir = self.upload_root / job_id / "chunks" / name
|
||||||
chunk_dir.mkdir(parents=True, exist_ok=True)
|
chunk_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(chunk_dir / f"{index:06d}.part").write_bytes(chunk)
|
(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)):
|
received = sum(1 for part in range(total) if (chunk_dir / f"{part:06d}.part").is_file())
|
||||||
return {"complete": False, "received": index + 1, "total": total}
|
if received < total:
|
||||||
|
upload["received"] = received
|
||||||
|
self._save_state(state)
|
||||||
|
return {"complete": False, "received": received, "total": total}
|
||||||
|
|
||||||
target_tmp = self.runtime_dir / f".{name}.{job_id}.tmp"
|
ready_dir = self.upload_root / job_id / "ready"
|
||||||
|
ready_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_tmp = ready_dir / f".{name}.tmp"
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
with target_tmp.open("wb") as output:
|
with target_tmp.open("wb") as output:
|
||||||
for part in range(total):
|
for part in range(total):
|
||||||
data = (chunk_dir / f"{part:06d}.part").read_bytes()
|
data = (chunk_dir / f"{part:06d}.part").read_bytes()
|
||||||
|
size += len(data)
|
||||||
|
if size > MAX_ARTIFACT_BYTES:
|
||||||
|
target_tmp.unlink(missing_ok=True)
|
||||||
|
raise ValueError("artifact exceeds maximum size")
|
||||||
digest.update(data)
|
digest.update(data)
|
||||||
output.write(data)
|
output.write(data)
|
||||||
if digest.hexdigest().lower() != sha256:
|
if digest.hexdigest().lower() != sha256:
|
||||||
target_tmp.unlink(missing_ok=True)
|
target_tmp.unlink(missing_ok=True)
|
||||||
raise ValueError("artifact sha256 mismatch")
|
raise ValueError("artifact sha256 mismatch")
|
||||||
|
|
||||||
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
target = ready_dir / name
|
||||||
os.replace(target_tmp, self.runtime_dir / name)
|
os.replace(target_tmp, target)
|
||||||
_remove_tree(chunk_dir)
|
_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 = job.setdefault("artifacts", [])
|
||||||
artifacts = [item for item in artifacts if item.get("name") != name]
|
artifacts = [item for item in artifacts if item.get("name") != name]
|
||||||
artifacts.append({"name": name, "sha256": sha256, "uploaded_at": _now()})
|
artifacts.append(
|
||||||
|
{"name": name, "sha256": sha256, "size": size, "staged_at": _now()}
|
||||||
|
)
|
||||||
job["artifacts"] = artifacts
|
job["artifacts"] = artifacts
|
||||||
|
upload["received"] = total
|
||||||
|
upload["complete"] = True
|
||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
return {"complete": True, "name": name, "sha256": sha256}
|
return {"complete": True, "staged": True, "name": name, "sha256": sha256}
|
||||||
|
|
||||||
def progress(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
def progress(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
|
job_id = _valid_job_id(job_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
state = self._load_state()
|
state = self._load_state()
|
||||||
job = self._job_by_id(state, job_id)
|
job = self._job_by_id(state, job_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise ValueError(f"training job not found: {job_id}")
|
raise ValueError(f"training job not found: {job_id}")
|
||||||
|
if job.get("status") != "running" or not job.get("claimed_by"):
|
||||||
|
raise ValueError("training job is not claimed and running")
|
||||||
if isinstance(payload.get("worker"), dict):
|
if isinstance(payload.get("worker"), dict):
|
||||||
state["worker"] = self._worker_from_payload(payload["worker"])
|
state["worker"] = self._worker_from_payload(payload["worker"])
|
||||||
job["status"] = str(payload.get("status") or job.get("status") or "running")
|
job["status"] = "running"
|
||||||
job["phase"] = str(payload.get("phase") or job.get("phase") or "running")
|
job["phase"] = str(payload.get("phase") or job.get("phase") or "running")[:80]
|
||||||
job["message"] = str(payload.get("message") or job.get("message") or "")
|
job["message"] = str(payload.get("message") or job.get("message") or "")[:2000]
|
||||||
job["progress_percent"] = _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
|
job["progress_percent"] = _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
|
||||||
job["updated_at"] = _now()
|
job["updated_at"] = _now()
|
||||||
if isinstance(payload.get("details"), dict):
|
if isinstance(payload.get("details"), dict):
|
||||||
@@ -160,12 +198,18 @@ class TrainingCoordinator:
|
|||||||
|
|
||||||
def complete(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
def complete(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
|
job_id = _valid_job_id(job_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
state = self._load_state()
|
state = self._load_state()
|
||||||
job = self._job_by_id(state, job_id)
|
job = self._job_by_id(state, job_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise ValueError(f"training job not found: {job_id}")
|
raise ValueError(f"training job not found: {job_id}")
|
||||||
|
if job.get("status") != "running" or not job.get("claimed_by"):
|
||||||
|
raise ValueError("training job is not claimed and running")
|
||||||
success = bool(payload.get("success", payload.get("status") == "completed"))
|
success = bool(payload.get("success", payload.get("status") == "completed"))
|
||||||
|
if success and job.get("artifacts"):
|
||||||
|
promoted = self._validate_and_promote(job_id, job)
|
||||||
|
job["promoted_artifacts"] = promoted
|
||||||
job["status"] = "completed" if success else "failed"
|
job["status"] = "completed" if success else "failed"
|
||||||
job["phase"] = "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["progress_percent"] = 100 if success else _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
|
||||||
@@ -173,9 +217,72 @@ class TrainingCoordinator:
|
|||||||
job["message"] = str(payload.get("message") or "")
|
job["message"] = str(payload.get("message") or "")
|
||||||
if isinstance(payload.get("summary"), dict):
|
if isinstance(payload.get("summary"), dict):
|
||||||
job["summary"] = payload["summary"]
|
job["summary"] = payload["summary"]
|
||||||
|
if isinstance(payload["summary"].get("accepted"), bool):
|
||||||
|
job["model_decision"] = (
|
||||||
|
"accepted" if payload["summary"]["accepted"] else "rejected"
|
||||||
|
)
|
||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
return {"ok": True, "job": job, "status": self._public_status(state)}
|
return {"ok": True, "job": job, "status": self._public_status(state)}
|
||||||
|
|
||||||
|
def _validate_and_promote(self, job_id: str, job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
ready_dir = self.upload_root / job_id / "ready"
|
||||||
|
staged = {path.name for path in ready_dir.iterdir() if path.is_file()} if ready_dir.is_dir() else set()
|
||||||
|
missing = REQUIRED_MODEL_BUNDLE - staged
|
||||||
|
if missing:
|
||||||
|
raise ValueError("training bundle is incomplete: " + ", ".join(sorted(missing)))
|
||||||
|
|
||||||
|
model = _read_json(ready_dir / "lstm_forecaster.json")
|
||||||
|
guard = _read_json(ready_dir / "torch_retrain_guard.json")
|
||||||
|
calibration = _read_json(ready_dir / "torch_threshold_calibration.json")
|
||||||
|
if model.get("type") != "pytorch_recurrent_forecaster":
|
||||||
|
raise ValueError("candidate model type is invalid")
|
||||||
|
symbols = model.get("symbols")
|
||||||
|
if not isinstance(symbols, dict) or not symbols:
|
||||||
|
raise ValueError("candidate model has no symbol models")
|
||||||
|
_validate_symbol_models(symbols)
|
||||||
|
model_sha256 = hashlib.sha256((ready_dir / "lstm_forecaster.json").read_bytes()).hexdigest()
|
||||||
|
if calibration.get("artifact_sha256") != model_sha256:
|
||||||
|
raise ValueError("candidate calibration is not bound to the uploaded model")
|
||||||
|
if not bool(guard.get("accepted")):
|
||||||
|
raise ValueError("candidate retrain guard did not accept the model")
|
||||||
|
if guard.get("candidate_artifact_sha256") != model_sha256:
|
||||||
|
raise ValueError("candidate guard is not bound to the uploaded model")
|
||||||
|
validation = calibration.get("validation")
|
||||||
|
if not isinstance(validation, dict) or not _validation_passed(validation):
|
||||||
|
raise ValueError("candidate quality gate did not pass")
|
||||||
|
if validation.get("protocol") != "untouched_model_holdout_with_threshold_walk_forward":
|
||||||
|
raise ValueError("candidate validation protocol is not an untouched holdout")
|
||||||
|
|
||||||
|
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
backup_dir = self.runtime_dir / ".model_backups" / f"{_compact_now()}-{job_id}"
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name in sorted(REQUIRED_MODEL_BUNDLE):
|
||||||
|
current = self.runtime_dir / name
|
||||||
|
if current.is_file():
|
||||||
|
shutil.copy2(current, backup_dir / name)
|
||||||
|
|
||||||
|
promoted: list[dict[str, Any]] = []
|
||||||
|
artifact_rows = {
|
||||||
|
str(item.get("name")): item
|
||||||
|
for item in job.get("artifacts", [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
for name in sorted(REQUIRED_MODEL_BUNDLE):
|
||||||
|
staged_path = ready_dir / name
|
||||||
|
target_tmp = self.runtime_dir / f".{name}.{job_id}.promote"
|
||||||
|
shutil.copy2(staged_path, target_tmp)
|
||||||
|
os.replace(target_tmp, self.runtime_dir / name)
|
||||||
|
row = artifact_rows.get(name, {})
|
||||||
|
promoted.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"sha256": row.get("sha256", ""),
|
||||||
|
"promoted_at": _now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_remove_tree(self.upload_root / job_id)
|
||||||
|
return promoted
|
||||||
|
|
||||||
def _load_state(self) -> dict[str, Any]:
|
def _load_state(self) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
data = json.loads(self.state_path.read_text(encoding="utf-8"))
|
data = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||||
@@ -193,10 +300,11 @@ class TrainingCoordinator:
|
|||||||
os.replace(tmp, self.state_path)
|
os.replace(tmp, self.state_path)
|
||||||
|
|
||||||
def _worker_from_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _worker_from_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
worker_id = str(payload.get("worker_id") or payload.get("id") or "windows-training-host").strip()
|
||||||
return {
|
return {
|
||||||
"id": str(payload.get("worker_id") or payload.get("id") or "windows-training-host"),
|
"id": worker_id,
|
||||||
"name": str(payload.get("name") or "DESKTOP-TMFDL0H"),
|
"name": str(payload.get("name") or worker_id).strip(),
|
||||||
"path": str(payload.get("path") or "C:\\Repos\\TradeBot"),
|
"path": str(payload.get("path") or "").strip(),
|
||||||
"version": str(payload.get("version") or "1"),
|
"version": str(payload.get("version") or "1"),
|
||||||
"last_seen_at": _now(),
|
"last_seen_at": _now(),
|
||||||
}
|
}
|
||||||
@@ -262,8 +370,154 @@ class TrainingCoordinator:
|
|||||||
def _safe_parameters(value: Any) -> dict[str, Any]:
|
def _safe_parameters(value: Any) -> dict[str, Any]:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return {}
|
return {}
|
||||||
allowed = {"symbols", "limit", "lookbacks", "architectures", "hidden_sizes", "layers", "dropouts", "epochs"}
|
allowed = {
|
||||||
return {key: value[key] for key in allowed if key in value}
|
"symbols",
|
||||||
|
"limit",
|
||||||
|
"lookbacks",
|
||||||
|
"architectures",
|
||||||
|
"hidden_sizes",
|
||||||
|
"layers",
|
||||||
|
"dropouts",
|
||||||
|
"epochs",
|
||||||
|
"validation_window",
|
||||||
|
"holdout_window",
|
||||||
|
"ensemble_seeds",
|
||||||
|
"selection_folds",
|
||||||
|
"learning_rate",
|
||||||
|
"weight_decay",
|
||||||
|
"horizon",
|
||||||
|
"horizons",
|
||||||
|
"patience",
|
||||||
|
"context_symbols",
|
||||||
|
"features",
|
||||||
|
"seed",
|
||||||
|
"interval",
|
||||||
|
"pooled",
|
||||||
|
"resume_candidate",
|
||||||
|
}
|
||||||
|
result = {key: value[key] for key in allowed if key in value}
|
||||||
|
for key, low, high in (
|
||||||
|
("limit", 500, 20000),
|
||||||
|
("epochs", 1, 200),
|
||||||
|
("validation_window", 64, 2000),
|
||||||
|
("holdout_window", 64, 1000),
|
||||||
|
("selection_folds", 1, 12),
|
||||||
|
("horizon", 1, 96),
|
||||||
|
("patience", 1, 50),
|
||||||
|
("seed", 1, 2_147_483_647),
|
||||||
|
):
|
||||||
|
if key not in result:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result[key] = max(low, min(high, int(result[key])))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
result.pop(key, None)
|
||||||
|
if "symbols" in result:
|
||||||
|
symbols = [
|
||||||
|
item.strip().upper()
|
||||||
|
for item in str(result["symbols"]).split(",")
|
||||||
|
if re.fullmatch(r"[A-Z0-9]{3,20}", item.strip().upper())
|
||||||
|
]
|
||||||
|
result["symbols"] = ",".join(symbols[:30])
|
||||||
|
if "architectures" in result:
|
||||||
|
architectures = [
|
||||||
|
item.strip().lower()
|
||||||
|
for item in str(result["architectures"]).split(",")
|
||||||
|
if item.strip().lower() in {"lstm", "gru"}
|
||||||
|
]
|
||||||
|
result["architectures"] = ",".join(architectures) or "lstm,gru"
|
||||||
|
for key in (
|
||||||
|
"lookbacks",
|
||||||
|
"hidden_sizes",
|
||||||
|
"layers",
|
||||||
|
"dropouts",
|
||||||
|
"ensemble_seeds",
|
||||||
|
"horizons",
|
||||||
|
"context_symbols",
|
||||||
|
"features",
|
||||||
|
"interval",
|
||||||
|
):
|
||||||
|
if key in result:
|
||||||
|
result[key] = str(result[key])[: 4000 if key == "features" else 500]
|
||||||
|
for key, low, high in (
|
||||||
|
("learning_rate", 0.00001, 0.1),
|
||||||
|
("weight_decay", 0.0, 0.1),
|
||||||
|
):
|
||||||
|
if key not in result:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result[key] = max(low, min(high, float(result[key])))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
result.pop(key, None)
|
||||||
|
if "pooled" in result:
|
||||||
|
result["pooled"] = result["pooled"] is True
|
||||||
|
if "resume_candidate" in result:
|
||||||
|
result["resume_candidate"] = result["resume_candidate"] is True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_job_id(value: str) -> str:
|
||||||
|
try:
|
||||||
|
return str(uuid.UUID(str(value)))
|
||||||
|
except (ValueError, AttributeError, TypeError) as exc:
|
||||||
|
raise ValueError("invalid training job id") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise ValueError(f"invalid training artifact: {path.name}") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f"invalid training artifact: {path.name}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _validation_passed(validation: dict[str, Any]) -> bool:
|
||||||
|
if "passed" in validation:
|
||||||
|
return bool(validation.get("passed"))
|
||||||
|
return str(validation.get("status", "")).strip().lower() in {"pass", "passed", "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_symbol_models(symbols: dict[str, Any]) -> None:
|
||||||
|
for symbol, entry in symbols.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ValueError(f"candidate model entry is invalid: {symbol}")
|
||||||
|
if entry.get("model") not in {"torch_lstm", "torch_gru"}:
|
||||||
|
raise ValueError(f"candidate model architecture is invalid: {symbol}")
|
||||||
|
try:
|
||||||
|
lookback = int(entry.get("lookback", 0))
|
||||||
|
input_size = int(entry.get("input_size", 0))
|
||||||
|
hidden_size = int(entry.get("hidden_size", 0))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"candidate model dimensions are invalid: {symbol}") from exc
|
||||||
|
if not 4 <= lookback <= 512 or not 1 <= input_size <= 256 or not 1 <= hidden_size <= 1024:
|
||||||
|
raise ValueError(f"candidate model dimensions are out of range: {symbol}")
|
||||||
|
members = entry.get("ensemble_members")
|
||||||
|
payloads = members if isinstance(members, list) and members else [entry]
|
||||||
|
for payload in payloads:
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("state_dict"), dict):
|
||||||
|
raise ValueError(f"candidate recurrent state is missing: {symbol}")
|
||||||
|
merged = {**entry, **payload}
|
||||||
|
if merged.get("multitask_head") is True:
|
||||||
|
required = (
|
||||||
|
"head_hidden_weight",
|
||||||
|
"head_hidden_bias",
|
||||||
|
"return_head_weight",
|
||||||
|
"return_head_bias",
|
||||||
|
"event_head_weight",
|
||||||
|
"event_head_bias",
|
||||||
|
)
|
||||||
|
if any(not isinstance(merged.get(name), list) for name in required):
|
||||||
|
raise ValueError(f"candidate multitask forecast head is missing: {symbol}")
|
||||||
|
elif not isinstance(merged.get("head_weight"), list) or not isinstance(
|
||||||
|
merged.get("head_bias"), list
|
||||||
|
):
|
||||||
|
raise ValueError(f"candidate forecast head is missing: {symbol}")
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_now() -> str:
|
||||||
|
return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
def _latest_job(state: dict[str, Any]) -> dict[str, Any] | None:
|
def _latest_job(state: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
|||||||
+10
-1
@@ -6,12 +6,21 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
|
PYTHONDONTWRITEBYTECODE: "1"
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8787:8787"
|
- "127.0.0.1:8787:8787"
|
||||||
volumes:
|
volumes:
|
||||||
- ./.env:/app/.env
|
- ./.env:/app/.env:ro
|
||||||
- ./runtime:/app/runtime
|
- ./runtime:/app/runtime
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=64m,mode=1777
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=5).read()"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=5).read()"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.4.2
|
||||||
@@ -2,4 +2,3 @@ fastapi==0.115.6
|
|||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
websockets==14.1
|
websockets==14.1
|
||||||
pytest==8.4.2
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
-1162397
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
|||||||
BTCUSDT: loaded 2000 60 candles
|
|
||||||
ETHUSDT: loaded 2000 60 candles
|
|
||||||
LTCUSDT: loaded 2000 60 candles
|
|
||||||
SOLUSDT: loaded 2000 60 candles
|
|
||||||
BTCUSDT: replay records 720
|
|
||||||
ETHUSDT: replay records 720
|
|
||||||
SOLUSDT: replay records 720
|
|
||||||
LTCUSDT: replay records 720
|
|
||||||
|
|
||||||
records_by_symbol {"BTCUSDT": 720, "ETHUSDT": 720, "LTCUSDT": 720, "SOLUSDT": 720}
|
|
||||||
artifact {"created_at": "2026-06-23T19:07:54.434411+00:00", "feature_count": 55, "symbols": {"BTCUSDT": {"directional_accuracy": 0.725, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.15903346077183758}, "ETHUSDT": {"directional_accuracy": 0.6916666666666667, "hidden_size": 64, "lookback": 64, "model": "torch_gru", "skill": 0.09273757527902074}, "LTCUSDT": {"directional_accuracy": 0.6583333333333333, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.11954702418314447}, "SOLUSDT": {"directional_accuracy": 0.6416666666666667, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.03400498728351002}}, "target_horizon": 3, "target_horizons": [1, 3, 6, 12], "target_transform": "net_return_over_volatility", "version": 4}
|
|
||||||
|
|
||||||
TOP_RESULTS
|
|
||||||
edge=0.1000 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.1000 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.1000 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.1000 prob=0.6200 conf=0.6000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.1000 prob=0.6200 conf=0.5600 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.1000 prob=0.6200 conf=0.5000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.0800 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.0800 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.0800 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.0800 prob=0.6200 conf=0.6000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.0800 prob=0.6200 conf=0.5600 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.0800 prob=0.6200 conf=0.5000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.0600 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.0600 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
edge=0.0600 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
|
||||||
|
|
||||||
RECOMMENDED
|
|
||||||
edge=0.1000 prob=0.6000 conf=0.6800 trades=16 win=0.562 avg=0.4948% total=7.9171% dd=1.8130% pf=3.629 score=0.5817
|
|
||||||
|
|
||||||
FULL_REPLAY
|
|
||||||
trades=5 win=1.000 avg=1.9149% total=9.5746% dd=0.0000% pf=999.000
|
|
||||||
|
|
||||||
WALK_FORWARD
|
|
||||||
{"avg_net_percent": 0.4783, "max_drawdown_percent": 1.3024, "profit_factor": 3.471, "status": "ok", "total_net_percent": 7.1747, "trades": 15, "win_rate": 0.5333, "wins": 8}
|
|
||||||
env TIME_SERIES_MIN_EDGE_PERCENT=0.1000 TIME_SERIES_MIN_PROBABILITY_UP=0.6000 TIME_SERIES_MIN_CONFIDENCE=0.6800
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,515 +0,0 @@
|
|||||||
{
|
|
||||||
"artifact": {
|
|
||||||
"version": 4,
|
|
||||||
"created_at": "2026-06-23T19:07:54.434411+00:00",
|
|
||||||
"feature_count": 55,
|
|
||||||
"target_horizon": 3,
|
|
||||||
"target_horizons": [
|
|
||||||
1,
|
|
||||||
3,
|
|
||||||
6,
|
|
||||||
12
|
|
||||||
],
|
|
||||||
"target_transform": "net_return_over_volatility",
|
|
||||||
"symbols": {
|
|
||||||
"BTCUSDT": {
|
|
||||||
"model": "torch_gru",
|
|
||||||
"lookback": 64,
|
|
||||||
"hidden_size": 96,
|
|
||||||
"skill": 0.15903346077183758,
|
|
||||||
"directional_accuracy": 0.725
|
|
||||||
},
|
|
||||||
"ETHUSDT": {
|
|
||||||
"model": "torch_gru",
|
|
||||||
"lookback": 64,
|
|
||||||
"hidden_size": 64,
|
|
||||||
"skill": 0.09273757527902074,
|
|
||||||
"directional_accuracy": 0.6916666666666667
|
|
||||||
},
|
|
||||||
"SOLUSDT": {
|
|
||||||
"model": "torch_gru",
|
|
||||||
"lookback": 64,
|
|
||||||
"hidden_size": 96,
|
|
||||||
"skill": 0.03400498728351002,
|
|
||||||
"directional_accuracy": 0.6416666666666667
|
|
||||||
},
|
|
||||||
"LTCUSDT": {
|
|
||||||
"model": "torch_gru",
|
|
||||||
"lookback": 64,
|
|
||||||
"hidden_size": 96,
|
|
||||||
"skill": 0.11954702418314447,
|
|
||||||
"directional_accuracy": 0.6583333333333333
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"records_by_symbol": {
|
|
||||||
"BTCUSDT": 720,
|
|
||||||
"ETHUSDT": 720,
|
|
||||||
"SOLUSDT": 720,
|
|
||||||
"LTCUSDT": 720
|
|
||||||
},
|
|
||||||
"recommended": {
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 30,
|
|
||||||
"wins": 17,
|
|
||||||
"win_rate": 0.5666666666666667,
|
|
||||||
"total_net_percent": 12.82679871911413,
|
|
||||||
"average_net_percent": 0.42755995730380436,
|
|
||||||
"max_drawdown_percent": 1.812991648733242,
|
|
||||||
"profit_factor": 3.2963631842987433,
|
|
||||||
"score": 0.5882388552951857
|
|
||||||
},
|
|
||||||
"full_replay": {
|
|
||||||
"trades": 8,
|
|
||||||
"wins": 8,
|
|
||||||
"win_rate": 1.0,
|
|
||||||
"total_net_percent": 21.0484,
|
|
||||||
"avg_net_percent": 2.631,
|
|
||||||
"max_drawdown_percent": 0.0,
|
|
||||||
"profit_factor": 999.0,
|
|
||||||
"trades_detail": [
|
|
||||||
{
|
|
||||||
"symbol": "ETHUSDT",
|
|
||||||
"entry_timestamp": 1779832800000,
|
|
||||||
"exit_timestamp": 1779868800000,
|
|
||||||
"net_percent": 0.5281,
|
|
||||||
"reason": "forecast_weak_profit_lock",
|
|
||||||
"held_bars": 10,
|
|
||||||
"entry_probability": 0.5747,
|
|
||||||
"entry_expected_percent": 0.3453
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"symbol": "ETHUSDT",
|
|
||||||
"entry_timestamp": 1779940800000,
|
|
||||||
"exit_timestamp": 1779984000000,
|
|
||||||
"net_percent": 1.3185,
|
|
||||||
"reason": "forecast_weak_profit_lock",
|
|
||||||
"held_bars": 12,
|
|
||||||
"entry_probability": 0.6018,
|
|
||||||
"entry_expected_percent": 0.3155
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"symbol": "ETHUSDT",
|
|
||||||
"entry_timestamp": 1780300800000,
|
|
||||||
"exit_timestamp": 1780347600000,
|
|
||||||
"net_percent": 0.2591,
|
|
||||||
"reason": "forecast_weak_profit_lock",
|
|
||||||
"held_bars": 13,
|
|
||||||
"entry_probability": 0.6164,
|
|
||||||
"entry_expected_percent": 0.3215
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"symbol": "ETHUSDT",
|
|
||||||
"entry_timestamp": 1780768800000,
|
|
||||||
"exit_timestamp": 1780855200000,
|
|
||||||
"net_percent": 4.4581,
|
|
||||||
"reason": "max_hold",
|
|
||||||
"held_bars": 24,
|
|
||||||
"entry_probability": 0.5632,
|
|
||||||
"entry_expected_percent": 0.5685
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"symbol": "ETHUSDT",
|
|
||||||
"entry_timestamp": 1780862400000,
|
|
||||||
"exit_timestamp": 1780869600000,
|
|
||||||
"net_percent": 2.4609,
|
|
||||||
"reason": "forecast_weak_profit_lock",
|
|
||||||
"held_bars": 2,
|
|
||||||
"entry_probability": 0.5368,
|
|
||||||
"entry_expected_percent": 0.4055
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"symbol": "ETHUSDT",
|
|
||||||
"entry_timestamp": 1781139600000,
|
|
||||||
"exit_timestamp": 1781204400000,
|
|
||||||
"net_percent": 2.0707,
|
|
||||||
"reason": "forecast_weak_profit_lock",
|
|
||||||
"held_bars": 18,
|
|
||||||
"entry_probability": 0.5775,
|
|
||||||
"entry_expected_percent": 0.3013
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"symbol": "ETHUSDT",
|
|
||||||
"entry_timestamp": 1781445600000,
|
|
||||||
"exit_timestamp": 1781532000000,
|
|
||||||
"net_percent": 9.0305,
|
|
||||||
"reason": "max_hold",
|
|
||||||
"held_bars": 24,
|
|
||||||
"entry_probability": 0.6014,
|
|
||||||
"entry_expected_percent": 0.2946
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"symbol": "ETHUSDT",
|
|
||||||
"entry_timestamp": 1781892000000,
|
|
||||||
"exit_timestamp": 1781942400000,
|
|
||||||
"net_percent": 0.9224,
|
|
||||||
"reason": "forecast_weak_profit_lock",
|
|
||||||
"held_bars": 14,
|
|
||||||
"entry_probability": 0.5966,
|
|
||||||
"entry_expected_percent": 0.2647
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"walk_forward": {
|
|
||||||
"summary": {
|
|
||||||
"trades": 16,
|
|
||||||
"wins": 8,
|
|
||||||
"win_rate": 0.5,
|
|
||||||
"total_net_percent": 6.8682,
|
|
||||||
"avg_net_percent": 0.4293,
|
|
||||||
"max_drawdown_percent": 1.3024,
|
|
||||||
"profit_factor": 3.1396,
|
|
||||||
"status": "warn"
|
|
||||||
},
|
|
||||||
"folds": [
|
|
||||||
{
|
|
||||||
"fold": 1,
|
|
||||||
"train_records": 720,
|
|
||||||
"test_records": 720,
|
|
||||||
"thresholds": {
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.6,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 2,
|
|
||||||
"wins": 1,
|
|
||||||
"win_rate": 0.5,
|
|
||||||
"total_net_percent": -0.10348384852443271,
|
|
||||||
"average_net_percent": -0.051741924262216354,
|
|
||||||
"max_drawdown_percent": 0.5106090484004788,
|
|
||||||
"profit_factor": 0.7973325211360753,
|
|
||||||
"score": -0.0037694524148430344
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"trades": 4,
|
|
||||||
"wins": 2,
|
|
||||||
"win_rate": 0.5,
|
|
||||||
"total_net_percent": 0.0557,
|
|
||||||
"avg_net_percent": 0.0139,
|
|
||||||
"max_drawdown_percent": 1.3024,
|
|
||||||
"profit_factor": 1.0428
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fold": 2,
|
|
||||||
"train_records": 1440,
|
|
||||||
"test_records": 720,
|
|
||||||
"thresholds": {
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 18,
|
|
||||||
"wins": 11,
|
|
||||||
"win_rate": 0.6111111111111112,
|
|
||||||
"total_net_percent": 6.01434645293809,
|
|
||||||
"average_net_percent": 0.3341303584965606,
|
|
||||||
"max_drawdown_percent": 1.812991648733242,
|
|
||||||
"profit_factor": 2.6352078385564366,
|
|
||||||
"score": 0.3944002502730791
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"trades": 11,
|
|
||||||
"wins": 6,
|
|
||||||
"win_rate": 0.5455,
|
|
||||||
"total_net_percent": 7.119,
|
|
||||||
"avg_net_percent": 0.6472,
|
|
||||||
"max_drawdown_percent": 1.0894,
|
|
||||||
"profit_factor": 5.4462
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fold": 3,
|
|
||||||
"train_records": 2160,
|
|
||||||
"test_records": 720,
|
|
||||||
"thresholds": {
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 29,
|
|
||||||
"wins": 17,
|
|
||||||
"win_rate": 0.5862068965517241,
|
|
||||||
"total_net_percent": 13.13332018590817,
|
|
||||||
"average_net_percent": 0.4528731098589024,
|
|
||||||
"max_drawdown_percent": 1.812991648733242,
|
|
||||||
"profit_factor": 3.48775770371021,
|
|
||||||
"score": 0.6189314390475966
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"trades": 1,
|
|
||||||
"wins": 0,
|
|
||||||
"win_rate": 0.0,
|
|
||||||
"total_net_percent": -0.3065,
|
|
||||||
"avg_net_percent": -0.3065,
|
|
||||||
"max_drawdown_percent": 0.3065,
|
|
||||||
"profit_factor": 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"probability_calibration": {
|
|
||||||
"samples": 2880,
|
|
||||||
"buckets": [
|
|
||||||
{
|
|
||||||
"bucket": "0.30-0.35",
|
|
||||||
"samples": 38,
|
|
||||||
"avg_probability": 0.336,
|
|
||||||
"actual_win_rate": 0.1053,
|
|
||||||
"avg_future_net_percent": -0.6575
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bucket": "0.35-0.40",
|
|
||||||
"samples": 418,
|
|
||||||
"avg_probability": 0.3839,
|
|
||||||
"actual_win_rate": 0.2225,
|
|
||||||
"avg_future_net_percent": -0.6359
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bucket": "0.40-0.45",
|
|
||||||
"samples": 1065,
|
|
||||||
"avg_probability": 0.427,
|
|
||||||
"actual_win_rate": 0.2873,
|
|
||||||
"avg_future_net_percent": -0.4036
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bucket": "0.45-0.50",
|
|
||||||
"samples": 911,
|
|
||||||
"avg_probability": 0.4746,
|
|
||||||
"actual_win_rate": 0.3271,
|
|
||||||
"avg_future_net_percent": -0.3061
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bucket": "0.50-0.55",
|
|
||||||
"samples": 290,
|
|
||||||
"avg_probability": 0.5188,
|
|
||||||
"actual_win_rate": 0.3966,
|
|
||||||
"avg_future_net_percent": -0.0583
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bucket": "0.55-0.60",
|
|
||||||
"samples": 104,
|
|
||||||
"avg_probability": 0.5758,
|
|
||||||
"actual_win_rate": 0.4327,
|
|
||||||
"avg_future_net_percent": 0.0173
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bucket": "0.60-0.65",
|
|
||||||
"samples": 42,
|
|
||||||
"avg_probability": 0.6138,
|
|
||||||
"actual_win_rate": 0.4762,
|
|
||||||
"avg_future_net_percent": -0.0041
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bucket": "0.65-0.70",
|
|
||||||
"samples": 6,
|
|
||||||
"avg_probability": 0.6679,
|
|
||||||
"actual_win_rate": 0.3333,
|
|
||||||
"avg_future_net_percent": 0.6587
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bucket": "0.70-0.75",
|
|
||||||
"samples": 6,
|
|
||||||
"avg_probability": 0.7103,
|
|
||||||
"actual_win_rate": 0.8333,
|
|
||||||
"avg_future_net_percent": 2.1268
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"top_results": [
|
|
||||||
{
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 30,
|
|
||||||
"wins": 17,
|
|
||||||
"win_rate": 0.5666666666666667,
|
|
||||||
"total_net_percent": 12.82679871911413,
|
|
||||||
"average_net_percent": 0.42755995730380436,
|
|
||||||
"max_drawdown_percent": 1.812991648733242,
|
|
||||||
"profit_factor": 3.2963631842987433,
|
|
||||||
"score": 0.5882388552951857
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.5,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 30,
|
|
||||||
"wins": 17,
|
|
||||||
"win_rate": 0.5666666666666667,
|
|
||||||
"total_net_percent": 12.82679871911413,
|
|
||||||
"average_net_percent": 0.42755995730380436,
|
|
||||||
"max_drawdown_percent": 1.812991648733242,
|
|
||||||
"profit_factor": 3.2963631842987433,
|
|
||||||
"score": 0.5882388552951857
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.68,
|
|
||||||
"trades": 38,
|
|
||||||
"wins": 19,
|
|
||||||
"win_rate": 0.5,
|
|
||||||
"total_net_percent": 13.314209250896504,
|
|
||||||
"average_net_percent": 0.35037392765517117,
|
|
||||||
"max_drawdown_percent": 2.2311766078638495,
|
|
||||||
"profit_factor": 2.618335500149353,
|
|
||||||
"score": 0.5031517681827032
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.5,
|
|
||||||
"confidence": 0.68,
|
|
||||||
"trades": 38,
|
|
||||||
"wins": 19,
|
|
||||||
"win_rate": 0.5,
|
|
||||||
"total_net_percent": 13.314209250896504,
|
|
||||||
"average_net_percent": 0.35037392765517117,
|
|
||||||
"max_drawdown_percent": 2.2311766078638495,
|
|
||||||
"profit_factor": 2.618335500149353,
|
|
||||||
"score": 0.5031517681827032
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.08,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.64,
|
|
||||||
"trades": 56,
|
|
||||||
"wins": 28,
|
|
||||||
"win_rate": 0.5,
|
|
||||||
"total_net_percent": 17.433321755380405,
|
|
||||||
"average_net_percent": 0.31130931706036435,
|
|
||||||
"max_drawdown_percent": 3.6509791332801522,
|
|
||||||
"profit_factor": 2.1428339375966368,
|
|
||||||
"score": 0.4832797693926659
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.06,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.68,
|
|
||||||
"trades": 56,
|
|
||||||
"wins": 28,
|
|
||||||
"win_rate": 0.5,
|
|
||||||
"total_net_percent": 17.433321755380405,
|
|
||||||
"average_net_percent": 0.31130931706036435,
|
|
||||||
"max_drawdown_percent": 3.6509791332801522,
|
|
||||||
"profit_factor": 2.1428339375966368,
|
|
||||||
"score": 0.4832797693926659
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.05,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.68,
|
|
||||||
"trades": 60,
|
|
||||||
"wins": 29,
|
|
||||||
"win_rate": 0.48333333333333334,
|
|
||||||
"total_net_percent": 16.9130381260749,
|
|
||||||
"average_net_percent": 0.281883968767915,
|
|
||||||
"max_drawdown_percent": 3.7288680658046136,
|
|
||||||
"profit_factor": 1.9655257883521706,
|
|
||||||
"score": 0.44304683201823336
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.08,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 38,
|
|
||||||
"wins": 16,
|
|
||||||
"win_rate": 0.42105263157894735,
|
|
||||||
"total_net_percent": 12.356704347142244,
|
|
||||||
"average_net_percent": 0.3251764301879538,
|
|
||||||
"max_drawdown_percent": 2.826650409116027,
|
|
||||||
"profit_factor": 2.4329654894966497,
|
|
||||||
"score": 0.44256958838476457
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.08,
|
|
||||||
"probability": 0.5,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 38,
|
|
||||||
"wins": 16,
|
|
||||||
"win_rate": 0.42105263157894735,
|
|
||||||
"total_net_percent": 12.356704347142244,
|
|
||||||
"average_net_percent": 0.3251764301879538,
|
|
||||||
"max_drawdown_percent": 2.826650409116027,
|
|
||||||
"profit_factor": 2.4329654894966497,
|
|
||||||
"score": 0.44256958838476457
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.52,
|
|
||||||
"confidence": 0.6,
|
|
||||||
"trades": 59,
|
|
||||||
"wins": 28,
|
|
||||||
"win_rate": 0.4745762711864407,
|
|
||||||
"total_net_percent": 16.704033568694644,
|
|
||||||
"average_net_percent": 0.2831192130287228,
|
|
||||||
"max_drawdown_percent": 3.9030543543860263,
|
|
||||||
"profit_factor": 2.053895519143829,
|
|
||||||
"score": 0.4355711367750193
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.54,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 29,
|
|
||||||
"wins": 16,
|
|
||||||
"win_rate": 0.5517241379310345,
|
|
||||||
"total_net_percent": 9.266858120167019,
|
|
||||||
"average_net_percent": 0.3195468317298972,
|
|
||||||
"max_drawdown_percent": 1.812991648733242,
|
|
||||||
"profit_factor": 2.659032178431275,
|
|
||||||
"score": 0.41557735852998334
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.55,
|
|
||||||
"confidence": 0.72,
|
|
||||||
"trades": 25,
|
|
||||||
"wins": 14,
|
|
||||||
"win_rate": 0.56,
|
|
||||||
"total_net_percent": 9.16979950649479,
|
|
||||||
"average_net_percent": 0.3667919802597916,
|
|
||||||
"max_drawdown_percent": 1.812991648733242,
|
|
||||||
"profit_factor": 3.155095090386423,
|
|
||||||
"score": 0.41121722668525085
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.08,
|
|
||||||
"probability": 0.5,
|
|
||||||
"confidence": 0.64,
|
|
||||||
"trades": 57,
|
|
||||||
"wins": 28,
|
|
||||||
"win_rate": 0.49122807017543857,
|
|
||||||
"total_net_percent": 15.383105036720245,
|
|
||||||
"average_net_percent": 0.2698790357319341,
|
|
||||||
"max_drawdown_percent": 3.6509791332801522,
|
|
||||||
"profit_factor": 1.8889561886320847,
|
|
||||||
"score": 0.4107453600913507
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.06,
|
|
||||||
"probability": 0.5,
|
|
||||||
"confidence": 0.68,
|
|
||||||
"trades": 57,
|
|
||||||
"wins": 28,
|
|
||||||
"win_rate": 0.49122807017543857,
|
|
||||||
"total_net_percent": 15.383105036720245,
|
|
||||||
"average_net_percent": 0.2698790357319341,
|
|
||||||
"max_drawdown_percent": 3.6509791332801522,
|
|
||||||
"profit_factor": 1.8889561886320847,
|
|
||||||
"score": 0.4107453600913507
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"edge": 0.1,
|
|
||||||
"probability": 0.55,
|
|
||||||
"confidence": 0.68,
|
|
||||||
"trades": 32,
|
|
||||||
"wins": 16,
|
|
||||||
"win_rate": 0.5,
|
|
||||||
"total_net_percent": 9.83610967545454,
|
|
||||||
"average_net_percent": 0.3073784273579544,
|
|
||||||
"max_drawdown_percent": 2.2311766078638495,
|
|
||||||
"profit_factor": 2.5019571623752666,
|
|
||||||
"score": 0.40798477425385704
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
BTCUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.47001% baseline=0.55889% skill=0.1590 dir=0.725 p_brier=0.2443
|
|
||||||
ETHUSDT: model=torch_gru lookback=64 features=55 hidden=64 layers=2 horizons=1,3,6,12 mae=0.63328% baseline=0.69801% skill=0.0927 dir=0.692 p_brier=0.2239
|
|
||||||
SOLUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.85491% baseline=0.88500% skill=0.0340 dir=0.642 p_brier=0.2308
|
|
||||||
LTCUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.57185% baseline=0.64949% skill=0.1195 dir=0.658 p_brier=0.2369
|
|
||||||
saved G:\Repos\TradeBot\runtime\lstm_forecaster.candidate.json
|
|
||||||
Binary file not shown.
@@ -89,11 +89,13 @@ def make_settings():
|
|||||||
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,
|
time_series_rebound_fallback_enabled=True,
|
||||||
|
time_series_trend_fallback_enabled=False,
|
||||||
stop_loss_percent=0.02,
|
stop_loss_percent=0.02,
|
||||||
stop_loss_exit_enabled=True,
|
stop_loss_exit_enabled=True,
|
||||||
take_profit_percent=0.035,
|
take_profit_percent=0.035,
|
||||||
trailing_stop_percent=0.015,
|
trailing_stop_percent=0.015,
|
||||||
min_hold_seconds=180,
|
min_hold_seconds=180,
|
||||||
|
min_exit_net_percent=0.20,
|
||||||
entry_cooldown_seconds=180,
|
entry_cooldown_seconds=180,
|
||||||
max_daily_drawdown_usdt=6.0,
|
max_daily_drawdown_usdt=6.0,
|
||||||
min_cash_reserve_usdt=5.0,
|
min_cash_reserve_usdt=5.0,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from crypto_spot_bot.auth import ApiAuthorizer
|
||||||
|
|
||||||
|
|
||||||
|
def _request(**headers: str) -> Request:
|
||||||
|
encoded = [(key.lower().encode(), value.encode()) for key, value in headers.items()]
|
||||||
|
return Request({"type": "http", "method": "GET", "path": "/", "headers": encoded})
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_authorizer_accepts_direct_basic_token(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(tmp_path, api_auth_token="user:secret")
|
||||||
|
auth = ApiAuthorizer(settings)
|
||||||
|
basic = base64.b64encode(b"user:secret").decode("ascii")
|
||||||
|
|
||||||
|
asyncio.run(auth.require(_request(Authorization=f"Basic {basic}")))
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_authorizer_accepts_trusted_proxy_header(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(tmp_path, trusted_proxy_user_header="X-TradeBot-Proxy-User")
|
||||||
|
auth = ApiAuthorizer(settings)
|
||||||
|
|
||||||
|
asyncio.run(auth.require(_request(**{"X-TradeBot-Proxy-User": "sevenhill"})))
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_authorizer_rejects_missing_credentials(make_settings, tmp_path) -> None:
|
||||||
|
auth = ApiAuthorizer(make_settings(tmp_path))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as raised:
|
||||||
|
asyncio.run(auth.require(_request()))
|
||||||
|
|
||||||
|
assert raised.value.status_code == 401
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from crypto_spot_bot.bybit import BybitClient, websocket_subscribe_message, _looks_like_leveraged_token, _looks_like_stablecoin
|
from crypto_spot_bot.bybit import BybitClient, websocket_subscribe_message, _looks_like_leveraged_token, _looks_like_stablecoin
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +88,38 @@ def test_private_get_signs_the_same_query_it_sends(make_settings, tmp_path) -> N
|
|||||||
assert captured["headers"]["X-BAPI-SIGN"]
|
assert captured["headers"]["X-BAPI-SIGN"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_get_recreates_failed_tls_session_before_retry(make_settings, tmp_path, monkeypatch) -> None:
|
||||||
|
client = BybitClient(make_settings(tmp_path))
|
||||||
|
|
||||||
|
class FailedSession:
|
||||||
|
def get(self, *_args, **_kwargs):
|
||||||
|
raise requests.exceptions.SSLError("invalid session id")
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def raise_for_status(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"retCode": 0, "result": {"ok": True}}
|
||||||
|
|
||||||
|
class WorkingSession:
|
||||||
|
def get(self, *_args, **_kwargs):
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
resets = []
|
||||||
|
client.session = FailedSession()
|
||||||
|
|
||||||
|
def reset_session() -> None:
|
||||||
|
resets.append(True)
|
||||||
|
client.session = WorkingSession()
|
||||||
|
|
||||||
|
monkeypatch.setattr(client, "_reset_session", reset_session)
|
||||||
|
monkeypatch.setattr("crypto_spot_bot.bybit.time.sleep", lambda _seconds: None)
|
||||||
|
|
||||||
|
assert client.public_get("/v5/market/kline", {"symbol": "BTCUSDT"}) == {"ok": True}
|
||||||
|
assert resets == [True]
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_subscribe_uses_configured_kline_interval() -> None:
|
def test_websocket_subscribe_uses_configured_kline_interval() -> None:
|
||||||
payload = websocket_subscribe_message(["BTCUSDT"], interval="60")
|
payload = websocket_subscribe_message(["BTCUSDT"], interval="60")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from tools.calibrate_torch_thresholds import (
|
||||||
|
CalibrationResult,
|
||||||
|
ForecastRecord,
|
||||||
|
_average_selected_predictions,
|
||||||
|
_apply_platt_calibration,
|
||||||
|
_build_torch_model,
|
||||||
|
_choose_recommendation,
|
||||||
|
_full_backtest,
|
||||||
|
_fit_platt_calibration,
|
||||||
|
_record_event_target,
|
||||||
|
_entry_validation_skill,
|
||||||
|
)
|
||||||
|
from tools.train_torch_recurrent_forecaster import (
|
||||||
|
OUTPUT_LAYOUT,
|
||||||
|
RecurrentReturnModel,
|
||||||
|
_ensemble_candidate,
|
||||||
|
_export_head_state,
|
||||||
|
_export_recurrent_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(*, trades: int, average: float, total: float, profit_factor: float) -> CalibrationResult:
|
||||||
|
return CalibrationResult(
|
||||||
|
edge=0.05,
|
||||||
|
probability=0.52,
|
||||||
|
confidence=0.4,
|
||||||
|
trades=trades,
|
||||||
|
wins=max(0, trades // 2),
|
||||||
|
win_rate=0.5,
|
||||||
|
total_net_percent=total,
|
||||||
|
average_net_percent=average,
|
||||||
|
max_drawdown_percent=1.0,
|
||||||
|
profit_factor=profit_factor,
|
||||||
|
score=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(index: int, probability: float, future: float) -> ForecastRecord:
|
||||||
|
return ForecastRecord(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
index=index,
|
||||||
|
timestamp=index,
|
||||||
|
close=100.0,
|
||||||
|
high=101.0,
|
||||||
|
low=99.0,
|
||||||
|
next_open=100.0,
|
||||||
|
next_timestamp=index + 1,
|
||||||
|
atr=1.0,
|
||||||
|
expected_percent=0.1,
|
||||||
|
probability_up=probability,
|
||||||
|
confidence=0.5,
|
||||||
|
skill=0.1,
|
||||||
|
q50_percent=0.1,
|
||||||
|
block_entry=False,
|
||||||
|
future_net_percent=future,
|
||||||
|
benchmark_entry=False,
|
||||||
|
benchmark_exit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_does_not_fallback_to_too_few_trades() -> None:
|
||||||
|
selected = _choose_recommendation(
|
||||||
|
[_result(trades=1, average=2.0, total=2.0, profit_factor=999.0)],
|
||||||
|
min_trades=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert selected is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_selects_only_viable_result() -> None:
|
||||||
|
viable = _result(trades=30, average=0.2, total=6.0, profit_factor=1.4)
|
||||||
|
|
||||||
|
assert _choose_recommendation([viable], min_trades=30) is viable
|
||||||
|
|
||||||
|
|
||||||
|
def test_platt_calibration_learns_probability_direction_from_train_records() -> None:
|
||||||
|
records = [
|
||||||
|
_record(index, 0.8 if index % 2 else 0.2, -1.0 if index % 2 else 1.0)
|
||||||
|
for index in range(100)
|
||||||
|
]
|
||||||
|
|
||||||
|
calibration = _fit_platt_calibration(records)
|
||||||
|
calibrated = _apply_platt_calibration(
|
||||||
|
[_record(101, 0.8, -1.0), _record(102, 0.2, 1.0)],
|
||||||
|
calibration,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calibration["slope"] < 0
|
||||||
|
assert calibrated[0].probability_up < calibrated[1].probability_up
|
||||||
|
|
||||||
|
|
||||||
|
def test_barrier_event_target_takes_precedence_over_terminal_profit() -> None:
|
||||||
|
record = _record(1, 0.8, 3.0)
|
||||||
|
record.take_profit_first = False
|
||||||
|
|
||||||
|
assert _record_event_target(record) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_quality_never_falls_back_to_holdout_skill() -> None:
|
||||||
|
entry = {"validation_skill": 0.12, "skill": 0.99, "holdout_skill": 0.99}
|
||||||
|
|
||||||
|
assert _entry_validation_skill(entry) == 0.12
|
||||||
|
assert _entry_validation_skill({"skill": 0.99, "holdout_skill": 0.99}) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_batched_ensemble_averages_decoded_predictions() -> None:
|
||||||
|
averaged = _average_selected_predictions(
|
||||||
|
[
|
||||||
|
{"expected_return": 0.01, "q50": 0.02, "probability_up": 0.6},
|
||||||
|
{"expected_return": 0.03, "q50": 0.04, "probability_up": 0.8},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert averaged == {
|
||||||
|
"expected_return": 0.02,
|
||||||
|
"q50": 0.03,
|
||||||
|
"probability_up": 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrator_loads_multitask_head() -> None:
|
||||||
|
model = RecurrentReturnModel(
|
||||||
|
architecture="gru",
|
||||||
|
input_size=2,
|
||||||
|
hidden_size=4,
|
||||||
|
num_layers=1,
|
||||||
|
dropout=0.0,
|
||||||
|
output_size=len(OUTPUT_LAYOUT),
|
||||||
|
attention_pooling=False,
|
||||||
|
context_norm=False,
|
||||||
|
multitask_head=True,
|
||||||
|
head_hidden_size=6,
|
||||||
|
)
|
||||||
|
entry = {
|
||||||
|
"input_size": 2,
|
||||||
|
"hidden_size": 4,
|
||||||
|
"num_layers": 1,
|
||||||
|
"output_size": len(OUTPUT_LAYOUT),
|
||||||
|
"multitask_head": True,
|
||||||
|
"head_hidden_size": 6,
|
||||||
|
"state_dict": _export_recurrent_state(model),
|
||||||
|
**_export_head_state(model),
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded = _build_torch_model(entry, "torch_gru")
|
||||||
|
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.multitask_head is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_seed_export_does_not_duplicate_first_member_weights() -> None:
|
||||||
|
members = [
|
||||||
|
{
|
||||||
|
"validation_mae": 0.1,
|
||||||
|
"state_dict": {"weight": [seed]},
|
||||||
|
"head_weight": [[seed]],
|
||||||
|
"head_bias": [seed],
|
||||||
|
}
|
||||||
|
for seed in (7, 19)
|
||||||
|
]
|
||||||
|
|
||||||
|
exported = _ensemble_candidate(members, [7, 19])
|
||||||
|
|
||||||
|
assert exported["ensemble_size"] == 2
|
||||||
|
assert exported["ensemble_seeds"] == [7, 19]
|
||||||
|
assert len(exported["ensemble_members"]) == 2
|
||||||
|
assert "state_dict" not in exported
|
||||||
|
assert "head_weight" not in exported
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_seed_export_keeps_only_top_level_weights() -> None:
|
||||||
|
exported = _ensemble_candidate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"validation_mae": 0.1,
|
||||||
|
"state_dict": {"weight": [7]},
|
||||||
|
"head_weight": [[7]],
|
||||||
|
"head_bias": [7],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[7],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exported["ensemble_size"] == 1
|
||||||
|
assert exported["state_dict"] == {"weight": [7]}
|
||||||
|
assert "ensemble_members" not in exported
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_backtest_never_uses_global_threshold_for_ineligible_symbol() -> None:
|
||||||
|
btc = [_record(index, 0.8, 1.0) for index in range(3)]
|
||||||
|
eth = [_record(index, 0.8, 1.0) for index in range(3)]
|
||||||
|
for record in eth:
|
||||||
|
record.symbol = "ETHUSDT"
|
||||||
|
thresholds = _result(trades=3, average=1.0, total=3.0, profit_factor=999.0)
|
||||||
|
|
||||||
|
replay = _full_backtest(
|
||||||
|
btc + eth,
|
||||||
|
thresholds,
|
||||||
|
horizon=3,
|
||||||
|
round_trip_cost=0.0,
|
||||||
|
settings=SimpleNamespace(
|
||||||
|
stop_loss_percent=0.04,
|
||||||
|
take_profit_percent=0.035,
|
||||||
|
stop_loss_exit_enabled=True,
|
||||||
|
atr_trailing_multiplier=2.2,
|
||||||
|
),
|
||||||
|
symbol_thresholds={"BTCUSDT": thresholds},
|
||||||
|
require_symbol_thresholds=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {row["symbol"] for row in replay["symbol_breakdown"]} == {"BTCUSDT"}
|
||||||
@@ -154,3 +154,20 @@ def test_auto_select_uses_empty_symbol_list(tmp_path, monkeypatch) -> None:
|
|||||||
assert settings.auto_select_symbols is True
|
assert settings.auto_select_symbols is True
|
||||||
assert settings.top_symbols_count == 12
|
assert settings.top_symbols_count == 12
|
||||||
assert settings.symbols == ()
|
assert settings.symbols == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_settings_rejects_inconsistent_exposure_limits(tmp_path, monkeypatch) -> None:
|
||||||
|
for key in (
|
||||||
|
"MIN_POSITION_USDT",
|
||||||
|
"MAX_SYMBOL_EXPOSURE_USDT",
|
||||||
|
"MAX_TOTAL_EXPOSURE_USDT",
|
||||||
|
):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text(
|
||||||
|
"MIN_POSITION_USDT=10\nMAX_SYMBOL_EXPOSURE_USDT=5\nMAX_TOTAL_EXPOSURE_USDT=20\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="MAX_SYMBOL_EXPOSURE_USDT"):
|
||||||
|
load_settings(env_file)
|
||||||
|
|||||||
+106
-1
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from crypto_spot_bot.bybit import Instrument
|
from crypto_spot_bot.bybit import Instrument
|
||||||
from crypto_spot_bot.bot import CryptoSpotBot
|
from crypto_spot_bot.bot import CryptoSpotBot
|
||||||
from crypto_spot_bot.execution import PaperBroker
|
from crypto_spot_bot.execution import LiveBroker, PaperBroker
|
||||||
from crypto_spot_bot.models import Signal, Ticker
|
from crypto_spot_bot.models import Signal, Ticker
|
||||||
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
|
||||||
@@ -319,3 +319,108 @@ def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_sett
|
|||||||
assert trade["side"] == "SELL"
|
assert trade["side"] == "SELL"
|
||||||
assert trade["symbol"] == "HYPEUSDT"
|
assert trade["symbol"] == "HYPEUSDT"
|
||||||
assert "trend_macd" in trade["reason"]
|
assert "trend_macd" in trade["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_broker_records_exchange_fill_and_protective_stop(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
trading_mode="live",
|
||||||
|
enable_live_trading=True,
|
||||||
|
live_trading_confirm="I_ACCEPT_REAL_RISK",
|
||||||
|
bybit_api_key="key",
|
||||||
|
bybit_api_secret="secret",
|
||||||
|
live_protective_stop_enabled=True,
|
||||||
|
)
|
||||||
|
storage = Storage(settings.database_path)
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def place_spot_market_order(self, **kwargs):
|
||||||
|
return {"orderId": "buy-1"}
|
||||||
|
|
||||||
|
def wait_for_spot_order(self, **kwargs):
|
||||||
|
return {
|
||||||
|
"order": {"orderStatus": "Filled"},
|
||||||
|
"executions": [
|
||||||
|
{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"execQty": "0.001",
|
||||||
|
"execValue": "10",
|
||||||
|
"execPrice": "10000",
|
||||||
|
"execFee": "0.01",
|
||||||
|
"feeCurrency": "USDT",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def place_spot_protective_stop(self, **kwargs):
|
||||||
|
return {"orderId": "stop-1"}
|
||||||
|
|
||||||
|
broker = LiveBroker(settings, storage, Client())
|
||||||
|
broker.reconciliation_state = {"status": "ok", "blocking": False, "discrepancies": []}
|
||||||
|
ticker = Ticker("BTCUSDT", 10000, 9999, 10001, 10_000_000, 1000, 0)
|
||||||
|
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 5)
|
||||||
|
signal = Signal("BTCUSDT", "BUY", 0.8, "test", {"position_notional_usdt": 10})
|
||||||
|
|
||||||
|
position = broker.buy(signal, ticker, instrument, {"BTCUSDT": 10000})
|
||||||
|
|
||||||
|
assert position is not None
|
||||||
|
assert position.qty == 0.001
|
||||||
|
assert position.entry_price == 10000
|
||||||
|
assert position.protective_order_id == "stop-1"
|
||||||
|
assert storage.recent_orders()[0]["order_kind"] == "PROTECTIVE_STOP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_broker_sell_uses_confirmed_exchange_fill(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
trading_mode="live",
|
||||||
|
enable_live_trading=True,
|
||||||
|
live_trading_confirm="I_ACCEPT_REAL_RISK",
|
||||||
|
bybit_api_key="key",
|
||||||
|
bybit_api_secret="secret",
|
||||||
|
live_protective_stop_enabled=False,
|
||||||
|
)
|
||||||
|
storage = Storage(settings.database_path)
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
def place_spot_market_order(self, **kwargs):
|
||||||
|
return {"orderId": "buy-1" if kwargs["side"] == "Buy" else "sell-1"}
|
||||||
|
|
||||||
|
def wait_for_spot_order(self, **kwargs):
|
||||||
|
buying = kwargs["order_id"] == "buy-1"
|
||||||
|
return {
|
||||||
|
"order": {"orderStatus": "Filled"},
|
||||||
|
"executions": [
|
||||||
|
{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"execQty": "0.001",
|
||||||
|
"execValue": "10" if buying else "11",
|
||||||
|
"execPrice": "10000" if buying else "11000",
|
||||||
|
"execFee": "0.01",
|
||||||
|
"feeCurrency": "USDT",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
broker = LiveBroker(settings, storage, Client())
|
||||||
|
broker.reconciliation_state = {"status": "ok", "blocking": False, "discrepancies": []}
|
||||||
|
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 5)
|
||||||
|
entry_ticker = Ticker("BTCUSDT", 10000, 9999, 10001, 10_000_000, 1000, 0)
|
||||||
|
position = broker.buy(
|
||||||
|
Signal("BTCUSDT", "BUY", 0.8, "test", {"position_notional_usdt": 10}),
|
||||||
|
entry_ticker,
|
||||||
|
instrument,
|
||||||
|
{"BTCUSDT": 10000},
|
||||||
|
)
|
||||||
|
assert position is not None
|
||||||
|
|
||||||
|
trade = broker.sell(
|
||||||
|
position,
|
||||||
|
Ticker("BTCUSDT", 11000, 10999, 11001, 10_000_000, 1000, 0),
|
||||||
|
"test exit",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trade.exit_price == 11000
|
||||||
|
assert trade.qty == 0.001
|
||||||
|
assert broker.open_positions() == []
|
||||||
|
assert storage.recent_orders()[0]["status"] == "Filled"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from crypto_spot_bot.market_data import _closed_candles, _is_closed_kline_row
|
from crypto_spot_bot.market_data import _candles_due, _closed_candles, _is_closed_kline_row
|
||||||
from crypto_spot_bot.models import Candle
|
from crypto_spot_bot.models import Candle
|
||||||
|
|
||||||
|
|
||||||
@@ -19,3 +19,10 @@ def test_closed_candles_excludes_current_open_interval() -> None:
|
|||||||
def test_websocket_kline_requires_confirmed_candle() -> None:
|
def test_websocket_kline_requires_confirmed_candle() -> None:
|
||||||
assert _is_closed_kline_row({"start": 7_200_000, "confirm": False}, "60") is False
|
assert _is_closed_kline_row({"start": 7_200_000, "confirm": False}, "60") is False
|
||||||
assert _is_closed_kline_row({"start": 7_200_000, "confirm": True}, "60") is True
|
assert _is_closed_kline_row({"start": 7_200_000, "confirm": True}, "60") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_rest_candles_refresh_only_after_next_bar_closes() -> None:
|
||||||
|
candle = Candle(10 * 60_000, 1, 1, 1, 1, 1)
|
||||||
|
|
||||||
|
assert _candles_due([candle], "1", now_ms=11 * 60_000 + 30_000) is False
|
||||||
|
assert _candles_due([candle], "1", now_ms=12 * 60_000) is True
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrain_runner_passes_training_horizon_to_calibrator() -> None:
|
||||||
|
runner = (
|
||||||
|
Path(__file__).resolve().parents[1] / "tools" / "run_torch_retrain.ps1"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
calibration_start = runner.index("$calibrationBaseArgs = @(")
|
||||||
|
calibration_end = runner.index("\n )", calibration_start)
|
||||||
|
calibration_args = runner[calibration_start:calibration_end]
|
||||||
|
|
||||||
|
assert '"--horizon", $Horizon.ToString()' in calibration_args
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrain_runner_uses_a_regime_sized_validation_window() -> None:
|
||||||
|
runner = (
|
||||||
|
Path(__file__).resolve().parents[1] / "tools" / "run_torch_retrain.ps1"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "[int]$ValidationWindow = 0" in runner
|
||||||
|
assert "else { 720 }" in runner
|
||||||
|
assert '"--validation-window", $ValidationWindow.ToString()' in runner
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from crypto_spot_bot.models import Signal, utc_now
|
||||||
|
from crypto_spot_bot.storage import MAX_SIGNAL_DIAGNOSTICS_BYTES, PRUNE_BATCH_SIZE, Storage
|
||||||
|
from tools.compact_runtime_db import compact_database
|
||||||
|
|
||||||
|
|
||||||
|
def test_hold_sampling_is_independent_for_each_reason_and_diagnostics_are_bounded(tmp_path) -> None:
|
||||||
|
storage = Storage(tmp_path / "tradebot.sqlite3")
|
||||||
|
diagnostics = {
|
||||||
|
"strategy_mode": "torch_forecast",
|
||||||
|
"checks": {"model_fresh_ok": False},
|
||||||
|
"forecast": {
|
||||||
|
"model": "torch_lstm",
|
||||||
|
"expected_return_percent": 0.42,
|
||||||
|
"model_fresh": False,
|
||||||
|
"feature_snapshot": [
|
||||||
|
{"name": f"feature-{index}", "interpretation": "x" * 1000}
|
||||||
|
for index in range(100)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
first = Signal("BTCUSDT", "HOLD", 0.2, "entry blocked", diagnostics)
|
||||||
|
second = Signal("BTCUSDT", "HOLD", 0.2, "position held", diagnostics)
|
||||||
|
|
||||||
|
assert storage.insert_signal(first, hold_sample_seconds=60) is True
|
||||||
|
assert storage.insert_signal(second, hold_sample_seconds=60) is True
|
||||||
|
assert storage.insert_signal(first, hold_sample_seconds=60) is False
|
||||||
|
|
||||||
|
rows = storage.recent_signals(10)
|
||||||
|
assert len(rows) == 2
|
||||||
|
stored = json.loads(rows[0]["diagnostics_json"])
|
||||||
|
assert len(rows[0]["diagnostics_json"].encode("utf-8")) <= MAX_SIGNAL_DIAGNOSTICS_BYTES
|
||||||
|
assert stored["forecast"]["model"] == "torch_lstm"
|
||||||
|
assert "feature_snapshot" not in stored["forecast"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_deletes_only_one_bounded_batch_per_table(tmp_path) -> None:
|
||||||
|
storage = Storage(tmp_path / "tradebot.sqlite3")
|
||||||
|
old_timestamp = (utc_now() - timedelta(days=90)).isoformat()
|
||||||
|
rows = [
|
||||||
|
("BTCUSDT", "HOLD", 0.0, "old", "{}", old_timestamp)
|
||||||
|
for _ in range(PRUNE_BATCH_SIZE + 5)
|
||||||
|
]
|
||||||
|
with storage.connect() as conn:
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO signals (symbol, action, confidence, reason, diagnostics_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = storage.prune(30)
|
||||||
|
|
||||||
|
assert deleted["signals"] == PRUNE_BATCH_SIZE
|
||||||
|
assert len(storage.recent_signals(PRUNE_BATCH_SIZE + 10)) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_compaction_preserves_durable_state_and_bounds_telemetry(tmp_path) -> None:
|
||||||
|
database = tmp_path / "tradebot.sqlite3"
|
||||||
|
storage = Storage(database)
|
||||||
|
for index in range(10):
|
||||||
|
storage.insert_signal(
|
||||||
|
Signal("BTCUSDT", "BUY", 0.8, f"signal-{index}"),
|
||||||
|
hold_sample_seconds=0,
|
||||||
|
)
|
||||||
|
storage.set_runtime("active", {"value": 1})
|
||||||
|
|
||||||
|
result = compact_database(
|
||||||
|
database,
|
||||||
|
recent_rows={"signals": 3, "equity": 0, "events": 0, "llm_advice": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
compacted = Storage(database)
|
||||||
|
assert [row["reason"] for row in compacted.recent_signals(10)] == [
|
||||||
|
"signal-9",
|
||||||
|
"signal-8",
|
||||||
|
"signal-7",
|
||||||
|
]
|
||||||
|
assert compacted.get_runtime("active") == {"value": 1}
|
||||||
|
assert Path(result["backup"]).is_file()
|
||||||
|
assert result["rows"]["signals"] == 3
|
||||||
@@ -566,6 +566,108 @@ def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> N
|
|||||||
assert signal.diagnostics["checks"]["quality_gate_ok"] is False
|
assert signal.diagnostics["checks"]["quality_gate_ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_torch_forecast_uses_trend_fallback_when_model_is_not_ready(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
strategy_mode="torch_forecast",
|
||||||
|
time_series_trend_fallback_enabled=True,
|
||||||
|
time_series_require_quality_gate=True,
|
||||||
|
time_series_require_fresh_model=True,
|
||||||
|
max_position_usdt=50,
|
||||||
|
)
|
||||||
|
strategy = SpotStrategy(settings)
|
||||||
|
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
|
||||||
|
|
||||||
|
signal = strategy.entry_signal(
|
||||||
|
"BTCUSDT",
|
||||||
|
_trend_entry_candles(),
|
||||||
|
ticker,
|
||||||
|
open_positions_for_symbol=0,
|
||||||
|
forecast={"usable": False, "model": "none", "quality_gate_passed": False},
|
||||||
|
account={"equity": 100.0},
|
||||||
|
trend_candles=_daily_trend_candles(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert signal.action == "BUY"
|
||||||
|
assert signal.diagnostics["trade_mode"] == "TREND_MACD_FALLBACK"
|
||||||
|
assert signal.diagnostics["entry_path"] == "trend_macd_fallback"
|
||||||
|
assert signal.diagnostics["forecast_fallback_reasons"] == [
|
||||||
|
"torch_model_unavailable",
|
||||||
|
"quality_gate_not_passed",
|
||||||
|
"model_not_fresh",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_torch_forecast_uses_trend_exit_for_fallback_position(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
strategy_mode="torch_forecast",
|
||||||
|
time_series_trend_fallback_enabled=True,
|
||||||
|
)
|
||||||
|
strategy = SpotStrategy(settings)
|
||||||
|
candles = _trend_entry_candles()
|
||||||
|
candles[-2].macd = 0.2
|
||||||
|
candles[-2].macd_signal = 0.0
|
||||||
|
candles[-1].macd = -0.1
|
||||||
|
candles[-1].macd_signal = 0.0
|
||||||
|
position = Position(
|
||||||
|
1,
|
||||||
|
"BTCUSDT",
|
||||||
|
1,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
0.1,
|
||||||
|
96,
|
||||||
|
120,
|
||||||
|
100,
|
||||||
|
entry_diagnostics={"entry_path": "trend_macd_fallback"},
|
||||||
|
)
|
||||||
|
ticker = Ticker("BTCUSDT", 104, 103.99, 104.01, 1_000_000, 100, 0)
|
||||||
|
|
||||||
|
signal = strategy.exit_signal(position, candles, ticker, forecast={})
|
||||||
|
|
||||||
|
assert signal.action == "SELL"
|
||||||
|
assert signal.diagnostics["trade_mode"] == "TREND_MACD_FALLBACK"
|
||||||
|
assert "MACD" in signal.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_torch_forecast_allows_explicit_manual_quality_override(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
strategy_mode="torch_forecast",
|
||||||
|
time_series_require_quality_gate=True,
|
||||||
|
time_series_manual_quality_override=True,
|
||||||
|
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 == "BUY"
|
||||||
|
assert signal.diagnostics["checks"]["quality_gate_ok"] is True
|
||||||
|
assert signal.diagnostics["manual_quality_override"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_torch_forecast_probe_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None:
|
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,
|
||||||
@@ -954,6 +1056,81 @@ def test_torch_forecast_holds_atr_trailing_exit_that_does_not_cover_fees(make_se
|
|||||||
assert signal.diagnostics["atr_exit_blocked_by_cost"] is True
|
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:
|
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)
|
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
|
||||||
strategy = SpotStrategy(settings)
|
strategy = SpotStrategy(settings)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from crypto_spot_bot.models import Candle
|
from crypto_spot_bot.models import Candle
|
||||||
from crypto_spot_bot.time_series import TimeSeriesForecaster
|
from crypto_spot_bot.time_series import TimeSeriesForecaster
|
||||||
|
|
||||||
@@ -191,6 +193,77 @@ def _write_probabilistic_torch_gru_artifact(path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_barrier_multitask_gru_artifact(path) -> None:
|
||||||
|
hidden_size = 2
|
||||||
|
head_hidden_size = 2
|
||||||
|
input_size = 2
|
||||||
|
output_size = 5
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"version": 6,
|
||||||
|
"type": "pytorch_recurrent_forecaster",
|
||||||
|
"target_horizon": 3,
|
||||||
|
"target_horizons": [3],
|
||||||
|
"direct_horizon": True,
|
||||||
|
"target_transform": "barrier_net_return",
|
||||||
|
"event_target": "take_profit_before_stop_loss",
|
||||||
|
"round_trip_cost": 0.0026,
|
||||||
|
"output_layout": ["mean", "q10", "q50", "q90", "logit_tp_first"],
|
||||||
|
"feature_names": ["return_1", "range_percent"],
|
||||||
|
"symbols": {
|
||||||
|
"BTCUSDT": {
|
||||||
|
"model": "torch_gru",
|
||||||
|
"architecture": "gru",
|
||||||
|
"lookback": 8,
|
||||||
|
"target_horizon": 3,
|
||||||
|
"target_horizons": [3],
|
||||||
|
"direct_horizon": True,
|
||||||
|
"target_transform": "barrier_net_return",
|
||||||
|
"event_target": "take_profit_before_stop_loss",
|
||||||
|
"target_stop_loss_percent": 0.04,
|
||||||
|
"target_take_profit_percent": 0.035,
|
||||||
|
"round_trip_cost": 0.0026,
|
||||||
|
"output_layout": ["mean", "q10", "q50", "q90", "logit_tp_first"],
|
||||||
|
"input_size": input_size,
|
||||||
|
"output_size": output_size,
|
||||||
|
"feature_names": ["return_1", "range_percent"],
|
||||||
|
"feature_means": [0.0, 0.0],
|
||||||
|
"feature_scales": [0.001, 0.001],
|
||||||
|
"target_means": [0.0],
|
||||||
|
"target_scales": [1.0],
|
||||||
|
"target_mean": 0.0,
|
||||||
|
"target_scale": 1.0,
|
||||||
|
"hidden_size": hidden_size,
|
||||||
|
"num_layers": 1,
|
||||||
|
"clip": 8.0,
|
||||||
|
"validation_mae_by_horizon": {"3": 0.01},
|
||||||
|
"baseline_mae_by_horizon": {"3": 0.02},
|
||||||
|
"validation_mae_percent": 1.0,
|
||||||
|
"baseline_mae_percent": 2.0,
|
||||||
|
"skill": 0.2,
|
||||||
|
"multitask_head": True,
|
||||||
|
"head_hidden_size": head_hidden_size,
|
||||||
|
"state_dict": {
|
||||||
|
"weight_ih_l0": [[0.0, 0.0] for _ in range(3 * hidden_size)],
|
||||||
|
"weight_hh_l0": [[0.0, 0.0] for _ in range(3 * hidden_size)],
|
||||||
|
"bias_ih_l0": [0.0 for _ in range(3 * hidden_size)],
|
||||||
|
"bias_hh_l0": [0.0 for _ in range(3 * hidden_size)],
|
||||||
|
},
|
||||||
|
"head_hidden_weight": [[0.0, 0.0], [0.0, 0.0]],
|
||||||
|
"head_hidden_bias": [0.0, 0.0],
|
||||||
|
"return_head_weight": [[0.0, 0.0] for _ in range(4)],
|
||||||
|
"return_head_bias": [0.01, -0.01, 0.005, 0.02],
|
||||||
|
"event_head_weight": [[0.0, 0.0]],
|
||||||
|
"event_head_bias": [1.38629436112],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_time_series_forecaster_requires_torch_artifact(make_settings, tmp_path) -> None:
|
def test_time_series_forecaster_requires_torch_artifact(make_settings, tmp_path) -> None:
|
||||||
settings = make_settings(
|
settings = make_settings(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
@@ -304,6 +377,86 @@ def test_time_series_forecaster_attaches_quality_gate(make_settings, tmp_path) -
|
|||||||
assert forecast.quality_gate["status"] == "fail"
|
assert forecast.quality_gate["status"] == "fail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_series_forecaster_uses_symbol_calibration(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": "pass", "passed": True},
|
||||||
|
"recommended": {"edge": 0.08, "probability": 0.52, "confidence": 0.4},
|
||||||
|
"symbol_recommendations": {
|
||||||
|
"BTCUSDT": {"edge": 0.03, "probability": 0.55, "confidence": 0.45}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
time_series_lstm_model_path=artifact_path,
|
||||||
|
time_series_forecast_horizon=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
forecast = TimeSeriesForecaster(settings).forecast(
|
||||||
|
_candles_from_returns([0.0001] * 140), symbol="BTCUSDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert forecast.calibrated_min_edge_percent == 0.03
|
||||||
|
assert forecast.calibrated_min_probability_up == 0.55
|
||||||
|
assert forecast.calibrated_min_confidence == 0.45
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_series_forecaster_blocks_symbol_outside_train_only_allowlist(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": "pass", "passed": True},
|
||||||
|
"eligible_symbols": ["ETHUSDT"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
settings = make_settings(tmp_path, time_series_lstm_model_path=artifact_path)
|
||||||
|
|
||||||
|
forecast = TimeSeriesForecaster(settings).forecast(
|
||||||
|
_candles_from_returns([0.0001] * 140), symbol="BTCUSDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert forecast.usable is True
|
||||||
|
assert forecast.block_entry is True
|
||||||
|
assert forecast.reason == "symbol excluded by train-only calibration"
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_series_forecaster_averages_ensemble_members(make_settings, tmp_path) -> None:
|
||||||
|
artifact_path = tmp_path / "lstm_forecaster.json"
|
||||||
|
_write_torch_gru_artifact(artifact_path, head_bias=0.9)
|
||||||
|
artifact = json.loads(artifact_path.read_text(encoding="utf-8"))
|
||||||
|
entry = artifact["symbols"]["BTCUSDT"]
|
||||||
|
entry["ensemble_members"] = [
|
||||||
|
{"state_dict": entry["state_dict"], "head_weight": [0.0, 0.0], "head_bias": bias}
|
||||||
|
for bias in (0.1, 0.3)
|
||||||
|
]
|
||||||
|
entry.pop("state_dict")
|
||||||
|
entry.pop("head_weight")
|
||||||
|
entry.pop("head_bias")
|
||||||
|
artifact_path.write_text(json.dumps(artifact), encoding="utf-8")
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
time_series_lstm_model_path=artifact_path,
|
||||||
|
time_series_forecast_horizon=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
forecast = TimeSeriesForecaster(settings).forecast(
|
||||||
|
_candles_from_returns([0.0001] * 140), symbol="BTCUSDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert forecast.usable is True
|
||||||
|
assert 0.015 <= forecast.expected_return_percent <= 0.025
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
@@ -348,3 +501,24 @@ def test_time_series_forecaster_reads_probabilistic_multi_horizon_artifact(make_
|
|||||||
assert forecast.feature_snapshot[0]["label"] == "Доходность 1ч"
|
assert forecast.feature_snapshot[0]["label"] == "Доходность 1ч"
|
||||||
assert forecast.feature_snapshot[0]["raw_display"].endswith("%")
|
assert forecast.feature_snapshot[0]["raw_display"].endswith("%")
|
||||||
assert "диапазон" in forecast.feature_snapshot[0]["interpretation"]
|
assert "диапазон" in forecast.feature_snapshot[0]["interpretation"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_time_series_forecaster_reads_barrier_multitask_artifact(make_settings, tmp_path) -> None:
|
||||||
|
artifact_path = tmp_path / "lstm_forecaster.json"
|
||||||
|
_write_barrier_multitask_gru_artifact(artifact_path)
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
time_series_lstm_model_path=artifact_path,
|
||||||
|
time_series_min_candles=80,
|
||||||
|
time_series_forecast_horizon=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
forecast = TimeSeriesForecaster(settings).forecast(
|
||||||
|
_candles_from_returns([0.0002] * 140), symbol="BTCUSDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert forecast.usable is True
|
||||||
|
assert forecast.target_transform == "barrier_net_return"
|
||||||
|
assert forecast.expected_return_percent == pytest.approx(1.005, abs=0.01)
|
||||||
|
assert forecast.probability_take_profit_first == pytest.approx(0.8, abs=0.001)
|
||||||
|
assert "P(TP before SL)" in forecast.reason
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ def _report(*, validation_passed: bool = True, trades: int = 30, total: float =
|
|||||||
"max_drawdown_percent": 1.0,
|
"max_drawdown_percent": 1.0,
|
||||||
},
|
},
|
||||||
"walk_forward": {"summary": {"trades": trades, "avg_net_percent": 0.3}},
|
"walk_forward": {"summary": {"trades": trades, "avg_net_percent": 0.3}},
|
||||||
"validation": {"passed": validation_passed, "status": "pass" if validation_passed else "fail"},
|
"validation": {
|
||||||
|
"passed": validation_passed,
|
||||||
|
"status": "pass" if validation_passed else "fail",
|
||||||
|
"protocol": "untouched_model_holdout_with_threshold_walk_forward",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from crypto_spot_bot.models import Candle
|
||||||
|
from crypto_spot_bot.time_series import _torch_head_outputs
|
||||||
|
from tools.train_torch_recurrent_forecaster import (
|
||||||
|
OUTPUT_LAYOUT,
|
||||||
|
RecurrentReturnModel,
|
||||||
|
_barrier_outcome,
|
||||||
|
_export_head_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _candle(index: int, *, open_: float, high: float, low: float, close: float) -> Candle:
|
||||||
|
return Candle(index, open_, high, low, close, 100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_barrier_target_uses_next_open_and_marks_take_profit_first() -> None:
|
||||||
|
candles = [
|
||||||
|
_candle(0, open_=90.0, high=101.0, low=89.0, close=100.0),
|
||||||
|
_candle(1, open_=100.0, high=102.0, low=99.0, close=101.0),
|
||||||
|
_candle(2, open_=101.0, high=104.0, low=100.0, close=103.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
net_return, event = _barrier_outcome(
|
||||||
|
candles,
|
||||||
|
end_index=0,
|
||||||
|
horizon=2,
|
||||||
|
stop_loss_percent=0.02,
|
||||||
|
take_profit_percent=0.03,
|
||||||
|
round_trip_cost=0.002,
|
||||||
|
) or (math.nan, math.nan)
|
||||||
|
|
||||||
|
assert event == 1.0
|
||||||
|
assert net_return == pytest.approx(math.log(1.03) - 0.002)
|
||||||
|
|
||||||
|
|
||||||
|
def test_barrier_target_resolves_same_candle_tie_as_stop_loss() -> None:
|
||||||
|
candles = [
|
||||||
|
_candle(0, open_=100.0, high=101.0, low=99.0, close=100.0),
|
||||||
|
_candle(1, open_=100.0, high=104.0, low=97.0, close=101.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
net_return, event = _barrier_outcome(
|
||||||
|
candles,
|
||||||
|
end_index=0,
|
||||||
|
horizon=1,
|
||||||
|
stop_loss_percent=0.02,
|
||||||
|
take_profit_percent=0.03,
|
||||||
|
round_trip_cost=0.002,
|
||||||
|
) or (math.nan, math.nan)
|
||||||
|
|
||||||
|
assert event == 0.0
|
||||||
|
assert net_return == pytest.approx(math.log(0.98) - 0.002)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multitask_head_export_matches_runtime_inference() -> None:
|
||||||
|
torch.manual_seed(7)
|
||||||
|
model = RecurrentReturnModel(
|
||||||
|
architecture="gru",
|
||||||
|
input_size=2,
|
||||||
|
hidden_size=4,
|
||||||
|
num_layers=1,
|
||||||
|
dropout=0.0,
|
||||||
|
output_size=2 * len(OUTPUT_LAYOUT),
|
||||||
|
attention_pooling=False,
|
||||||
|
context_norm=False,
|
||||||
|
multitask_head=True,
|
||||||
|
head_hidden_size=6,
|
||||||
|
)
|
||||||
|
model.eval()
|
||||||
|
context = torch.tensor([[0.2, -0.1, 0.4, 0.3]], dtype=torch.float32)
|
||||||
|
with torch.no_grad():
|
||||||
|
shared = model.head_activation(model.head_hidden(context))
|
||||||
|
returns = model.return_head(shared).view(1, 2, 4)
|
||||||
|
events = model.event_head(shared).view(1, 2, 1)
|
||||||
|
expected = torch.cat((returns, events), dim=2).reshape(-1).tolist()
|
||||||
|
|
||||||
|
entry = {"multitask_head": True, **_export_head_state(model)}
|
||||||
|
actual = _torch_head_outputs(context[0].tolist(), entry, hidden_size=4)
|
||||||
|
|
||||||
|
assert actual == pytest.approx(expected, abs=2e-6)
|
||||||
@@ -4,7 +4,9 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from crypto_spot_bot.training_coordination import TrainingCoordinator
|
import pytest
|
||||||
|
|
||||||
|
from crypto_spot_bot.training_coordination import TrainingCoordinator, _validate_symbol_models
|
||||||
|
|
||||||
|
|
||||||
def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
|
def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
|
||||||
@@ -36,9 +38,93 @@ def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
|
|||||||
assert coordinator.status()["active_job"] is None
|
assert coordinator.status()["active_job"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_coordinator_preserves_boolean_resume_candidate_parameter(tmp_path) -> None:
|
||||||
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
|
|
||||||
|
requested = coordinator.request_retrain(
|
||||||
|
{"source": "recovery", "parameters": {"resume_candidate": True}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert requested["job"]["parameters"] == {"resume_candidate": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_coordinator_sanitizes_independent_training_parameters(tmp_path) -> None:
|
||||||
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
|
|
||||||
|
requested = coordinator.request_retrain(
|
||||||
|
{
|
||||||
|
"source": "recovery",
|
||||||
|
"parameters": {
|
||||||
|
"pooled": False,
|
||||||
|
"limit": 6000,
|
||||||
|
"validation_window": 720,
|
||||||
|
"ensemble_seeds": "7,19",
|
||||||
|
"selection_folds": 3,
|
||||||
|
"learning_rate": 0.0007,
|
||||||
|
"weight_decay": 0.0005,
|
||||||
|
"horizon": 12,
|
||||||
|
"horizons": "3,6,12,24",
|
||||||
|
"patience": 8,
|
||||||
|
"seed": 7,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert requested["job"]["parameters"] == {
|
||||||
|
"pooled": False,
|
||||||
|
"limit": 6000,
|
||||||
|
"validation_window": 720,
|
||||||
|
"ensemble_seeds": "7,19",
|
||||||
|
"selection_folds": 3,
|
||||||
|
"learning_rate": 0.0007,
|
||||||
|
"weight_decay": 0.0005,
|
||||||
|
"horizon": 12,
|
||||||
|
"horizons": "3,6,12,24",
|
||||||
|
"patience": 8,
|
||||||
|
"seed": 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_coordinator_reports_worker_identity_from_heartbeat(tmp_path) -> None:
|
||||||
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
|
|
||||||
|
heartbeat = coordinator.heartbeat(
|
||||||
|
{
|
||||||
|
"worker_id": "SEVENHILL:G:\\Repos\\TradeBot",
|
||||||
|
"name": "SEVENHILL",
|
||||||
|
"path": "G:\\Repos\\TradeBot",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert heartbeat["worker"]["name"] == "SEVENHILL"
|
||||||
|
assert heartbeat["worker"]["path"] == "G:\\Repos\\TradeBot"
|
||||||
|
assert heartbeat["status"]["worker"] == heartbeat["worker"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_coordinator_records_rejected_candidate_as_completed_training(tmp_path) -> None:
|
||||||
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
|
job = coordinator.request_retrain({"source": "android"})["job"]
|
||||||
|
coordinator.claim({"worker_id": "worker-1"})
|
||||||
|
|
||||||
|
completed = coordinator.complete(
|
||||||
|
job["id"],
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "training completed; candidate rejected by quality gate",
|
||||||
|
"summary": {"accepted": False, "reason": "candidate_failed_honest_validation"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert completed["job"]["status"] == "completed"
|
||||||
|
assert completed["job"]["phase"] == "completed"
|
||||||
|
assert completed["job"]["progress_percent"] == 100
|
||||||
|
assert completed["job"]["model_decision"] == "rejected"
|
||||||
|
|
||||||
|
|
||||||
def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
|
def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
|
||||||
coordinator = TrainingCoordinator(tmp_path)
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
job = coordinator.request_retrain({"source": "test"})["job"]
|
job = coordinator.request_retrain({"source": "test"})["job"]
|
||||||
|
coordinator.claim({"worker_id": "test-worker"})
|
||||||
payload = b'{"type":"pytorch_recurrent_forecaster","symbols":{}}\n'
|
payload = b'{"type":"pytorch_recurrent_forecaster","symbols":{}}\n'
|
||||||
sha256 = hashlib.sha256(payload).hexdigest()
|
sha256 = hashlib.sha256(payload).hexdigest()
|
||||||
first = payload[:20]
|
first = payload[:20]
|
||||||
@@ -67,10 +153,35 @@ def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
|
|||||||
|
|
||||||
assert part_1["complete"] is False
|
assert part_1["complete"] is False
|
||||||
assert part_2["complete"] is True
|
assert part_2["complete"] is True
|
||||||
assert (tmp_path / "lstm_forecaster.json").read_bytes() == payload
|
assert not (tmp_path / "lstm_forecaster.json").exists()
|
||||||
|
assert (tmp_path / ".training_uploads" / job["id"] / "ready" / "lstm_forecaster.json").read_bytes() == payload
|
||||||
assert coordinator.status()["latest_job"]["artifacts"][0]["sha256"] == sha256
|
assert coordinator.status()["latest_job"]["artifacts"][0]["sha256"] == sha256
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_validation_accepts_multitask_ensemble_members() -> None:
|
||||||
|
head = {
|
||||||
|
"state_dict": {"weight_ih_l0": [[0.0]]},
|
||||||
|
"head_hidden_weight": [[0.0]],
|
||||||
|
"head_hidden_bias": [0.0],
|
||||||
|
"return_head_weight": [[0.0]],
|
||||||
|
"return_head_bias": [0.0],
|
||||||
|
"event_head_weight": [[0.0]],
|
||||||
|
"event_head_bias": [0.0],
|
||||||
|
}
|
||||||
|
symbols = {
|
||||||
|
"BTCUSDT": {
|
||||||
|
"model": "torch_gru",
|
||||||
|
"lookback": 8,
|
||||||
|
"input_size": 2,
|
||||||
|
"hidden_size": 4,
|
||||||
|
"multitask_head": True,
|
||||||
|
"ensemble_members": [head, head],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_validate_symbol_models(symbols)
|
||||||
|
|
||||||
|
|
||||||
def test_running_claimed_job_keeps_agent_online_when_heartbeat_is_stale(tmp_path) -> None:
|
def test_running_claimed_job_keeps_agent_online_when_heartbeat_is_stale(tmp_path) -> None:
|
||||||
coordinator = TrainingCoordinator(tmp_path)
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
coordinator.request_retrain({"source": "android"})
|
coordinator.request_retrain({"source": "android"})
|
||||||
@@ -86,3 +197,73 @@ def test_running_claimed_job_keeps_agent_online_when_heartbeat_is_stale(tmp_path
|
|||||||
assert status["agent_recently_seen"] is False
|
assert status["agent_recently_seen"] is False
|
||||||
assert status["agent_busy"] is True
|
assert status["agent_busy"] is True
|
||||||
assert status["agent_online"] is True
|
assert status["agent_online"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_upload_rejects_unknown_job(tmp_path) -> None:
|
||||||
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
|
payload = b"{}"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
coordinator.save_artifact_chunk(
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
{
|
||||||
|
"name": "lstm_forecaster.json",
|
||||||
|
"index": 0,
|
||||||
|
"total": 1,
|
||||||
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"data_base64": base64.b64encode(payload).decode("ascii"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_bundle_promotes_only_after_successful_guard(tmp_path) -> None:
|
||||||
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
|
job = coordinator.request_retrain({"source": "test"})["job"]
|
||||||
|
coordinator.claim({"worker_id": "worker-1"})
|
||||||
|
model = {
|
||||||
|
"type": "pytorch_recurrent_forecaster",
|
||||||
|
"symbols": {
|
||||||
|
"BTCUSDT": {
|
||||||
|
"model": "torch_gru",
|
||||||
|
"lookback": 4,
|
||||||
|
"input_size": 1,
|
||||||
|
"hidden_size": 1,
|
||||||
|
"state_dict": {"weight_ih_l0": [[0.0]]},
|
||||||
|
"head_weight": [[0.0]],
|
||||||
|
"head_bias": [0.0],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
model_payload = (json.dumps(model) + "\n").encode()
|
||||||
|
model_sha256 = hashlib.sha256(model_payload).hexdigest()
|
||||||
|
artifacts = {
|
||||||
|
"lstm_forecaster.json": model,
|
||||||
|
"torch_retrain_guard.json": {
|
||||||
|
"accepted": True,
|
||||||
|
"candidate_artifact_sha256": model_sha256,
|
||||||
|
},
|
||||||
|
"torch_threshold_calibration.json": {
|
||||||
|
"artifact_sha256": model_sha256,
|
||||||
|
"validation": {
|
||||||
|
"passed": True,
|
||||||
|
"protocol": "untouched_model_holdout_with_threshold_walk_forward",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, data in artifacts.items():
|
||||||
|
payload = model_payload if name == "lstm_forecaster.json" else (json.dumps(data) + "\n").encode()
|
||||||
|
coordinator.save_artifact_chunk(
|
||||||
|
job["id"],
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"index": 0,
|
||||||
|
"total": 1,
|
||||||
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"data_base64": base64.b64encode(payload).decode("ascii"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = coordinator.complete(job["id"], {"success": True})
|
||||||
|
|
||||||
|
assert completed["job"]["status"] == "completed"
|
||||||
|
assert json.loads((tmp_path / "lstm_forecaster.json").read_text())["symbols"]["BTCUSDT"]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -11,6 +12,11 @@ def main() -> None:
|
|||||||
args = _parse_args()
|
args = _parse_args()
|
||||||
current = _read_json(args.current_report)
|
current = _read_json(args.current_report)
|
||||||
candidate = _read_json(args.candidate_report)
|
candidate = _read_json(args.candidate_report)
|
||||||
|
candidate_artifact = Path(args.candidate_artifact)
|
||||||
|
candidate_sha256 = _sha256(candidate_artifact)
|
||||||
|
if candidate.get("artifact_sha256") != candidate_sha256:
|
||||||
|
decision = {"accepted": False, "reason": "candidate_report_artifact_hash_mismatch"}
|
||||||
|
else:
|
||||||
decision = _decision(
|
decision = _decision(
|
||||||
current,
|
current,
|
||||||
candidate,
|
candidate,
|
||||||
@@ -24,6 +30,7 @@ def main() -> None:
|
|||||||
"reason": decision["reason"],
|
"reason": decision["reason"],
|
||||||
"current": _summary(current),
|
"current": _summary(current),
|
||||||
"candidate": _summary(candidate),
|
"candidate": _summary(candidate),
|
||||||
|
"candidate_artifact_sha256": candidate_sha256,
|
||||||
}
|
}
|
||||||
if args.report:
|
if args.report:
|
||||||
Path(args.report).write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
Path(args.report).write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
@@ -31,7 +38,6 @@ def main() -> None:
|
|||||||
if not decision["accepted"]:
|
if not decision["accepted"]:
|
||||||
raise SystemExit(2)
|
raise SystemExit(2)
|
||||||
target = Path(args.target_artifact)
|
target = Path(args.target_artifact)
|
||||||
candidate_artifact = Path(args.candidate_artifact)
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copy2(candidate_artifact, target)
|
shutil.copy2(candidate_artifact, target)
|
||||||
|
|
||||||
@@ -83,6 +89,8 @@ def _validation_passed(report: dict[str, Any]) -> bool:
|
|||||||
validation = report.get("validation")
|
validation = report.get("validation")
|
||||||
if not isinstance(validation, dict):
|
if not isinstance(validation, dict):
|
||||||
return False
|
return False
|
||||||
|
if validation.get("protocol") != "untouched_model_holdout_with_threshold_walk_forward":
|
||||||
|
return False
|
||||||
if "passed" in validation:
|
if "passed" in validation:
|
||||||
return bool(validation.get("passed"))
|
return bool(validation.get("passed"))
|
||||||
return str(validation.get("status", "")).strip().lower() in {"pass", "passed", "ok"}
|
return str(validation.get("status", "")).strip().lower() in {"pass", "passed", "ok"}
|
||||||
@@ -125,5 +133,13 @@ def _read_json(path: str) -> dict[str, Any]:
|
|||||||
return data if isinstance(data, dict) else {}
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ from crypto_spot_bot.indicators import add_indicators
|
|||||||
from crypto_spot_bot.models import Candle
|
from crypto_spot_bot.models import Candle
|
||||||
from crypto_spot_bot.time_series import (
|
from crypto_spot_bot.time_series import (
|
||||||
DEFAULT_TORCH_FEATURES,
|
DEFAULT_TORCH_FEATURES,
|
||||||
|
_barrier_outcome,
|
||||||
_current_volatility_scale,
|
_current_volatility_scale,
|
||||||
_entry_horizon,
|
_entry_horizon,
|
||||||
_entry_output_layout,
|
_entry_output_layout,
|
||||||
@@ -49,6 +51,10 @@ class ForecastRecord:
|
|||||||
index: int
|
index: int
|
||||||
timestamp: int
|
timestamp: int
|
||||||
close: float
|
close: float
|
||||||
|
high: float
|
||||||
|
low: float
|
||||||
|
next_open: float
|
||||||
|
next_timestamp: int
|
||||||
atr: float
|
atr: float
|
||||||
expected_percent: float
|
expected_percent: float
|
||||||
probability_up: float
|
probability_up: float
|
||||||
@@ -59,6 +65,7 @@ class ForecastRecord:
|
|||||||
future_net_percent: float
|
future_net_percent: float
|
||||||
benchmark_entry: bool
|
benchmark_entry: bool
|
||||||
benchmark_exit: bool
|
benchmark_exit: bool
|
||||||
|
take_profit_first: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -85,7 +92,9 @@ def main() -> None:
|
|||||||
symbols = _symbols(args.symbols, settings.symbols)
|
symbols = _symbols(args.symbols, settings.symbols)
|
||||||
context_symbols = sorted(set(symbols + _symbols(args.context_symbols, ())))
|
context_symbols = sorted(set(symbols + _symbols(args.context_symbols, ())))
|
||||||
artifact_path = Path(args.artifact or settings.time_series_lstm_model_path)
|
artifact_path = Path(args.artifact or settings.time_series_lstm_model_path)
|
||||||
artifact = json.loads(artifact_path.read_text(encoding="utf-8"))
|
artifact_bytes = artifact_path.read_bytes()
|
||||||
|
artifact_sha256 = hashlib.sha256(artifact_bytes).hexdigest()
|
||||||
|
artifact = json.loads(artifact_bytes.decode("utf-8"))
|
||||||
horizon = args.horizon if args.horizon > 0 else settings.time_series_forecast_horizon
|
horizon = args.horizon if args.horizon > 0 else settings.time_series_forecast_horizon
|
||||||
round_trip_cost = _artifact_round_trip_cost(artifact, settings)
|
round_trip_cost = _artifact_round_trip_cost(artifact, settings)
|
||||||
|
|
||||||
@@ -123,13 +132,15 @@ def main() -> None:
|
|||||||
if not records:
|
if not records:
|
||||||
raise SystemExit("No forecast records could be built for calibration.")
|
raise SystemExit("No forecast records could be built for calibration.")
|
||||||
|
|
||||||
results = _calibrate(
|
results = _calibrate_strategy(
|
||||||
records,
|
records,
|
||||||
edges=_float_grid(args.edge_grid),
|
edges=_float_grid(args.edge_grid),
|
||||||
probabilities=_float_grid(args.probability_grid),
|
probabilities=_float_grid(args.probability_grid),
|
||||||
confidences=_float_grid(args.confidence_grid),
|
confidences=_float_grid(args.confidence_grid),
|
||||||
min_trades=args.min_trades,
|
min_trades=args.min_trades,
|
||||||
horizon=horizon,
|
horizon=horizon,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
if not results:
|
if not results:
|
||||||
raise SystemExit("No calibration result produced trades. Use wider grids or more history.")
|
raise SystemExit("No calibration result produced trades. Use wider grids or more history.")
|
||||||
@@ -149,6 +160,45 @@ def main() -> None:
|
|||||||
round_trip_cost=round_trip_cost,
|
round_trip_cost=round_trip_cost,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
symbol_recommendations: dict[str, dict[str, Any]] = {}
|
||||||
|
symbol_threshold_results: dict[str, CalibrationResult] = {}
|
||||||
|
for symbol in symbols:
|
||||||
|
symbol_records = [record for record in records if record.symbol == symbol]
|
||||||
|
symbol_results = _calibrate_strategy(
|
||||||
|
symbol_records,
|
||||||
|
edges=_float_grid(args.edge_grid),
|
||||||
|
probabilities=_float_grid(args.probability_grid),
|
||||||
|
confidences=_float_grid(args.confidence_grid),
|
||||||
|
min_trades=max(3, min(args.min_trades, len(symbol_records) // 8)),
|
||||||
|
horizon=horizon,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
symbol_selected = _choose_recommendation(
|
||||||
|
symbol_results,
|
||||||
|
min_trades=max(3, min(args.min_trades, len(symbol_records) // 8)),
|
||||||
|
) if symbol_results else None
|
||||||
|
if symbol_selected is not None:
|
||||||
|
symbol_recommendations[symbol] = _result_dict(symbol_selected)
|
||||||
|
symbol_threshold_results[symbol] = symbol_selected
|
||||||
|
calibration_insufficient = recommended is None or not symbol_threshold_results
|
||||||
|
if recommended is None:
|
||||||
|
recommended = _empty_recommendation(
|
||||||
|
_float_grid(args.edge_grid),
|
||||||
|
_float_grid(args.probability_grid),
|
||||||
|
_float_grid(args.confidence_grid),
|
||||||
|
)
|
||||||
|
full_backtest = {**_stats([]), "trades_detail": [], "symbol_breakdown": []}
|
||||||
|
elif symbol_threshold_results:
|
||||||
|
full_backtest = _full_backtest(
|
||||||
|
records,
|
||||||
|
recommended,
|
||||||
|
horizon=horizon,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
|
symbol_thresholds=symbol_threshold_results,
|
||||||
|
require_symbol_thresholds=True,
|
||||||
|
)
|
||||||
print("\nRECOMMENDED")
|
print("\nRECOMMENDED")
|
||||||
print(_result_line(recommended))
|
print(_result_line(recommended))
|
||||||
print("\nFULL_REPLAY")
|
print("\nFULL_REPLAY")
|
||||||
@@ -181,6 +231,16 @@ def main() -> None:
|
|||||||
min_profit_factor=args.min_oos_profit_factor,
|
min_profit_factor=args.min_oos_profit_factor,
|
||||||
min_benchmark_edge=args.min_benchmark_edge_percent,
|
min_benchmark_edge=args.min_benchmark_edge_percent,
|
||||||
)
|
)
|
||||||
|
deployment_recommended = recommended
|
||||||
|
deployment_symbol_recommendations = symbol_recommendations
|
||||||
|
if walk_forward.get("folds"):
|
||||||
|
last_fold = walk_forward["folds"][-1]
|
||||||
|
fold_thresholds = last_fold.get("thresholds")
|
||||||
|
if isinstance(fold_thresholds, dict):
|
||||||
|
deployment_recommended = _result_from_dict(fold_thresholds)
|
||||||
|
fold_symbols = last_fold.get("symbol_thresholds")
|
||||||
|
if isinstance(fold_symbols, dict):
|
||||||
|
deployment_symbol_recommendations = fold_symbols
|
||||||
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("\nBENCHMARK")
|
||||||
@@ -196,9 +256,13 @@ def main() -> None:
|
|||||||
|
|
||||||
if args.output:
|
if args.output:
|
||||||
payload = {
|
payload = {
|
||||||
|
"artifact_sha256": artifact_sha256,
|
||||||
"artifact": _artifact_summary(artifact),
|
"artifact": _artifact_summary(artifact),
|
||||||
"records_by_symbol": per_symbol_counts,
|
"records_by_symbol": per_symbol_counts,
|
||||||
"recommended": _result_dict(recommended),
|
"recommended": _result_dict(deployment_recommended),
|
||||||
|
"calibration_insufficient": calibration_insufficient,
|
||||||
|
"symbol_recommendations": deployment_symbol_recommendations,
|
||||||
|
"eligible_symbols": sorted(deployment_symbol_recommendations),
|
||||||
"full_replay": full_backtest,
|
"full_replay": full_backtest,
|
||||||
"walk_forward": walk_forward,
|
"walk_forward": walk_forward,
|
||||||
"benchmark": benchmark,
|
"benchmark": benchmark,
|
||||||
@@ -273,6 +337,11 @@ def _forecast_records(
|
|||||||
decision_horizon = _entry_horizon(entry, horizon)
|
decision_horizon = _entry_horizon(entry, horizon)
|
||||||
start = max(min_candles, int(float(entry.get("lookback", 64))))
|
start = max(min_candles, int(float(entry.get("lookback", 64))))
|
||||||
end = len(candles) - decision_horizon - 1
|
end = len(candles) - decision_horizon - 1
|
||||||
|
holdout_start_timestamp = int(float(entry.get("holdout_start_timestamp", 0) or 0))
|
||||||
|
if holdout_start_timestamp <= 0:
|
||||||
|
return []
|
||||||
|
while start < end and candles[start].timestamp < holdout_start_timestamp:
|
||||||
|
start += 1
|
||||||
if calibration_window > 0:
|
if calibration_window > 0:
|
||||||
start = max(start, end - calibration_window)
|
start = max(start, end - calibration_window)
|
||||||
batched_records = _batch_forecast_records(
|
batched_records = _batch_forecast_records(
|
||||||
@@ -293,7 +362,9 @@ def _forecast_records(
|
|||||||
return batched_records
|
return batched_records
|
||||||
|
|
||||||
records: list[ForecastRecord] = []
|
records: list[ForecastRecord] = []
|
||||||
skill = float(entry.get("skill", 0.0) or 0.0)
|
# Entry eligibility may use validation-derived quality only. Holdout metrics
|
||||||
|
# belong exclusively to the final quality gate and cannot influence replay.
|
||||||
|
skill = _entry_validation_skill(entry)
|
||||||
for index in range(start, max(start, end)):
|
for index in range(start, max(start, end)):
|
||||||
prediction = _torch_recurrent_predict(
|
prediction = _torch_recurrent_predict(
|
||||||
_log_returns(closes[: index + 1]),
|
_log_returns(closes[: index + 1]),
|
||||||
@@ -313,7 +384,25 @@ def _forecast_records(
|
|||||||
q50 = float(selected.get("q50", expected_return))
|
q50 = float(selected.get("q50", expected_return))
|
||||||
expected_percent = (math.exp(expected_return) - 1.0) * 100.0
|
expected_percent = (math.exp(expected_return) - 1.0) * 100.0
|
||||||
q50_percent = (math.exp(q50) - 1.0) * 100.0
|
q50_percent = (math.exp(q50) - 1.0) * 100.0
|
||||||
future_log_return = math.log(closes[index + decision_horizon] / closes[index]) - round_trip_cost
|
next_open = float(candles[index + 1].open)
|
||||||
|
if next_open <= 0:
|
||||||
|
continue
|
||||||
|
take_profit_first: bool | None = None
|
||||||
|
if str(entry.get("target_transform", "")) == "barrier_net_return":
|
||||||
|
outcome = _barrier_outcome(
|
||||||
|
candles,
|
||||||
|
end_index=index,
|
||||||
|
horizon=decision_horizon,
|
||||||
|
stop_loss_percent=_float_entry(entry, "target_stop_loss_percent", 0.04),
|
||||||
|
take_profit_percent=_float_entry(entry, "target_take_profit_percent", 0.035),
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
)
|
||||||
|
if outcome is None:
|
||||||
|
continue
|
||||||
|
future_log_return, event = outcome
|
||||||
|
take_profit_first = event >= 0.5
|
||||||
|
else:
|
||||||
|
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
|
||||||
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
|
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
|
||||||
records.append(
|
records.append(
|
||||||
ForecastRecord(
|
ForecastRecord(
|
||||||
@@ -321,6 +410,10 @@ def _forecast_records(
|
|||||||
index=index,
|
index=index,
|
||||||
timestamp=candles[index].timestamp,
|
timestamp=candles[index].timestamp,
|
||||||
close=closes[index],
|
close=closes[index],
|
||||||
|
high=float(candles[index].high),
|
||||||
|
low=float(candles[index].low),
|
||||||
|
next_open=next_open,
|
||||||
|
next_timestamp=candles[index + 1].timestamp,
|
||||||
atr=float(candles[index].atr_14 or 0.0),
|
atr=float(candles[index].atr_14 or 0.0),
|
||||||
expected_percent=expected_percent,
|
expected_percent=expected_percent,
|
||||||
probability_up=probability_up,
|
probability_up=probability_up,
|
||||||
@@ -331,6 +424,7 @@ def _forecast_records(
|
|||||||
future_net_percent=future_net_percent,
|
future_net_percent=future_net_percent,
|
||||||
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
||||||
benchmark_exit=_benchmark_exit_signal(candles, index),
|
benchmark_exit=_benchmark_exit_signal(candles, index),
|
||||||
|
take_profit_first=take_profit_first,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return records
|
return records
|
||||||
@@ -356,8 +450,8 @@ def _batch_forecast_records(
|
|||||||
horizons = _entry_target_horizons(entry)
|
horizons = _entry_target_horizons(entry)
|
||||||
if not horizons:
|
if not horizons:
|
||||||
return None
|
return None
|
||||||
model = _build_torch_model(entry, model_name)
|
models = _build_torch_models(entry, model_name)
|
||||||
if model is None:
|
if not models:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
lookback = int(_clamp(_float_entry(entry, "lookback", 64.0), 4.0, 512.0))
|
lookback = int(_clamp(_float_entry(entry, "lookback", 64.0), 4.0, 512.0))
|
||||||
@@ -374,7 +468,8 @@ def _batch_forecast_records(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
records: list[ForecastRecord] = []
|
records: list[ForecastRecord] = []
|
||||||
skill = float(entry.get("skill", 0.0) or 0.0)
|
skill = _entry_validation_skill(entry)
|
||||||
|
for model in models:
|
||||||
model.eval()
|
model.eval()
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
for offset in range(0, len(indices), max(1, batch_size)):
|
for offset in range(0, len(indices), max(1, batch_size)):
|
||||||
@@ -390,10 +485,15 @@ def _batch_forecast_records(
|
|||||||
for index in batch_indices
|
for index in batch_indices
|
||||||
]
|
]
|
||||||
batch = torch.tensor(windows, dtype=torch.float32)
|
batch = torch.tensor(windows, dtype=torch.float32)
|
||||||
outputs = model(batch).detach().cpu().tolist()
|
outputs_by_model = [model(batch).detach().cpu().tolist() for model in models]
|
||||||
for index, output in zip(batch_indices, outputs):
|
for batch_offset, index in enumerate(batch_indices):
|
||||||
selected = _decode_selected_output(
|
selected = _average_selected_predictions(
|
||||||
output,
|
[
|
||||||
|
decoded
|
||||||
|
for outputs in outputs_by_model
|
||||||
|
if (
|
||||||
|
decoded := _decode_selected_output(
|
||||||
|
outputs[batch_offset],
|
||||||
entry=entry,
|
entry=entry,
|
||||||
candles=candles,
|
candles=candles,
|
||||||
closes=closes,
|
closes=closes,
|
||||||
@@ -402,6 +502,9 @@ def _batch_forecast_records(
|
|||||||
clip=clip,
|
clip=clip,
|
||||||
round_trip_cost=round_trip_cost,
|
round_trip_cost=round_trip_cost,
|
||||||
)
|
)
|
||||||
|
) is not None
|
||||||
|
]
|
||||||
|
)
|
||||||
if selected is None:
|
if selected is None:
|
||||||
continue
|
continue
|
||||||
expected_return = float(selected["expected_return"])
|
expected_return = float(selected["expected_return"])
|
||||||
@@ -409,7 +512,25 @@ def _batch_forecast_records(
|
|||||||
q50 = float(selected["q50"])
|
q50 = float(selected["q50"])
|
||||||
expected_percent = (math.exp(expected_return) - 1.0) * 100.0
|
expected_percent = (math.exp(expected_return) - 1.0) * 100.0
|
||||||
q50_percent = (math.exp(q50) - 1.0) * 100.0
|
q50_percent = (math.exp(q50) - 1.0) * 100.0
|
||||||
future_log_return = math.log(closes[index + decision_horizon] / closes[index]) - round_trip_cost
|
next_open = float(candles[index + 1].open)
|
||||||
|
if next_open <= 0:
|
||||||
|
continue
|
||||||
|
take_profit_first: bool | None = None
|
||||||
|
if str(entry.get("target_transform", "")) == "barrier_net_return":
|
||||||
|
outcome = _barrier_outcome(
|
||||||
|
candles,
|
||||||
|
end_index=index,
|
||||||
|
horizon=decision_horizon,
|
||||||
|
stop_loss_percent=_float_entry(entry, "target_stop_loss_percent", 0.04),
|
||||||
|
take_profit_percent=_float_entry(entry, "target_take_profit_percent", 0.035),
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
)
|
||||||
|
if outcome is None:
|
||||||
|
continue
|
||||||
|
future_log_return, event = outcome
|
||||||
|
take_profit_first = event >= 0.5
|
||||||
|
else:
|
||||||
|
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
|
||||||
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
|
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
|
||||||
records.append(
|
records.append(
|
||||||
ForecastRecord(
|
ForecastRecord(
|
||||||
@@ -417,6 +538,10 @@ def _batch_forecast_records(
|
|||||||
index=index,
|
index=index,
|
||||||
timestamp=candles[index].timestamp,
|
timestamp=candles[index].timestamp,
|
||||||
close=closes[index],
|
close=closes[index],
|
||||||
|
high=float(candles[index].high),
|
||||||
|
low=float(candles[index].low),
|
||||||
|
next_open=next_open,
|
||||||
|
next_timestamp=candles[index + 1].timestamp,
|
||||||
atr=float(candles[index].atr_14 or 0.0),
|
atr=float(candles[index].atr_14 or 0.0),
|
||||||
expected_percent=expected_percent,
|
expected_percent=expected_percent,
|
||||||
probability_up=probability_up,
|
probability_up=probability_up,
|
||||||
@@ -427,11 +552,26 @@ def _batch_forecast_records(
|
|||||||
future_net_percent=future_net_percent,
|
future_net_percent=future_net_percent,
|
||||||
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
||||||
benchmark_exit=_benchmark_exit_signal(candles, index),
|
benchmark_exit=_benchmark_exit_signal(candles, index),
|
||||||
|
take_profit_first=take_profit_first,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _build_torch_models(entry: dict[str, Any], model_name: str) -> list[Any]:
|
||||||
|
members = entry.get("ensemble_members")
|
||||||
|
if isinstance(members, list) and members:
|
||||||
|
base = {key: value for key, value in entry.items() if key != "ensemble_members"}
|
||||||
|
models = [
|
||||||
|
_build_torch_model({**base, **member}, model_name)
|
||||||
|
for member in members
|
||||||
|
if isinstance(member, dict)
|
||||||
|
]
|
||||||
|
return [model for model in models if model is not None]
|
||||||
|
model = _build_torch_model(entry, model_name)
|
||||||
|
return [model] if model is not None else []
|
||||||
|
|
||||||
|
|
||||||
def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
||||||
if torch is None or RecurrentReturnModel is None:
|
if torch is None or RecurrentReturnModel is None:
|
||||||
return None
|
return None
|
||||||
@@ -451,6 +591,10 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
|||||||
output_size=output_size,
|
output_size=output_size,
|
||||||
attention_pooling=bool(entry.get("attention_pooling")),
|
attention_pooling=bool(entry.get("attention_pooling")),
|
||||||
context_norm=bool(entry.get("context_norm")),
|
context_norm=bool(entry.get("context_norm")),
|
||||||
|
multitask_head=bool(entry.get("multitask_head")),
|
||||||
|
head_hidden_size=int(
|
||||||
|
_clamp(_float_entry(entry, "head_hidden_size", float(hidden_size)), 8.0, 1024.0)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
raw_state = entry.get("state_dict")
|
raw_state = entry.get("state_dict")
|
||||||
if not isinstance(raw_state, dict):
|
if not isinstance(raw_state, dict):
|
||||||
@@ -460,6 +604,20 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
|||||||
for key, value in raw_state.items()
|
for key, value in raw_state.items()
|
||||||
if isinstance(value, list)
|
if isinstance(value, list)
|
||||||
}
|
}
|
||||||
|
if bool(entry.get("multitask_head")):
|
||||||
|
for artifact_name, state_name in (
|
||||||
|
("head_hidden_weight", "head_hidden.weight"),
|
||||||
|
("head_hidden_bias", "head_hidden.bias"),
|
||||||
|
("return_head_weight", "return_head.weight"),
|
||||||
|
("return_head_bias", "return_head.bias"),
|
||||||
|
("event_head_weight", "event_head.weight"),
|
||||||
|
("event_head_bias", "event_head.bias"),
|
||||||
|
):
|
||||||
|
value = entry.get(artifact_name)
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return None
|
||||||
|
state[state_name] = torch.tensor(value, dtype=torch.float32)
|
||||||
|
else:
|
||||||
head_weight = entry.get("head_weight")
|
head_weight = entry.get("head_weight")
|
||||||
head_bias = entry.get("head_bias")
|
head_bias = entry.get("head_bias")
|
||||||
if not isinstance(head_weight, list) or not isinstance(head_bias, list):
|
if not isinstance(head_weight, list) or not isinstance(head_bias, list):
|
||||||
@@ -486,6 +644,15 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _average_selected_predictions(rows: list[dict[str, float]]) -> dict[str, float] | None:
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
name: sum(float(row[name]) for row in rows) / len(rows)
|
||||||
|
for name in ("expected_return", "q50", "probability_up")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _decode_selected_output(
|
def _decode_selected_output(
|
||||||
output: list[float],
|
output: list[float],
|
||||||
*,
|
*,
|
||||||
@@ -524,10 +691,20 @@ def _decode_selected_output(
|
|||||||
expected = decode("mean")
|
expected = decode("mean")
|
||||||
q_values = sorted([decode("q10", expected), decode("q50", expected), decode("q90", expected)])
|
q_values = sorted([decode("q10", expected), decode("q50", expected), decode("q90", expected)])
|
||||||
cap = _prediction_cap(history_closes, selected_horizon, round_trip_cost)
|
cap = _prediction_cap(history_closes, selected_horizon, round_trip_cost)
|
||||||
|
if str(entry.get("target_transform", "")) == "barrier_net_return":
|
||||||
|
stop_percent = _clamp(_float_entry(entry, "target_stop_loss_percent", 0.04), 0.003, 0.08)
|
||||||
|
take_percent = _clamp(_float_entry(entry, "target_take_profit_percent", 0.035), 0.003, 0.20)
|
||||||
|
cap = max(
|
||||||
|
cap,
|
||||||
|
abs(math.log(1.0 - stop_percent) - round_trip_cost),
|
||||||
|
abs(math.log(1.0 + take_percent) - round_trip_cost),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"expected_return": _clamp(expected, -cap, cap),
|
"expected_return": _clamp(expected, -cap, cap),
|
||||||
"q50": _clamp(q_values[1], -cap, cap),
|
"q50": _clamp(q_values[1], -cap, cap),
|
||||||
"probability_up": _sigmoid(float(values.get("logit_up", 0.0))),
|
"probability_up": _sigmoid(
|
||||||
|
float(values.get("logit_tp_first", values.get("logit_up", 0.0)))
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -563,18 +740,22 @@ def _full_backtest(
|
|||||||
round_trip_cost: float,
|
round_trip_cost: float,
|
||||||
settings: Any,
|
settings: Any,
|
||||||
detail_limit: int = 50,
|
detail_limit: int = 50,
|
||||||
|
symbol_thresholds: dict[str, CalibrationResult] | None = None,
|
||||||
|
require_symbol_thresholds: bool = False,
|
||||||
) -> 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
|
||||||
|
take_profit_percent = max(0.003, min(0.20, float(settings.take_profit_percent))) * 100.0
|
||||||
stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True))
|
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)):
|
||||||
|
active_thresholds = (symbol_thresholds or {}).get(record.symbol, thresholds)
|
||||||
position = positions.get(record.symbol)
|
position = positions.get(record.symbol)
|
||||||
if position is not None:
|
if position is not None:
|
||||||
position["highest"] = max(position["highest"], record.close)
|
position["highest"] = max(position["highest"], record.high)
|
||||||
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_level = (
|
atr_stop_level = (
|
||||||
@@ -584,26 +765,35 @@ def _full_backtest(
|
|||||||
)
|
)
|
||||||
atr_stop = bool(
|
atr_stop = bool(
|
||||||
atr_stop_level is not None
|
atr_stop_level is not None
|
||||||
and record.close <= atr_stop_level
|
and record.low <= atr_stop_level
|
||||||
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
|
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
|
||||||
)
|
)
|
||||||
weak_forecast = (
|
weak_forecast = (
|
||||||
record.expected_percent < thresholds.edge
|
record.expected_percent < active_thresholds.edge
|
||||||
or record.probability_up < thresholds.probability
|
or record.probability_up < active_thresholds.probability
|
||||||
or record.skill <= 0.0
|
or record.skill <= 0.0
|
||||||
)
|
)
|
||||||
exit_reason = ""
|
exit_reason = ""
|
||||||
if stop_loss_exit_enabled and net_percent <= -stop_loss_percent:
|
exit_price = record.close
|
||||||
|
stop_level = position["entry_price"] * (1.0 - stop_loss_percent / 100.0)
|
||||||
|
take_level = position["entry_price"] * (1.0 + take_profit_percent / 100.0)
|
||||||
|
if stop_loss_exit_enabled and record.low <= stop_level:
|
||||||
exit_reason = "stop_loss"
|
exit_reason = "stop_loss"
|
||||||
|
exit_price = stop_level
|
||||||
|
elif record.high >= take_level:
|
||||||
|
exit_reason = "take_profit"
|
||||||
|
exit_price = take_level
|
||||||
elif atr_stop:
|
elif atr_stop:
|
||||||
exit_reason = "atr_trailing_stop"
|
exit_reason = "atr_trailing_stop"
|
||||||
elif (record.expected_percent <= 0.0 or record.probability_up <= 0.50 or _candidate_blocks(record, thresholds.edge)):
|
exit_price = float(atr_stop_level)
|
||||||
|
elif (record.expected_percent <= 0.0 or record.probability_up <= 0.50 or _candidate_blocks(record, active_thresholds.edge)):
|
||||||
exit_reason = "forecast_negative"
|
exit_reason = "forecast_negative"
|
||||||
elif weak_forecast and net_percent >= 0:
|
elif weak_forecast and net_percent >= 0:
|
||||||
exit_reason = "forecast_weak_profit_lock"
|
exit_reason = "forecast_weak_profit_lock"
|
||||||
elif held >= max_hold:
|
elif held >= max_hold:
|
||||||
exit_reason = "max_hold"
|
exit_reason = "max_hold"
|
||||||
if exit_reason:
|
if exit_reason:
|
||||||
|
net_percent = _net_percent(position["entry_price"], exit_price, round_trip_cost)
|
||||||
trades.append(net_percent)
|
trades.append(net_percent)
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
@@ -622,12 +812,14 @@ def _full_backtest(
|
|||||||
|
|
||||||
if record.symbol in positions:
|
if record.symbol in positions:
|
||||||
continue
|
continue
|
||||||
if _candidate_allows(record, thresholds.edge, thresholds.probability, thresholds.confidence):
|
if require_symbol_thresholds and record.symbol not in (symbol_thresholds or {}):
|
||||||
|
continue
|
||||||
|
if _candidate_allows(record, active_thresholds.edge, active_thresholds.probability, active_thresholds.confidence):
|
||||||
positions[record.symbol] = {
|
positions[record.symbol] = {
|
||||||
"entry_price": record.close,
|
"entry_price": record.next_open,
|
||||||
"entry_index": record.index,
|
"entry_index": record.index + 1,
|
||||||
"timestamp": record.timestamp,
|
"timestamp": record.next_timestamp,
|
||||||
"highest": record.close,
|
"highest": record.next_open,
|
||||||
"probability_up": record.probability_up,
|
"probability_up": record.probability_up,
|
||||||
"expected_percent": record.expected_percent,
|
"expected_percent": record.expected_percent,
|
||||||
}
|
}
|
||||||
@@ -669,12 +861,13 @@ def _benchmark_backtest(
|
|||||||
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
|
||||||
|
take_profit_percent = max(0.003, min(0.20, float(settings.take_profit_percent))) * 100.0
|
||||||
stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True))
|
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)
|
||||||
if position is not None:
|
if position is not None:
|
||||||
position["highest"] = max(position["highest"], record.close)
|
position["highest"] = max(position["highest"], record.high)
|
||||||
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_level = (
|
atr_stop_level = (
|
||||||
@@ -684,19 +877,28 @@ def _benchmark_backtest(
|
|||||||
)
|
)
|
||||||
atr_stop = bool(
|
atr_stop = bool(
|
||||||
atr_stop_level is not None
|
atr_stop_level is not None
|
||||||
and record.close <= atr_stop_level
|
and record.low <= atr_stop_level
|
||||||
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
|
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
|
||||||
)
|
)
|
||||||
exit_reason = ""
|
exit_reason = ""
|
||||||
if stop_loss_exit_enabled and net_percent <= -stop_loss_percent:
|
exit_price = record.close
|
||||||
|
stop_level = position["entry_price"] * (1.0 - stop_loss_percent / 100.0)
|
||||||
|
take_level = position["entry_price"] * (1.0 + take_profit_percent / 100.0)
|
||||||
|
if stop_loss_exit_enabled and record.low <= stop_level:
|
||||||
exit_reason = "stop_loss"
|
exit_reason = "stop_loss"
|
||||||
|
exit_price = stop_level
|
||||||
|
elif record.high >= take_level:
|
||||||
|
exit_reason = "take_profit"
|
||||||
|
exit_price = take_level
|
||||||
elif atr_stop:
|
elif atr_stop:
|
||||||
exit_reason = "atr_trailing_stop"
|
exit_reason = "atr_trailing_stop"
|
||||||
|
exit_price = float(atr_stop_level)
|
||||||
elif record.benchmark_exit:
|
elif record.benchmark_exit:
|
||||||
exit_reason = "benchmark_exit"
|
exit_reason = "benchmark_exit"
|
||||||
elif held >= max_hold:
|
elif held >= max_hold:
|
||||||
exit_reason = "max_hold"
|
exit_reason = "max_hold"
|
||||||
if exit_reason:
|
if exit_reason:
|
||||||
|
net_percent = _net_percent(position["entry_price"], exit_price, round_trip_cost)
|
||||||
trades.append(net_percent)
|
trades.append(net_percent)
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
@@ -715,10 +917,10 @@ def _benchmark_backtest(
|
|||||||
continue
|
continue
|
||||||
if record.benchmark_entry:
|
if record.benchmark_entry:
|
||||||
positions[record.symbol] = {
|
positions[record.symbol] = {
|
||||||
"entry_price": record.close,
|
"entry_price": record.next_open,
|
||||||
"entry_index": record.index,
|
"entry_index": record.index + 1,
|
||||||
"timestamp": record.timestamp,
|
"timestamp": record.next_timestamp,
|
||||||
"highest": record.close,
|
"highest": record.next_open,
|
||||||
}
|
}
|
||||||
for symbol, position in list(positions.items()):
|
for symbol, position in list(positions.items()):
|
||||||
tail = next((record for record in reversed(records) if record.symbol == symbol), None)
|
tail = next((record for record in reversed(records) if record.symbol == symbol), None)
|
||||||
@@ -768,24 +970,50 @@ def _walk_forward(
|
|||||||
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]
|
||||||
train = [record for record in ordered if record.timestamp < test_start]
|
train = [record for record in ordered if record.timestamp < test_start]
|
||||||
test = [record for record in ordered if test_start <= record.timestamp <= test_end]
|
test = [record for record in ordered if test_start <= record.timestamp <= test_end]
|
||||||
train_results = _calibrate(
|
probability_calibration = _fit_platt_calibration(train)
|
||||||
train,
|
calibrated_train = _apply_platt_calibration(train, probability_calibration)
|
||||||
|
calibrated_test = _apply_platt_calibration(test, probability_calibration)
|
||||||
|
train_results = _calibrate_strategy(
|
||||||
|
calibrated_train,
|
||||||
edges=edges,
|
edges=edges,
|
||||||
probabilities=probabilities,
|
probabilities=probabilities,
|
||||||
confidences=confidences,
|
confidences=confidences,
|
||||||
min_trades=max(4, min_trades // 2),
|
min_trades=max(4, min_trades // 2),
|
||||||
horizon=horizon,
|
horizon=horizon,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
)
|
)
|
||||||
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))
|
||||||
|
if selected is None:
|
||||||
|
continue
|
||||||
|
symbol_thresholds: dict[str, CalibrationResult] = {}
|
||||||
|
train_symbols = sorted({record.symbol for record in calibrated_train})
|
||||||
|
symbol_min_trades = max(3, min_trades // max(2, len(train_symbols) * 2))
|
||||||
|
for symbol in train_symbols:
|
||||||
|
symbol_results = _calibrate_strategy(
|
||||||
|
[record for record in calibrated_train if record.symbol == symbol],
|
||||||
|
edges=edges,
|
||||||
|
probabilities=probabilities,
|
||||||
|
confidences=confidences,
|
||||||
|
min_trades=symbol_min_trades,
|
||||||
|
horizon=horizon,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
symbol_selected = _choose_recommendation(symbol_results, min_trades=symbol_min_trades) if symbol_results else None
|
||||||
|
if symbol_selected is not None:
|
||||||
|
symbol_thresholds[symbol] = symbol_selected
|
||||||
test_backtest = _full_backtest(
|
test_backtest = _full_backtest(
|
||||||
test,
|
calibrated_test,
|
||||||
selected,
|
selected,
|
||||||
horizon=horizon,
|
horizon=horizon,
|
||||||
round_trip_cost=round_trip_cost,
|
round_trip_cost=round_trip_cost,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
detail_limit=0,
|
detail_limit=0,
|
||||||
|
symbol_thresholds=symbol_thresholds,
|
||||||
|
require_symbol_thresholds=True,
|
||||||
)
|
)
|
||||||
test_rows = test_backtest.get("trades_detail", [])
|
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)]
|
test_trades = [float(row.get("net_percent", 0.0) or 0.0) for row in test_rows if isinstance(row, dict)]
|
||||||
@@ -797,6 +1025,11 @@ def _walk_forward(
|
|||||||
"train_records": len(train),
|
"train_records": len(train),
|
||||||
"test_records": len(test),
|
"test_records": len(test),
|
||||||
"thresholds": _result_dict(selected),
|
"thresholds": _result_dict(selected),
|
||||||
|
"symbol_thresholds": {
|
||||||
|
symbol: _result_dict(value) for symbol, value in symbol_thresholds.items()
|
||||||
|
},
|
||||||
|
"eligible_symbols": sorted(symbol_thresholds),
|
||||||
|
"probability_calibration": probability_calibration,
|
||||||
"test": {key: value for key, value in test_backtest.items() if key != "trades_detail"},
|
"test": {key: value for key, value in test_backtest.items() if key != "trades_detail"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -888,6 +1121,7 @@ def _quality_gate(
|
|||||||
return {
|
return {
|
||||||
"status": "pass" if passed else "fail",
|
"status": "pass" if passed else "fail",
|
||||||
"passed": passed,
|
"passed": passed,
|
||||||
|
"protocol": "untouched_model_holdout_with_threshold_walk_forward",
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
"oos_summary": summary,
|
"oos_summary": summary,
|
||||||
"benchmark_summary": benchmark_summary,
|
"benchmark_summary": benchmark_summary,
|
||||||
@@ -932,6 +1166,11 @@ def _candidate_blocks(record: ForecastRecord, edge: float) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_validation_skill(entry: dict[str, Any]) -> float:
|
||||||
|
value = entry.get("validation_skill")
|
||||||
|
return float(value) if isinstance(value, (int, float)) and math.isfinite(float(value)) else 0.0
|
||||||
|
|
||||||
|
|
||||||
def _candidate_allows(record: ForecastRecord, edge: float, probability: float, confidence: float) -> bool:
|
def _candidate_allows(record: ForecastRecord, edge: float, probability: float, confidence: float) -> bool:
|
||||||
dynamic_confidence = _forecast_confidence(record.expected_percent, record.probability_up, record.skill, edge)
|
dynamic_confidence = _forecast_confidence(record.expected_percent, record.probability_up, record.skill, edge)
|
||||||
return (
|
return (
|
||||||
@@ -1098,6 +1337,101 @@ def _calibrate(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _calibrate_strategy(
|
||||||
|
records: list[ForecastRecord],
|
||||||
|
*,
|
||||||
|
edges: list[float],
|
||||||
|
probabilities: list[float],
|
||||||
|
confidences: list[float],
|
||||||
|
min_trades: int,
|
||||||
|
horizon: int,
|
||||||
|
round_trip_cost: float,
|
||||||
|
settings: Any,
|
||||||
|
) -> list[CalibrationResult]:
|
||||||
|
results: list[CalibrationResult] = []
|
||||||
|
for edge in edges:
|
||||||
|
for probability in probabilities:
|
||||||
|
for confidence in confidences:
|
||||||
|
thresholds = CalibrationResult(
|
||||||
|
edge=edge,
|
||||||
|
probability=probability,
|
||||||
|
confidence=confidence,
|
||||||
|
trades=0,
|
||||||
|
wins=0,
|
||||||
|
win_rate=0.0,
|
||||||
|
total_net_percent=0.0,
|
||||||
|
average_net_percent=0.0,
|
||||||
|
max_drawdown_percent=0.0,
|
||||||
|
profit_factor=0.0,
|
||||||
|
score=0.0,
|
||||||
|
)
|
||||||
|
replay = _full_backtest(
|
||||||
|
records,
|
||||||
|
thresholds,
|
||||||
|
horizon=horizon,
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
settings=settings,
|
||||||
|
detail_limit=0,
|
||||||
|
)
|
||||||
|
trades = int(replay.get("trades", 0) or 0)
|
||||||
|
if trades <= 0:
|
||||||
|
continue
|
||||||
|
wins = int(replay.get("wins", 0) or 0)
|
||||||
|
total = float(replay.get("total_net_percent", 0.0) or 0.0)
|
||||||
|
average = float(replay.get("avg_net_percent", 0.0) or 0.0)
|
||||||
|
drawdown = float(replay.get("max_drawdown_percent", 0.0) or 0.0)
|
||||||
|
profit_factor = float(replay.get("profit_factor", 0.0) or 0.0)
|
||||||
|
trade_factor = min(1.0, trades / max(1, min_trades))
|
||||||
|
score = (
|
||||||
|
average * trade_factor
|
||||||
|
+ total * 0.015
|
||||||
|
- drawdown * 0.03
|
||||||
|
+ (wins / trades) * 0.04
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
CalibrationResult(
|
||||||
|
edge=edge,
|
||||||
|
probability=probability,
|
||||||
|
confidence=confidence,
|
||||||
|
trades=trades,
|
||||||
|
wins=wins,
|
||||||
|
win_rate=wins / trades,
|
||||||
|
total_net_percent=total,
|
||||||
|
average_net_percent=average,
|
||||||
|
max_drawdown_percent=drawdown,
|
||||||
|
profit_factor=profit_factor,
|
||||||
|
score=score,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
results.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
item.score,
|
||||||
|
item.average_net_percent,
|
||||||
|
item.total_net_percent,
|
||||||
|
item.profit_factor,
|
||||||
|
item.trades,
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _result_from_dict(value: dict[str, Any]) -> CalibrationResult:
|
||||||
|
return CalibrationResult(
|
||||||
|
edge=float(value.get("edge", 0.1) or 0.1),
|
||||||
|
probability=float(value.get("probability", 0.7) or 0.7),
|
||||||
|
confidence=float(value.get("confidence", 0.4) or 0.4),
|
||||||
|
trades=int(value.get("trades", 0) or 0),
|
||||||
|
wins=int(value.get("wins", 0) or 0),
|
||||||
|
win_rate=float(value.get("win_rate", 0.0) or 0.0),
|
||||||
|
total_net_percent=float(value.get("total_net_percent", 0.0) or 0.0),
|
||||||
|
average_net_percent=float(value.get("average_net_percent", 0.0) or 0.0),
|
||||||
|
max_drawdown_percent=float(value.get("max_drawdown_percent", 0.0) or 0.0),
|
||||||
|
profit_factor=float(value.get("profit_factor", 0.0) or 0.0),
|
||||||
|
score=float(value.get("score", 0.0) or 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _selected_trades(
|
def _selected_trades(
|
||||||
records: list[ForecastRecord],
|
records: list[ForecastRecord],
|
||||||
edge: float,
|
edge: float,
|
||||||
@@ -1116,7 +1450,7 @@ def _selected_trades(
|
|||||||
return trades
|
return trades
|
||||||
|
|
||||||
|
|
||||||
def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int) -> CalibrationResult:
|
def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int) -> CalibrationResult | None:
|
||||||
viable = [
|
viable = [
|
||||||
result
|
result
|
||||||
for result in results
|
for result in results
|
||||||
@@ -1125,7 +1459,73 @@ def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int)
|
|||||||
and result.total_net_percent > 0
|
and result.total_net_percent > 0
|
||||||
and result.profit_factor >= 1.05
|
and result.profit_factor >= 1.05
|
||||||
]
|
]
|
||||||
return viable[0] if viable else results[0]
|
return viable[0] if viable else None
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_recommendation(
|
||||||
|
edges: list[float], probabilities: list[float], confidences: list[float]
|
||||||
|
) -> CalibrationResult:
|
||||||
|
return CalibrationResult(
|
||||||
|
edge=max(edges or [1.0]),
|
||||||
|
probability=max(probabilities or [0.95]),
|
||||||
|
confidence=max(confidences or [1.0]),
|
||||||
|
trades=0,
|
||||||
|
wins=0,
|
||||||
|
win_rate=0.0,
|
||||||
|
total_net_percent=0.0,
|
||||||
|
average_net_percent=0.0,
|
||||||
|
max_drawdown_percent=0.0,
|
||||||
|
profit_factor=0.0,
|
||||||
|
score=-1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_platt_calibration(records: list[ForecastRecord]) -> dict[str, float]:
|
||||||
|
samples = [
|
||||||
|
(
|
||||||
|
math.log(_clamp(record.probability_up, 1e-5, 1.0 - 1e-5) / (1.0 - _clamp(record.probability_up, 1e-5, 1.0 - 1e-5))),
|
||||||
|
_record_event_target(record),
|
||||||
|
)
|
||||||
|
for record in records
|
||||||
|
]
|
||||||
|
if len(samples) < 30:
|
||||||
|
return {"slope": 1.0, "intercept": 0.0, "samples": float(len(samples))}
|
||||||
|
slope = 1.0
|
||||||
|
intercept = 0.0
|
||||||
|
learning_rate = 0.05
|
||||||
|
for _ in range(300):
|
||||||
|
grad_slope = 0.0
|
||||||
|
grad_intercept = 0.0
|
||||||
|
for logit, target in samples:
|
||||||
|
probability = 1.0 / (1.0 + math.exp(-_clamp(slope * logit + intercept, -30.0, 30.0)))
|
||||||
|
error = probability - target
|
||||||
|
grad_slope += error * logit
|
||||||
|
grad_intercept += error
|
||||||
|
grad_slope = grad_slope / len(samples) + 0.001 * (slope - 1.0)
|
||||||
|
grad_intercept /= len(samples)
|
||||||
|
slope -= learning_rate * grad_slope
|
||||||
|
intercept -= learning_rate * grad_intercept
|
||||||
|
return {"slope": round(slope, 8), "intercept": round(intercept, 8), "samples": float(len(samples))}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_event_target(record: ForecastRecord) -> float:
|
||||||
|
if record.take_profit_first is not None:
|
||||||
|
return 1.0 if record.take_profit_first else 0.0
|
||||||
|
return 1.0 if record.future_net_percent > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_platt_calibration(
|
||||||
|
records: list[ForecastRecord], calibration: dict[str, float]
|
||||||
|
) -> list[ForecastRecord]:
|
||||||
|
slope = float(calibration.get("slope", 1.0))
|
||||||
|
intercept = float(calibration.get("intercept", 0.0))
|
||||||
|
output: list[ForecastRecord] = []
|
||||||
|
for record in records:
|
||||||
|
probability = _clamp(record.probability_up, 1e-5, 1.0 - 1e-5)
|
||||||
|
logit = math.log(probability / (1.0 - probability))
|
||||||
|
calibrated = 1.0 / (1.0 + math.exp(-_clamp(slope * logit + intercept, -30.0, 30.0)))
|
||||||
|
output.append(replace(record, probability_up=calibrated))
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
def _choose_replay_recommendation(
|
def _choose_replay_recommendation(
|
||||||
@@ -1137,8 +1537,10 @@ def _choose_replay_recommendation(
|
|||||||
horizon: int,
|
horizon: int,
|
||||||
round_trip_cost: float,
|
round_trip_cost: float,
|
||||||
settings: Any,
|
settings: Any,
|
||||||
) -> tuple[CalibrationResult, dict[str, Any]]:
|
) -> tuple[CalibrationResult | None, dict[str, Any]]:
|
||||||
fallback = _choose_recommendation(results, min_trades=min_trades)
|
fallback = _choose_recommendation(results, min_trades=min_trades)
|
||||||
|
if fallback is None:
|
||||||
|
return None, {**_stats([]), "trades_detail": [], "symbol_breakdown": []}
|
||||||
fallback_replay = _full_backtest(records, fallback, horizon=horizon, round_trip_cost=round_trip_cost, settings=settings)
|
fallback_replay = _full_backtest(records, fallback, horizon=horizon, round_trip_cost=round_trip_cost, settings=settings)
|
||||||
if min_full_replay_trades <= 0:
|
if min_full_replay_trades <= 0:
|
||||||
return fallback, fallback_replay
|
return fallback, fallback_replay
|
||||||
@@ -1159,7 +1561,7 @@ def _choose_replay_recommendation(
|
|||||||
viable.append((result, replay))
|
viable.append((result, replay))
|
||||||
|
|
||||||
if not viable:
|
if not viable:
|
||||||
return fallback, fallback_replay
|
return None, fallback_replay
|
||||||
viable.sort(
|
viable.sort(
|
||||||
key=lambda item: (
|
key=lambda item: (
|
||||||
item[0].score,
|
item[0].score,
|
||||||
@@ -1210,12 +1612,18 @@ def _artifact_summary(artifact: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"target_horizon": artifact.get("target_horizon"),
|
"target_horizon": artifact.get("target_horizon"),
|
||||||
"target_horizons": artifact.get("target_horizons"),
|
"target_horizons": artifact.get("target_horizons"),
|
||||||
"target_transform": artifact.get("target_transform"),
|
"target_transform": artifact.get("target_transform"),
|
||||||
|
"event_target": artifact.get("event_target"),
|
||||||
|
"target_stop_loss_percent": artifact.get("target_stop_loss_percent"),
|
||||||
|
"target_take_profit_percent": artifact.get("target_take_profit_percent"),
|
||||||
"symbols": {
|
"symbols": {
|
||||||
symbol: {
|
symbol: {
|
||||||
"model": row.get("model"),
|
"model": row.get("model"),
|
||||||
"lookback": row.get("lookback"),
|
"lookback": row.get("lookback"),
|
||||||
"hidden_size": row.get("hidden_size"),
|
"hidden_size": row.get("hidden_size"),
|
||||||
"skill": row.get("skill"),
|
"skill": row.get("skill"),
|
||||||
|
"validation_skill": row.get("validation_skill"),
|
||||||
|
"holdout_skill": row.get("holdout_skill"),
|
||||||
|
"holdout_start_timestamp": row.get("holdout_start_timestamp"),
|
||||||
"directional_accuracy": row.get("directional_accuracy"),
|
"directional_accuracy": row.get("directional_accuracy"),
|
||||||
}
|
}
|
||||||
for symbol, row in (artifact.get("symbols") or {}).items()
|
for symbol, row in (artifact.get("symbols") or {}).items()
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from crypto_spot_bot.storage import Storage
|
||||||
|
|
||||||
|
|
||||||
|
PRESERVED_TABLES = ("positions", "trades", "runtime", "orders")
|
||||||
|
DEFAULT_RECENT_ROWS = {
|
||||||
|
"signals": 5_000,
|
||||||
|
"equity": 5_000,
|
||||||
|
"events": 2_000,
|
||||||
|
"llm_advice": 1_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compact_database(
|
||||||
|
database: Path,
|
||||||
|
*,
|
||||||
|
recent_rows: dict[str, int] | None = None,
|
||||||
|
backup: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
database = database.resolve()
|
||||||
|
if not database.is_file():
|
||||||
|
raise FileNotFoundError(database)
|
||||||
|
limits = dict(DEFAULT_RECENT_ROWS)
|
||||||
|
if recent_rows:
|
||||||
|
limits.update({key: max(0, int(value)) for key, value in recent_rows.items()})
|
||||||
|
temp = database.with_name(database.name + ".compact")
|
||||||
|
backup = (backup or database.with_name(database.name + ".precompact.bak")).resolve()
|
||||||
|
if temp.exists():
|
||||||
|
temp.unlink()
|
||||||
|
if backup.exists():
|
||||||
|
raise FileExistsError(f"backup already exists: {backup}")
|
||||||
|
|
||||||
|
source_bytes = database.stat().st_size
|
||||||
|
Storage(temp)
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
conn = sqlite3.connect(temp)
|
||||||
|
try:
|
||||||
|
conn.execute("PRAGMA foreign_keys=OFF")
|
||||||
|
conn.execute("ATTACH DATABASE ? AS source", (str(database),))
|
||||||
|
for table in PRESERVED_TABLES:
|
||||||
|
counts[table] = _copy_table(conn, table, limit=None)
|
||||||
|
for table, limit in limits.items():
|
||||||
|
counts[table] = _copy_table(conn, table, limit=limit)
|
||||||
|
conn.commit()
|
||||||
|
# Check only the newly built main database. The attached multi-gigabyte
|
||||||
|
# source is preserved as the rollback copy and must not be rescanned here.
|
||||||
|
integrity = str(conn.execute("PRAGMA main.integrity_check").fetchone()[0])
|
||||||
|
if integrity.lower() != "ok":
|
||||||
|
raise RuntimeError(f"compacted database integrity check failed: {integrity}")
|
||||||
|
conn.execute("DETACH DATABASE source")
|
||||||
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
|
conn.execute("PRAGMA journal_mode=DELETE")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
database.replace(backup)
|
||||||
|
temp.replace(database)
|
||||||
|
compacted_bytes = database.stat().st_size
|
||||||
|
return {
|
||||||
|
"database": str(database),
|
||||||
|
"backup": str(backup),
|
||||||
|
"source_bytes": source_bytes,
|
||||||
|
"compacted_bytes": compacted_bytes,
|
||||||
|
"reclaimed_bytes": max(0, source_bytes - compacted_bytes),
|
||||||
|
"rows": counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_table(conn: sqlite3.Connection, table: str, *, limit: int | None) -> int:
|
||||||
|
destination_columns = _columns(conn, "main", table)
|
||||||
|
source_columns = set(_columns(conn, "source", table))
|
||||||
|
columns = [column for column in destination_columns if column in source_columns]
|
||||||
|
if not columns:
|
||||||
|
return 0
|
||||||
|
quoted = ", ".join(f'"{column}"' for column in columns)
|
||||||
|
if limit is None:
|
||||||
|
conn.execute(
|
||||||
|
f'INSERT INTO main."{table}" ({quoted}) SELECT {quoted} FROM source."{table}"'
|
||||||
|
)
|
||||||
|
elif limit > 0:
|
||||||
|
conn.execute(
|
||||||
|
f'INSERT INTO main."{table}" ({quoted}) '
|
||||||
|
f'SELECT {quoted} FROM source."{table}" ORDER BY id DESC LIMIT ?',
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
row = conn.execute(f'SELECT COUNT(*) FROM main."{table}"').fetchone()
|
||||||
|
return int(row[0] if row else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(conn: sqlite3.Connection, schema: str, table: str) -> list[str]:
|
||||||
|
return [str(row[1]) for row in conn.execute(f'PRAGMA {schema}.table_info("{table}")')]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Atomically compact the TradeBot runtime database while preserving durable trading state."
|
||||||
|
)
|
||||||
|
parser.add_argument("--database", required=True)
|
||||||
|
parser.add_argument("--backup", default="")
|
||||||
|
parser.add_argument("--signals", type=int, default=DEFAULT_RECENT_ROWS["signals"])
|
||||||
|
parser.add_argument("--equity", type=int, default=DEFAULT_RECENT_ROWS["equity"])
|
||||||
|
parser.add_argument("--events", type=int, default=DEFAULT_RECENT_ROWS["events"])
|
||||||
|
parser.add_argument("--llm-advice", type=int, default=DEFAULT_RECENT_ROWS["llm_advice"])
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = _parse_args()
|
||||||
|
result = compact_database(
|
||||||
|
Path(args.database),
|
||||||
|
backup=Path(args.backup) if args.backup else None,
|
||||||
|
recent_rows={
|
||||||
|
"signals": args.signals,
|
||||||
|
"equity": args.equity,
|
||||||
|
"events": args.events,
|
||||||
|
"llm_advice": args.llm_advice,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,6 +6,7 @@ param(
|
|||||||
[int]$PollSeconds = 10,
|
[int]$PollSeconds = 10,
|
||||||
[int]$WatchdogMinutes = 5,
|
[int]$WatchdogMinutes = 5,
|
||||||
[string]$RepoRoot = "",
|
[string]$RepoRoot = "",
|
||||||
|
[string]$CredentialPath = "",
|
||||||
[switch]$StartNow,
|
[switch]$StartNow,
|
||||||
[switch]$KeepLegacyRetrainer
|
[switch]$KeepLegacyRetrainer
|
||||||
)
|
)
|
||||||
@@ -15,71 +16,78 @@ $ErrorActionPreference = "Stop"
|
|||||||
if (-not $RepoRoot) {
|
if (-not $RepoRoot) {
|
||||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||||
}
|
}
|
||||||
$Agent = Join-Path $RepoRoot "tools\windows_training_agent.py"
|
if (-not $CredentialPath) {
|
||||||
if (-not (Test-Path $Agent)) {
|
$CredentialPath = Join-Path $env:LOCALAPPDATA "TradeBot\training-agent.token"
|
||||||
throw "Windows training agent not found: $Agent"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Resolve-Python {
|
$runner = Join-Path $RepoRoot "tools\run_windows_training_agent.ps1"
|
||||||
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
if (-not (Test-Path -LiteralPath $runner)) {
|
||||||
if (Test-Path $venvPython) {
|
throw "Windows training agent runner not found: $runner"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$credentialDirectory = Split-Path -Parent $CredentialPath
|
||||||
|
New-Item -ItemType Directory -Path $credentialDirectory -Force | Out-Null
|
||||||
if ($ApiAuth) {
|
if ($ApiAuth) {
|
||||||
[Environment]::SetEnvironmentVariable("TRADEBOT_API_AUTH", $ApiAuth, "User")
|
$ApiAuth.Trim() |
|
||||||
$env:TRADEBOT_API_AUTH = $ApiAuth
|
ConvertTo-SecureString -AsPlainText -Force |
|
||||||
|
ConvertFrom-SecureString |
|
||||||
|
Set-Content -LiteralPath $CredentialPath -Encoding UTF8
|
||||||
}
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $CredentialPath)) {
|
||||||
|
throw "ApiAuth is required for the first installation."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove the legacy plaintext secret from the user environment. The new runner
|
||||||
|
# decrypts the DPAPI-protected credential only inside the agent process tree.
|
||||||
|
[Environment]::SetEnvironmentVariable("TRADEBOT_API_AUTH", $null, "User")
|
||||||
|
Remove-Item Env:TRADEBOT_API_AUTH -ErrorAction SilentlyContinue
|
||||||
[Environment]::SetEnvironmentVariable("TRADEBOT_API_BASE_URL", $ApiBaseUrl, "User")
|
[Environment]::SetEnvironmentVariable("TRADEBOT_API_BASE_URL", $ApiBaseUrl, "User")
|
||||||
[Environment]::SetEnvironmentVariable("TRADEBOT_TRAINING_WORKER_NAME", $env:COMPUTERNAME, "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) {
|
if (-not $KeepLegacyRetrainer) {
|
||||||
foreach ($legacyName in @("TradeBot PyTorch Forecaster Retrainer", "TradeBot LSTM Retrainer")) {
|
foreach ($legacyName in @("TradeBot PyTorch Forecaster Retrainer", "TradeBot LSTM Retrainer")) {
|
||||||
|
try {
|
||||||
$legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue
|
$legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue
|
||||||
if ($legacyTask) {
|
if ($legacyTask) {
|
||||||
Unregister-ScheduledTask -TaskName $legacyName -Confirm:$false
|
Unregister-ScheduledTask -TaskName $legacyName -Confirm:$false
|
||||||
Write-Host "Removed legacy scheduled task '$legacyName'."
|
Write-Host "Removed legacy scheduled task '$legacyName'."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not remove legacy scheduled task '$legacyName': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$python = Resolve-WindowlessPython
|
|
||||||
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||||
$arguments = @(
|
$principal = New-Object System.Security.Principal.WindowsPrincipal(
|
||||||
"-u",
|
[System.Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
"`"$Agent`"",
|
)
|
||||||
"--repo-root", "`"$RepoRoot`"",
|
$isAdministrator = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
"--api-base-url", "`"$ApiBaseUrl`"",
|
$powershell = (Get-Command powershell.exe -ErrorAction Stop).Source
|
||||||
"--poll-seconds", $PollSeconds.ToString()
|
$runnerArguments = @(
|
||||||
|
"-NoProfile",
|
||||||
|
"-WindowStyle", "Hidden",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", "`"$runner`"",
|
||||||
|
"-RepoRoot", "`"$RepoRoot`"",
|
||||||
|
"-ApiBaseUrl", "`"$ApiBaseUrl`"",
|
||||||
|
"-CredentialPath", "`"$CredentialPath`"",
|
||||||
|
"-WorkerName", "`"$env:COMPUTERNAME`"",
|
||||||
|
"-PollSeconds", $PollSeconds.ToString()
|
||||||
) -join " "
|
) -join " "
|
||||||
|
|
||||||
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $RepoRoot
|
$startupShortcut = Join-Path ([Environment]::GetFolderPath("Startup")) "$TaskName.lnk"
|
||||||
$trigger = @(
|
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
|
||||||
|
Remove-ItemProperty -Path $runKey -Name "TradeBotWindowsTrainingAgent" -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$installMode = "startup shortcut"
|
||||||
|
if ($isAdministrator) {
|
||||||
|
if (Test-Path -LiteralPath $startupShortcut) {
|
||||||
|
Remove-Item -LiteralPath $startupShortcut -Force
|
||||||
|
}
|
||||||
|
$action = New-ScheduledTaskAction -Execute $powershell -Argument $runnerArguments -WorkingDirectory $RepoRoot
|
||||||
|
$trigger = @(
|
||||||
New-ScheduledTaskTrigger -AtLogOn -User $currentUser
|
New-ScheduledTaskTrigger -AtLogOn -User $currentUser
|
||||||
New-ScheduledTaskTrigger -AtStartup
|
New-ScheduledTaskTrigger -AtStartup
|
||||||
New-ScheduledTaskTrigger `
|
New-ScheduledTaskTrigger `
|
||||||
@@ -87,12 +95,12 @@ $trigger = @(
|
|||||||
-At (Get-Date).AddMinutes(1) `
|
-At (Get-Date).AddMinutes(1) `
|
||||||
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
|
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
|
||||||
-RepetitionDuration (New-TimeSpan -Days 3650)
|
-RepetitionDuration (New-TimeSpan -Days 3650)
|
||||||
)
|
)
|
||||||
$principal = New-ScheduledTaskPrincipal `
|
$taskPrincipal = New-ScheduledTaskPrincipal `
|
||||||
-UserId $currentUser `
|
-UserId $currentUser `
|
||||||
-LogonType Interactive `
|
-LogonType Interactive `
|
||||||
-RunLevel Limited
|
-RunLevel Limited
|
||||||
$settings = New-ScheduledTaskSettingsSet `
|
$settings = New-ScheduledTaskSettingsSet `
|
||||||
-StartWhenAvailable `
|
-StartWhenAvailable `
|
||||||
-MultipleInstances IgnoreNew `
|
-MultipleInstances IgnoreNew `
|
||||||
-AllowStartIfOnBatteries `
|
-AllowStartIfOnBatteries `
|
||||||
@@ -101,19 +109,60 @@ $settings = New-ScheduledTaskSettingsSet `
|
|||||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||||
-ExecutionTimeLimit (New-TimeSpan -Days 30)
|
-ExecutionTimeLimit (New-TimeSpan -Days 30)
|
||||||
|
|
||||||
Register-ScheduledTask `
|
Register-ScheduledTask `
|
||||||
-TaskName $TaskName `
|
-TaskName $TaskName `
|
||||||
-Action $action `
|
-Action $action `
|
||||||
-Trigger $trigger `
|
-Trigger $trigger `
|
||||||
-Principal $principal `
|
-Principal $taskPrincipal `
|
||||||
-Settings $settings `
|
-Settings $settings `
|
||||||
-Description "Keeps the TradeBot Windows training agent online and polls the public bot API for retrain jobs." `
|
-Description "Keeps the TradeBot Windows training agent online and polls the bot API for retrain jobs." `
|
||||||
-Force | Out-Null
|
-Force | Out-Null
|
||||||
|
$installMode = "scheduled task"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
if ($existingTask) {
|
||||||
|
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not remove an existing elevated task: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
if ($StartNow) {
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
Start-ScheduledTask -TaskName $TaskName
|
$shortcut = $shell.CreateShortcut($startupShortcut)
|
||||||
|
$shortcut.TargetPath = $powershell
|
||||||
|
$shortcut.Arguments = $runnerArguments
|
||||||
|
$shortcut.WorkingDirectory = $RepoRoot
|
||||||
|
$shortcut.WindowStyle = 7
|
||||||
|
$shortcut.Description = "TradeBot Windows Training Agent"
|
||||||
|
$shortcut.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Registered scheduled task '$TaskName' for Windows startup, logon, and watchdog restarts."
|
Get-CimInstance Win32_Process |
|
||||||
|
Where-Object {
|
||||||
|
$_.ProcessId -ne $PID -and
|
||||||
|
$_.CommandLine -and
|
||||||
|
($_.CommandLine -match [regex]::Escape("windows_training_agent.py") -or
|
||||||
|
$_.CommandLine -match [regex]::Escape("run_windows_training_agent.ps1"))
|
||||||
|
} |
|
||||||
|
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
|
if ($StartNow) {
|
||||||
|
if ($installMode -eq "scheduled task") {
|
||||||
|
Start-ScheduledTask -TaskName $TaskName
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Start-Process `
|
||||||
|
-FilePath $powershell `
|
||||||
|
-ArgumentList $runnerArguments `
|
||||||
|
-WorkingDirectory $RepoRoot `
|
||||||
|
-WindowStyle Hidden | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Installed '$TaskName' using $installMode."
|
||||||
Write-Host "Agent API: $ApiBaseUrl"
|
Write-Host "Agent API: $ApiBaseUrl"
|
||||||
Write-Host "Agent script: $Agent"
|
Write-Host "Encrypted credential: $CredentialPath"
|
||||||
|
Write-Host "Agent runner: $runner"
|
||||||
|
|||||||
+103
-31
@@ -12,8 +12,14 @@ param(
|
|||||||
[string]$Features = "",
|
[string]$Features = "",
|
||||||
[string]$ContextSymbols = "",
|
[string]$ContextSymbols = "",
|
||||||
[int]$Seed = 0,
|
[int]$Seed = 0,
|
||||||
|
[string]$EnsembleSeeds = "",
|
||||||
|
[int]$SelectionFolds = 0,
|
||||||
|
[double]$LearningRate = 0,
|
||||||
|
[double]$WeightDecay = 0,
|
||||||
[int]$Epochs = 0,
|
[int]$Epochs = 0,
|
||||||
[int]$Patience = 0,
|
[int]$Patience = 0,
|
||||||
|
[int]$ValidationWindow = 0,
|
||||||
|
[int]$HoldoutWindow = 0,
|
||||||
[string]$Interval = "",
|
[string]$Interval = "",
|
||||||
[string]$EnvFile = "",
|
[string]$EnvFile = "",
|
||||||
[switch]$DeployToPi,
|
[switch]$DeployToPi,
|
||||||
@@ -22,7 +28,9 @@ param(
|
|||||||
[string]$PiRoot = "",
|
[string]$PiRoot = "",
|
||||||
[string]$PiSshKeyPath = "",
|
[string]$PiSshKeyPath = "",
|
||||||
[switch]$NoPiRestart,
|
[switch]$NoPiRestart,
|
||||||
[switch]$SkipGuard
|
[switch]$Pooled,
|
||||||
|
[switch]$SkipGuard,
|
||||||
|
[switch]$ResumeCandidate
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -38,6 +46,30 @@ function Write-RetrainLog {
|
|||||||
"[$timestamp] $Message" | Tee-Object -FilePath $LogFile -Append
|
"[$timestamp] $Message" | Tee-Object -FilePath $LogFile -Append
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Invoke-LoggedNativeCommand {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[object[]]$ArgumentList,
|
||||||
|
[string]$LogPath
|
||||||
|
)
|
||||||
|
|
||||||
|
# Windows PowerShell converts redirected native stderr into PowerShell error
|
||||||
|
# records. With the script-wide Stop preference an expected non-zero exit
|
||||||
|
# would jump to catch before callers can inspect LASTEXITCODE.
|
||||||
|
$previousErrorActionPreference = $ErrorActionPreference
|
||||||
|
try {
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
& $FilePath @ArgumentList 2>&1 |
|
||||||
|
Tee-Object -FilePath $LogPath -Append |
|
||||||
|
Out-Host
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$ErrorActionPreference = $previousErrorActionPreference
|
||||||
|
}
|
||||||
|
return [int]$exitCode
|
||||||
|
}
|
||||||
|
|
||||||
function Resolve-Python {
|
function Resolve-Python {
|
||||||
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
||||||
if (Test-Path $venvPython) {
|
if (Test-Path $venvPython) {
|
||||||
@@ -106,20 +138,26 @@ function Sync-AcceptedArtifactsToPi {
|
|||||||
|
|
||||||
if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS }
|
if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS }
|
||||||
if ($Limit -le 0) {
|
if ($Limit -le 0) {
|
||||||
$Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 3000 }
|
$Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 6000 }
|
||||||
}
|
}
|
||||||
if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "64" } }
|
if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "32,64,128" } }
|
||||||
if (-not $Architectures) { $Architectures = if ($env:TORCH_RETRAIN_ARCHITECTURES) { $env:TORCH_RETRAIN_ARCHITECTURES } else { "lstm,gru" } }
|
if (-not $Architectures) { $Architectures = if ($env:TORCH_RETRAIN_ARCHITECTURES) { $env:TORCH_RETRAIN_ARCHITECTURES } else { "lstm,gru" } }
|
||||||
if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "64,96" } }
|
if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "64,96" } }
|
||||||
if (-not $Layers) { $Layers = if ($env:TORCH_RETRAIN_LAYERS) { $env:TORCH_RETRAIN_LAYERS } else { "2" } }
|
if (-not $Layers) { $Layers = if ($env:TORCH_RETRAIN_LAYERS) { $env:TORCH_RETRAIN_LAYERS } else { "2" } }
|
||||||
if (-not $Dropouts) { $Dropouts = if ($env:TORCH_RETRAIN_DROPOUTS) { $env:TORCH_RETRAIN_DROPOUTS } else { "0.15" } }
|
if (-not $Dropouts) { $Dropouts = if ($env:TORCH_RETRAIN_DROPOUTS) { $env:TORCH_RETRAIN_DROPOUTS } else { "0.20" } }
|
||||||
if ($Horizon -le 0 -and $env:TORCH_RETRAIN_HORIZON) { $Horizon = [int]$env:TORCH_RETRAIN_HORIZON }
|
if ($Horizon -le 0) { $Horizon = if ($env:TORCH_RETRAIN_HORIZON) { [int]$env:TORCH_RETRAIN_HORIZON } else { 12 } }
|
||||||
if (-not $Horizons -and $env:TORCH_RETRAIN_HORIZONS) { $Horizons = $env:TORCH_RETRAIN_HORIZONS }
|
if (-not $Horizons) { $Horizons = if ($env:TORCH_RETRAIN_HORIZONS) { $env:TORCH_RETRAIN_HORIZONS } else { "3,6,12,24" } }
|
||||||
if (-not $Features -and $env:TORCH_RETRAIN_FEATURES) { $Features = $env:TORCH_RETRAIN_FEATURES }
|
if (-not $Features -and $env:TORCH_RETRAIN_FEATURES) { $Features = $env:TORCH_RETRAIN_FEATURES }
|
||||||
if (-not $ContextSymbols -and $env:TORCH_RETRAIN_CONTEXT_SYMBOLS) { $ContextSymbols = $env:TORCH_RETRAIN_CONTEXT_SYMBOLS }
|
if (-not $ContextSymbols -and $env:TORCH_RETRAIN_CONTEXT_SYMBOLS) { $ContextSymbols = $env:TORCH_RETRAIN_CONTEXT_SYMBOLS }
|
||||||
if ($Seed -le 0 -and $env:TORCH_RETRAIN_SEED) { $Seed = [int]$env:TORCH_RETRAIN_SEED }
|
if ($Seed -le 0 -and $env:TORCH_RETRAIN_SEED) { $Seed = [int]$env:TORCH_RETRAIN_SEED }
|
||||||
|
if (-not $EnsembleSeeds) { $EnsembleSeeds = if ($env:TORCH_RETRAIN_ENSEMBLE_SEEDS) { $env:TORCH_RETRAIN_ENSEMBLE_SEEDS } else { "7,19" } }
|
||||||
|
if ($SelectionFolds -le 0) { $SelectionFolds = if ($env:TORCH_RETRAIN_SELECTION_FOLDS) { [int]$env:TORCH_RETRAIN_SELECTION_FOLDS } else { 3 } }
|
||||||
|
if ($LearningRate -le 0) { $LearningRate = if ($env:TORCH_RETRAIN_LEARNING_RATE) { [double]$env:TORCH_RETRAIN_LEARNING_RATE } else { 0.0007 } }
|
||||||
|
if ($WeightDecay -le 0) { $WeightDecay = if ($env:TORCH_RETRAIN_WEIGHT_DECAY) { [double]$env:TORCH_RETRAIN_WEIGHT_DECAY } else { 0.0005 } }
|
||||||
if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } }
|
if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } }
|
||||||
if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } }
|
if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } }
|
||||||
|
if ($ValidationWindow -le 0) { $ValidationWindow = if ($env:TORCH_RETRAIN_VALIDATION_WINDOW) { [int]$env:TORCH_RETRAIN_VALIDATION_WINDOW } else { 720 } }
|
||||||
|
if ($HoldoutWindow -le 0) { $HoldoutWindow = if ($env:TORCH_RETRAIN_HOLDOUT_WINDOW) { [int]$env:TORCH_RETRAIN_HOLDOUT_WINDOW } else { 1000 } }
|
||||||
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
|
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
|
||||||
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
|
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
|
||||||
if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" }
|
if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" }
|
||||||
@@ -154,8 +192,20 @@ try {
|
|||||||
"--dropouts", $Dropouts,
|
"--dropouts", $Dropouts,
|
||||||
"--epochs", $Epochs.ToString(),
|
"--epochs", $Epochs.ToString(),
|
||||||
"--patience", $Patience.ToString(),
|
"--patience", $Patience.ToString(),
|
||||||
|
"--validation-window", $ValidationWindow.ToString(),
|
||||||
|
"--holdout-window", $HoldoutWindow.ToString(),
|
||||||
|
"--ensemble-seeds", $EnsembleSeeds,
|
||||||
|
"--selection-folds", $SelectionFolds.ToString(),
|
||||||
|
"--learning-rate", $LearningRate.ToString([Globalization.CultureInfo]::InvariantCulture),
|
||||||
|
"--weight-decay", $WeightDecay.ToString([Globalization.CultureInfo]::InvariantCulture),
|
||||||
"--output", $CandidateFile
|
"--output", $CandidateFile
|
||||||
)
|
)
|
||||||
|
if ($Pooled) {
|
||||||
|
$trainerArgs += "--pooled"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$trainerArgs += "--no-pooled"
|
||||||
|
}
|
||||||
if ($Symbols) { $trainerArgs += @("--symbols", $Symbols) }
|
if ($Symbols) { $trainerArgs += @("--symbols", $Symbols) }
|
||||||
if ($Interval) { $trainerArgs += @("--interval", $Interval) }
|
if ($Interval) { $trainerArgs += @("--interval", $Interval) }
|
||||||
if ($EnvFile) { $trainerArgs += @("--env", $EnvFile) }
|
if ($EnvFile) { $trainerArgs += @("--env", $EnvFile) }
|
||||||
@@ -167,9 +217,15 @@ try {
|
|||||||
|
|
||||||
Push-Location $RepoRoot
|
Push-Location $RepoRoot
|
||||||
$pushedLocation = $true
|
$pushedLocation = $true
|
||||||
|
if ($ResumeCandidate) {
|
||||||
|
if (-not (Test-TorchArtifactFile $CandidateFile)) {
|
||||||
|
throw "ResumeCandidate requested, but no valid candidate artifact exists: $CandidateFile"
|
||||||
|
}
|
||||||
|
Write-RetrainLog "Resuming guard from existing candidate artifact: $CandidateFile"
|
||||||
|
}
|
||||||
|
else {
|
||||||
Write-RetrainLog "Starting PyTorch recurrent retrain: $python $($trainerArgs -join ' ')"
|
Write-RetrainLog "Starting PyTorch recurrent retrain: $python $($trainerArgs -join ' ')"
|
||||||
& $python @trainerArgs 2>&1 | Tee-Object -FilePath $LogFile -Append
|
$trainerExitCode = Invoke-LoggedNativeCommand -FilePath $python -ArgumentList $trainerArgs -LogPath $LogFile
|
||||||
$trainerExitCode = $LASTEXITCODE
|
|
||||||
if ($trainerExitCode -ne 0) {
|
if ($trainerExitCode -ne 0) {
|
||||||
if (Test-TorchArtifactFile $CandidateFile) {
|
if (Test-TorchArtifactFile $CandidateFile) {
|
||||||
Write-RetrainLog "WARNING: Trainer exited with code $trainerExitCode after writing a valid candidate artifact; continuing to guard."
|
Write-RetrainLog "WARNING: Trainer exited with code $trainerExitCode after writing a valid candidate artifact; continuing to guard."
|
||||||
@@ -179,51 +235,67 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Write-RetrainLog "Finished PyTorch recurrent retrain candidate: $CandidateFile"
|
Write-RetrainLog "Finished PyTorch recurrent retrain candidate: $CandidateFile"
|
||||||
|
}
|
||||||
|
|
||||||
if ($SkipGuard -or -not (Test-Path $ModelFile)) {
|
if ($SkipGuard) {
|
||||||
Move-Item -Force -LiteralPath $CandidateFile -Destination $ModelFile
|
throw "SkipGuard is disabled: every candidate must pass untouched-holdout validation."
|
||||||
Write-RetrainLog "Accepted candidate without guard. Active artifact: $ModelFile"
|
|
||||||
Sync-AcceptedArtifactsToPi
|
|
||||||
exit 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$calibrationBaseArgs = @(
|
$calibrationBaseArgs = @(
|
||||||
"-u",
|
"-u",
|
||||||
"tools\calibrate_torch_thresholds.py",
|
"tools\calibrate_torch_thresholds.py",
|
||||||
"--limit", "3000",
|
"--limit", $Limit.ToString(),
|
||||||
"--calibration-window", "1200",
|
"--horizon", $Horizon.ToString(),
|
||||||
"--min-trades", "60",
|
"--calibration-window", ([Math]::Min(2400, [Math]::Max(1200, [int]($Limit / 2)))).ToString(),
|
||||||
|
"--min-trades", "24",
|
||||||
"--walk-forward-folds", "8",
|
"--walk-forward-folds", "8",
|
||||||
"--confidence-grid", "0.40"
|
"--confidence-grid", "0.40"
|
||||||
)
|
)
|
||||||
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
|
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
|
||||||
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
|
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
|
||||||
|
|
||||||
|
if (Test-Path $ModelFile) {
|
||||||
Write-RetrainLog "Calibrating current artifact for guard."
|
Write-RetrainLog "Calibrating current artifact for guard."
|
||||||
& $python @($calibrationBaseArgs + @("--artifact", $ModelFile, "--output", $CurrentCalibration)) 2>&1 | Tee-Object -FilePath $LogFile -Append
|
$currentCalibrationExitCode = Invoke-LoggedNativeCommand `
|
||||||
if ($LASTEXITCODE -ne 0) {
|
-FilePath $python `
|
||||||
throw "Current artifact calibration failed with exit code $LASTEXITCODE."
|
-ArgumentList ($calibrationBaseArgs + @("--artifact", $ModelFile, "--output", $CurrentCalibration)) `
|
||||||
|
-LogPath $LogFile
|
||||||
|
if ($currentCalibrationExitCode -ne 0) {
|
||||||
|
Write-RetrainLog "Current artifact has no compatible untouched holdout; comparing candidate against an empty current report."
|
||||||
|
"{}" | Set-Content -LiteralPath $CurrentCalibration -Encoding utf8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-RetrainLog "No active artifact yet; candidate still must pass the full guard."
|
||||||
|
"{}" | Set-Content -LiteralPath $CurrentCalibration -Encoding utf8
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-RetrainLog "Calibrating candidate artifact for guard."
|
Write-RetrainLog "Calibrating candidate artifact for guard."
|
||||||
& $python @($calibrationBaseArgs + @("--artifact", $CandidateFile, "--output", $CandidateCalibration)) 2>&1 | Tee-Object -FilePath $LogFile -Append
|
$candidateCalibrationExitCode = Invoke-LoggedNativeCommand `
|
||||||
if ($LASTEXITCODE -ne 0) {
|
-FilePath $python `
|
||||||
throw "Candidate artifact calibration failed with exit code $LASTEXITCODE."
|
-ArgumentList ($calibrationBaseArgs + @("--artifact", $CandidateFile, "--output", $CandidateCalibration)) `
|
||||||
|
-LogPath $LogFile
|
||||||
|
if ($candidateCalibrationExitCode -ne 0) {
|
||||||
|
throw "Candidate artifact calibration failed with exit code $candidateCalibrationExitCode."
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-RetrainLog "Running retrain guard."
|
Write-RetrainLog "Running retrain guard."
|
||||||
& $python -u "tools\accept_torch_candidate.py" `
|
$guardArgs = @(
|
||||||
--current-report $CurrentCalibration `
|
"-u",
|
||||||
--candidate-report $CandidateCalibration `
|
"tools\accept_torch_candidate.py",
|
||||||
--candidate-artifact $CandidateFile `
|
"--current-report", $CurrentCalibration,
|
||||||
--target-artifact $ModelFile `
|
"--candidate-report", $CandidateCalibration,
|
||||||
--report $GuardReport 2>&1 | Tee-Object -FilePath $LogFile -Append
|
"--candidate-artifact", $CandidateFile,
|
||||||
if ($LASTEXITCODE -eq 2) {
|
"--target-artifact", $ModelFile,
|
||||||
|
"--report", $GuardReport
|
||||||
|
)
|
||||||
|
$guardExitCode = Invoke-LoggedNativeCommand -FilePath $python -ArgumentList $guardArgs -LogPath $LogFile
|
||||||
|
if ($guardExitCode -eq 2) {
|
||||||
Write-RetrainLog "Candidate rejected by guard; keeping active artifact: $ModelFile"
|
Write-RetrainLog "Candidate rejected by guard; keeping active artifact: $ModelFile"
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($guardExitCode -ne 0) {
|
||||||
throw "Retrain guard failed with exit code $LASTEXITCODE."
|
throw "Retrain guard failed with exit code $guardExitCode."
|
||||||
}
|
}
|
||||||
if (Test-Path $CandidateCalibration) {
|
if (Test-Path $CandidateCalibration) {
|
||||||
Copy-Item -Force -LiteralPath $CandidateCalibration -Destination (Join-Path $RuntimeDir "torch_threshold_calibration.json")
|
Copy-Item -Force -LiteralPath $CandidateCalibration -Destination (Join-Path $RuntimeDir "torch_threshold_calibration.json")
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ApiBaseUrl = "https://tb.kusoft.xyz",
|
||||||
|
[string]$RepoRoot = "",
|
||||||
|
[string]$CredentialPath = "",
|
||||||
|
[string]$WorkerName = $env:COMPUTERNAME,
|
||||||
|
[int]$PollSeconds = 10,
|
||||||
|
[int]$RestartDelaySeconds = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not $RepoRoot) {
|
||||||
|
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||||
|
}
|
||||||
|
if (-not $CredentialPath) {
|
||||||
|
$CredentialPath = Join-Path $env:LOCALAPPDATA "TradeBot\training-agent.token"
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = Join-Path $RepoRoot "tools\windows_training_agent.py"
|
||||||
|
if (-not (Test-Path -LiteralPath $agent)) {
|
||||||
|
throw "Windows training agent not found: $agent"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $CredentialPath)) {
|
||||||
|
throw "Encrypted training credential not found: $CredentialPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-Python {
|
||||||
|
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
||||||
|
if (Test-Path -LiteralPath $venvPython) {
|
||||||
|
return $venvPython
|
||||||
|
}
|
||||||
|
|
||||||
|
$userPython = Join-Path $env:LOCALAPPDATA "Programs\TradeBotPython312\python.exe"
|
||||||
|
if (Test-Path -LiteralPath $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."
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdNew = $false
|
||||||
|
$mutex = [System.Threading.Mutex]::new($false, "Local\TradeBotWindowsTrainingAgent", [ref]$createdNew)
|
||||||
|
if (-not $createdNew) {
|
||||||
|
$mutex.Dispose()
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$encryptedToken = (Get-Content -LiteralPath $CredentialPath -Raw -Encoding UTF8).Trim()
|
||||||
|
$secureToken = $encryptedToken | ConvertTo-SecureString
|
||||||
|
$tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
|
||||||
|
try {
|
||||||
|
$env:TRADEBOT_API_AUTH = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer)
|
||||||
|
$python = Resolve-Python
|
||||||
|
$workerId = "${WorkerName}:$RepoRoot"
|
||||||
|
$arguments = @(
|
||||||
|
"-u",
|
||||||
|
$agent,
|
||||||
|
"--repo-root", $RepoRoot,
|
||||||
|
"--api-base-url", $ApiBaseUrl,
|
||||||
|
"--worker-id", $workerId,
|
||||||
|
"--worker-name", $WorkerName,
|
||||||
|
"--poll-seconds", [Math]::Max(5, $PollSeconds).ToString()
|
||||||
|
)
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
& $python @arguments
|
||||||
|
Start-Sleep -Seconds ([Math]::Max(5, $RestartDelaySeconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$env:TRADEBOT_API_AUTH = $null
|
||||||
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer)
|
||||||
|
$mutex.ReleaseMutex()
|
||||||
|
$mutex.Dispose()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -63,14 +64,20 @@ def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_
|
|||||||
try:
|
try:
|
||||||
run_retrain(args, job_id, job, repo_root, log_path)
|
run_retrain(args, job_id, job, repo_root, log_path)
|
||||||
summary = read_json(runtime_dir / "torch_retrain_guard.json")
|
summary = read_json(runtime_dir / "torch_retrain_guard.json")
|
||||||
|
accepted = summary.get("accepted") is True
|
||||||
|
if accepted:
|
||||||
report_progress(args, job_id, "running", "uploading", 72, "Обучение завершено, загружаю артефакты")
|
report_progress(args, job_id, "running", "uploading", 72, "Обучение завершено, загружаю артефакты")
|
||||||
for name in ARTIFACT_NAMES:
|
for name in ARTIFACT_NAMES:
|
||||||
path = runtime_dir / name
|
path = runtime_dir / name
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
upload_artifact(args, job_id, path, log_path)
|
upload_artifact(args, job_id, path, log_path)
|
||||||
|
message = "training completed; candidate accepted"
|
||||||
|
log(log_path, f"Completed retrain job {job_id}; candidate accepted")
|
||||||
|
else:
|
||||||
|
reason = str(summary.get("reason") or "validation failed")
|
||||||
|
message = f"training completed; candidate rejected by quality gate: {reason}"
|
||||||
|
log(log_path, f"Completed retrain job {job_id}; candidate rejected: {reason}")
|
||||||
success = True
|
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.
|
except Exception as exc: # noqa: BLE001 - report failure to the bot.
|
||||||
message = str(exc)
|
message = str(exc)
|
||||||
log(log_path, f"Job {job_id} failed: {message}")
|
log(log_path, f"Job {job_id} failed: {message}")
|
||||||
@@ -101,11 +108,28 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
|||||||
"layers": "-Layers",
|
"layers": "-Layers",
|
||||||
"dropouts": "-Dropouts",
|
"dropouts": "-Dropouts",
|
||||||
"epochs": "-Epochs",
|
"epochs": "-Epochs",
|
||||||
|
"validation_window": "-ValidationWindow",
|
||||||
|
"holdout_window": "-HoldoutWindow",
|
||||||
|
"ensemble_seeds": "-EnsembleSeeds",
|
||||||
|
"selection_folds": "-SelectionFolds",
|
||||||
|
"learning_rate": "-LearningRate",
|
||||||
|
"weight_decay": "-WeightDecay",
|
||||||
|
"horizon": "-Horizon",
|
||||||
|
"horizons": "-Horizons",
|
||||||
|
"patience": "-Patience",
|
||||||
|
"context_symbols": "-ContextSymbols",
|
||||||
|
"features": "-Features",
|
||||||
|
"seed": "-Seed",
|
||||||
|
"interval": "-Interval",
|
||||||
}
|
}
|
||||||
for key, ps_arg in arg_map.items():
|
for key, ps_arg in arg_map.items():
|
||||||
value = parameters.get(key)
|
value = parameters.get(key)
|
||||||
if value not in (None, ""):
|
if value not in (None, ""):
|
||||||
cmd.extend([ps_arg, str(value)])
|
cmd.extend([ps_arg, str(value)])
|
||||||
|
if parameters.get("pooled") is True:
|
||||||
|
cmd.append("-Pooled")
|
||||||
|
if parameters.get("resume_candidate") is True:
|
||||||
|
cmd.append("-ResumeCandidate")
|
||||||
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
|
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
|
||||||
report_progress(args, job_id, "running", "training", 8, "PyTorch retrain запущен")
|
report_progress(args, job_id, "running", "training", 8, "PyTorch retrain запущен")
|
||||||
line_count = 0
|
line_count = 0
|
||||||
@@ -124,6 +148,7 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
|||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
errors="replace",
|
errors="replace",
|
||||||
|
**hidden_subprocess_kwargs(),
|
||||||
) as process:
|
) as process:
|
||||||
reader = threading.Thread(target=read_output, name="training-output-reader", daemon=True)
|
reader = threading.Thread(target=read_output, name="training-output-reader", daemon=True)
|
||||||
reader.start()
|
reader.start()
|
||||||
@@ -140,7 +165,7 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
|||||||
log(log_path, message)
|
log(log_path, message)
|
||||||
line_count += 1
|
line_count += 1
|
||||||
if message:
|
if message:
|
||||||
last_message = message[-220:]
|
last_message = friendly_training_message(message)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -163,6 +188,78 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
|||||||
report_progress(args, job_id, "running", "guard", 70, "Guard завершён, подготавливаю артефакты")
|
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:
|
def training_heartbeat_message(now: float, started_at: float, last_output_at: float, last_message: str) -> str:
|
||||||
elapsed = format_duration(now - started_at)
|
elapsed = format_duration(now - started_at)
|
||||||
idle_seconds = max(0.0, now - last_output_at)
|
idle_seconds = max(0.0, now - last_output_at)
|
||||||
@@ -313,6 +410,18 @@ def read_json(path: Path) -> dict[str, Any]:
|
|||||||
return data if isinstance(data, dict) else {}
|
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:
|
def quote_for_log(value: str) -> str:
|
||||||
return f'"{value}"' if " " in value else value
|
return f'"{value}"' if " " in value else value
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user