Compare commits

..
27 Commits
Author SHA1 Message Date
Курнат Андрей 3ce92b6428 fix: make dashboard login responsive 2026-07-19 22:56:19 +03:00
Курнат Андрей 393454c9e0 fix: expose dashboard login shell 2026-07-19 22:20:14 +03:00
Курнат Андрей 0ec64645b6 fix: route web controls through proxy auth 2026-07-19 22:09:59 +03:00
Курнат Андрей be5c9d482a feat: add TradeBot web control panel 2026-07-19 22:00:23 +03:00
Курнат Андрей 991b77351c feat: enforce profit-only spot exits 2026-07-15 20:23:31 +03:00
Курнат Андрей 5082be2e5a feat: auto-queue orderbook retrain at coverage gate 2026-07-15 09:50:57 +03:00
Курнат Андрей 0992da0ece chore: bump training agent protocol version 2026-07-15 09:47:09 +03:00
Курнат Андрей 5d8ad1437e feat: add orderbook shadow training pipeline 2026-07-15 09:44:29 +03:00
Курнат Андрей f7a625586e fix: initialize adaptive rules for legacy exits 2026-07-15 00:40:45 +03:00
Курнат Андрей 2967cd607c feat: collect Bybit orderbook observations for training 2026-07-15 00:36:56 +03:00
Курнат Андрей d0869b5d29 fix: keep paper trading active without approved model 2026-07-15 00:23:03 +03:00
Курнат Андрей 1f2fb011a7 fix: honor explicit calibration horizon 2026-07-14 23:58:05 +03:00
Курнат Андрей e1a42a9011 fix: align pooled symbol features at training 2026-07-14 23:36:04 +03:00
Курнат Андрей 51a7833896 fix: calibrate dynamic model symbols 2026-07-14 23:28:53 +03:00
Курнат Андрей 1c7701c38e fix: preserve mobile auth form while editing 2026-07-14 23:25:43 +03:00
Курнат Андрей 4c347ed425 fix: preserve remote reverse proxy route 2026-07-14 22:59:24 +03:00
Курнат Андрей 5c4aecfe5f feat: production paper trading platform 2026-07-14 22:52:47 +03:00
Курнат Андрей 7186acb9a1 feat: train forecasts on trade outcomes 2026-07-14 07:49:32 +03:00
Курнат Андрей 668e606ee2 Keep bot operational when forecast model is unavailable 2026-07-13 11:57:25 +03:00
Курнат Андрей da53483164 Fix remote training and model validation pipeline 2026-07-12 22:56:49 +03:00
sevenhill 18936cf8b1 Merge pull request 'Harden trading, training, and monitoring' (#1) from codex/tradebot-hardening into main 2026-07-10 14:39:32 +01:00
Codex 069d75d2f2 Harden trading, training, and monitoring 2026-07-10 15:51:53 +03:00
Codex 6fb79ee2a9 Keep open positions carousel stable 2026-07-09 22:50:33 +03:00
Codex 77a063d263 Preserve open positions carousel scroll 2026-07-09 22:46:04 +03:00
Codex e21a96640d Improve Android monitor incremental updates 2026-07-09 22:39:26 +03:00
Codex 7f1ac694e4 Improve Windows training agent progress 2026-07-03 20:44:09 +03:00
Codex 33c3831bf2 Require minimum net profit for forecast exits 2026-07-02 15:35:29 +03:00
85 changed files with 10687 additions and 2332974 deletions
-93
View File
@@ -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
+58 -3
View File
@@ -1,15 +1,23 @@
TRADING_MODE=paper TRADING_MODE=paper
HOST=127.0.0.1 HOST=127.0.0.1
PORT=8787 PORT=8787
# Keep 127.0.0.1 when the reverse proxy is local. This Dell deployment uses
# 0.0.0.0 because Caddy reaches port 8787 from a separate LAN host.
TRADEBOT_BIND_ADDRESS=127.0.0.1
BYBIT_TESTNET=false BYBIT_TESTNET=false
# Official regional public endpoints for this deployment. Before future live
# trading they must match the Bybit site where the API key was created.
BYBIT_REST_BASE_URL=https://api.bybit.kz
BYBIT_WEBSOCKET_URL=wss://stream.bybit.kz/v5/public/spot
BYBIT_API_KEY= BYBIT_API_KEY=
BYBIT_API_SECRET= BYBIT_API_SECRET=
STARTING_BALANCE_USDT=100 STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false AUTO_SELECT_SYMBOLS=true
TOP_SYMBOLS_COUNT=12 TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT # Leave empty to discover the most liquid eligible USDT Spot pairs from Bybit.
SYMBOLS=
STRATEGY_MODE=torch_forecast STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60 BASE_INTERVAL=60
@@ -22,6 +30,8 @@ FAST_LOOP_INTERVAL_SECONDS=1
FAST_ENTRY_COOLDOWN_SECONDS=20 FAST_ENTRY_COOLDOWN_SECONDS=20
MAX_ENTRIES_PER_MINUTE=12 MAX_ENTRIES_PER_MINUTE=12
WEBSOCKET_ENABLED=true WEBSOCKET_ENABLED=true
MARKET_OBSERVATION_ENABLED=true
MARKET_OBSERVATION_SAMPLE_SECONDS=30
MIN_SIGNAL_CONFIDENCE=0.64 MIN_SIGNAL_CONFIDENCE=0.64
MAX_SPREAD_PERCENT=0.18 MAX_SPREAD_PERCENT=0.18
MIN_24H_TURNOVER_USDT=1000000 MIN_24H_TURNOVER_USDT=1000000
@@ -71,11 +81,26 @@ 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_FALLBACK_MODE=legacy
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
STOP_LOSS_EXIT_ENABLED=false
TAKE_PROFIT_PERCENT=0.035 TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015 TRAILING_STOP_PERCENT=0.015
MIN_HOLD_SECONDS=180 MIN_HOLD_SECONDS=180
# Ordinary RSI/EMA/model/exposure exits are only executed when the estimated
# result after entry fee, exit fee, spread and slippage clears this net margin.
PROFIT_ONLY_EXIT_ENABLED=true
MIN_EXIT_NET_PERCENT=0.31
ENTRY_COOLDOWN_SECONDS=180 ENTRY_COOLDOWN_SECONDS=180
MAX_DAILY_DRAWDOWN_USDT=6 MAX_DAILY_DRAWDOWN_USDT=6
MIN_CASH_RESERVE_USDT=5 MIN_CASH_RESERVE_USDT=5
@@ -86,6 +111,36 @@ 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
TORCH_ORDERBOOK_DB=runtime/orderbook_observations.sqlite3
TORCH_ORDERBOOK_MIN_SAMPLES_PER_BUCKET=20
TORCH_ORDERBOOK_MIN_COVERED_BUCKETS=240
TORCH_ORDERBOOK_MIN_SYMBOLS=2
TORCH_ORDERBOOK_AUTO_CHECK_SECONDS=3600
# Forward-only gate for an offline-approved shadow model. Promotion remains an
# explicit authenticated API action after every check has passed.
SHADOW_GATE_MIN_SETTLED=300
SHADOW_GATE_MIN_ELIGIBLE=30
SHADOW_GATE_MIN_SYMBOLS=2
SHADOW_GATE_MIN_PROFIT_FACTOR=1.10
SHADOW_GATE_MIN_DIRECTION_ACCURACY=0.52
SHADOW_GATE_MAX_BRIER=0.25
DATABASE_PATH=runtime/tradebot.sqlite3 DATABASE_PATH=runtime/tradebot.sqlite3
LOG_PATH=runtime/tradebot.log LOG_PATH=runtime/tradebot.log
+12 -5
View File
@@ -1,13 +1,20 @@
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 --disable-pip-version-check --upgrade pip \
&& pip install --no-cache-dir -r /app/requirements.txt && pip install --no-cache-dir --disable-pip-version-check -r /app/requirements.txt \
&& groupadd --gid 1000 tradebot \
&& useradd --uid 1000 --gid tradebot --home-dir /app --shell /usr/sbin/nologin tradebot
COPY crypto_spot_bot /app/crypto_spot_bot COPY --chown=1000:1000 crypto_spot_bot /app/crypto_spot_bot
COPY README.md /app/README.md COPY --chown=1000:1000 README.md /app/README.md
RUN mkdir -p /app/runtime RUN mkdir -p /app/runtime && chown -R 1000:1000 /app/runtime
EXPOSE 8787 EXPOSE 8787
USER 1000:1000
STOPSIGNAL SIGTERM
CMD ["python", "-m", "crypto_spot_bot.main"] CMD ["python", "-m", "crypto_spot_bot.main"]
+74 -28
View File
@@ -1,15 +1,23 @@
# Crypto Spot TradeBot # Crypto Spot TradeBot
Веб-панель управления доступна на корневом адресе сервиса: локально
`http://127.0.0.1:8787/`, в production — `https://tb.kusoft.xyz/`. Панель показывает
готовность торгового контура, капитал, позиции, рынки, сигналы, сделки, события и
состояние модели; из неё можно запускать и останавливать цикл и переключать быстрый
режим. Приватные данные и управляющие действия используют ту же авторизацию, что и API.
Spot-бот для демо-торговли криптовалютой на реальных данных Bybit. По умолчанию работает только в `paper`-режиме со стартовым балансом `100 USDT`; live-режим заблокирован до явного включения через env-переменные. Spot-бот для демо-торговли криптовалютой на реальных данных Bybit. По умолчанию работает только в `paper`-режиме со стартовым балансом `100 USDT`; live-режим заблокирован до явного включения через env-переменные.
## Что реализовано ## Что реализовано
- Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления. - Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления.
- Фиксированный набор 12 USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `HYPEUSDT`, `SOLUSDT`, `XRPUSDT`, `XPLUSDT`, `WLDUSDT`, `MNTUSDT`, `HUSDT`, `XAUTUSDT`, `IPUSDT`, `AAVEUSDT`. - Сэмплированный L1-стакан Bybit сохраняется в SQLite: bid/ask size, spread, imbalance и microprice доступны обучающему агенту через защищённый постраничный API. Частота по умолчанию — один sample на пару каждые 30 секунд, а хранилище ограничено 1 200 000 строк.
- Торговый universe автоматически строится из актуальных Bybit Spot-инструментов: выбираются до 12 ликвидных USDT-пар по `turnover24h`, исключаются stablecoin-to-stablecoin и leveraged-token пары; фиксированный список можно задать только явным `SYMBOLS`.
- 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-модели включает самостоятельную fallback-стратегию. `TIME_SERIES_FALLBACK_MODE=legacy` разрешён только для paper и даёт многорежимные виртуальные входы; live всегда принудительно использует более строгий `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`.
@@ -17,12 +25,14 @@ Spot-бот для демо-торговли криптовалютой на р
- DCA/мартингейл отключены: в режиме `trend_macd` брокер не разрешает вторую позицию по той же паре. - DCA/мартингейл отключены: в режиме `trend_macd` брокер не разрешает вторую позицию по той же паре.
- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`. - Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`.
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются. - Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения. - Защищённый JSON API: equity, cash, PnL, позиции, сделки, сигналы, события, свечи, управление paper-циклом и состоянием обучения.
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для просмотра 12 пар, свечей, Torch/Kelly параметров, расписания удалённого retrain и live-чеклиста. - Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для динамического списка Bybit-пар, свечей, Torch/Kelly параметров, WorkManager-расписания удалённого 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`.
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост. - Все приватные API endpoints требуют токен или подтвержденный reverse-proxy user header; health и metrics остаются доступными для локального мониторинга.
- Hardened Docker Compose для установки на Dell/Linux: non-root user, read-only root filesystem, dropped capabilities, healthcheck и ротация container logs.
- 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-ключей.
- Внешний production endpoint сохраняется на `https://tb.kusoft.xyz`; Caddy завершает TLS и проксирует API к контейнеру на loopback.
## Источники и принятые параметры ## Источники и принятые параметры
@@ -32,6 +42,8 @@ Spot-бот для демо-торговли криптовалютой на р
Популярность пар определяется через `/v5/market/tickers`, потому что Bybit Spot ticker возвращает `turnover24h`, `volume24h`, `bid1Price`, `ask1Price` и `lastPrice`: <https://bybit-exchange.github.io/docs/v5/market/tickers>. Популярность пар определяется через `/v5/market/tickers`, потому что Bybit Spot ticker возвращает `turnover24h`, `volume24h`, `bid1Price`, `ask1Price` и `lastPrice`: <https://bybit-exchange.github.io/docs/v5/market/tickers>.
Для текущего paper/training-развёртывания используется официальный региональный endpoint `api.bybit.kz`; Bybit перечисляет его в Integration Guidance. `BYBIT_REST_BASE_URL` и `BYBIT_WEBSOCKET_URL` остаются явными настройками, потому что перед будущим live-режимом домен обязан соответствовать площадке выпуска API-ключа: <https://bybit-exchange.github.io/docs/v5/guide>.
Лучшие bid/ask берутся из `/v5/market/orderbook`; документация Bybit описывает `GET /v5/market/orderbook` с `category=spot`: <https://bybit-exchange.github.io/docs/v5/market/orderbook>. Лучшие bid/ask берутся из `/v5/market/orderbook`; документация Bybit описывает `GET /v5/market/orderbook` с `category=spot`: <https://bybit-exchange.github.io/docs/v5/market/orderbook>.
WebSocket-стакан использует topic `orderbook.{depth}.{symbol}`; Bybit документирует snapshot/delta-поведение и частоты push для Spot depth 1/50/200/1000: <https://bybit-exchange.github.io/docs/v5/websocket/public/orderbook>. WebSocket-стакан использует topic `orderbook.{depth}.{symbol}`; Bybit документирует snapshot/delta-поведение и частоты push для Spot depth 1/50/200/1000: <https://bybit-exchange.github.io/docs/v5/websocket/public/orderbook>.
@@ -43,7 +55,7 @@ Live market orders используют `/v5/order/create`; Bybit докумен
- Investopedia перечисляет важные свойства algo trading software: real-time market data, low latency, configurability, backtesting, broker/exchange integration, fees/costs и APIs: <https://www.investopedia.com/articles/active-trading/090815/picking-right-algorithmic-trading-software.asp>. - Investopedia перечисляет важные свойства algo trading software: real-time market data, low latency, configurability, backtesting, broker/exchange integration, fees/costs и APIs: <https://www.investopedia.com/articles/active-trading/090815/picking-right-algorithmic-trading-software.asp>.
- Investopedia отдельно указывает, что automated trading systems задают правила entry/exit/money management, но требуют мониторинга и несут риск mechanical failures и over-optimization: <https://www.investopedia.com/articles/trading/11/automated-trading-systems.asp>. - Investopedia отдельно указывает, что automated trading systems задают правила entry/exit/money management, но требуют мониторинга и несут риск mechanical failures и over-optimization: <https://www.investopedia.com/articles/trading/11/automated-trading-systems.asp>.
- QuantInsti описывает типовой путь разработки: стратегия, backtesting, paper trading, затем live trading, плюс GUI, order management и risk management: <https://www.quantinsti.com/articles/automated-trading-system/>. - QuantInsti описывает типовой путь разработки: стратегия, backtesting, paper trading, затем live trading, плюс GUI, order management и risk management: <https://www.quantinsti.com/articles/automated-trading-system/>.
- Hochreiter и Schmidhuber описали LSTM как recurrent neural network architecture для последовательностей; обучение LSTM/GRU в проекте выполняется локально через PyTorch, а Raspberry Pi исполняет только экспортированные JSON-веса без PyTorch runtime: <https://direct.mit.edu/neco/article/9/8/1735/6109/Long-Short-Term-Memory>. - Hochreiter и Schmidhuber описали LSTM как recurrent neural network architecture для последовательностей; обучение LSTM/GRU в проекте выполняется локально через PyTorch, а Dell исполняет только прошедшие quality gate экспортированные JSON-веса без PyTorch runtime: <https://direct.mit.edu/neco/article/9/8/1735/6109/Long-Short-Term-Memory>.
Я не могу подтвердить, что эта стратегия будет прибыльной. Источники выше описывают технические свойства и риски автоматической торговли, но не гарантируют прибыль. Я не могу подтвердить, что эта стратегия будет прибыльной. Источники выше описывают технические свойства и риски автоматической торговли, но не гарантируют прибыль.
@@ -57,17 +69,17 @@ Copy-Item .env.example .env
python -m crypto_spot_bot.main python -m crypto_spot_bot.main
``` ```
Dashboard: <http://127.0.0.1:8787/> Liveness: <http://127.0.0.1:8787/api/health>
## Локальное обучение PyTorch LSTM/GRU ## Локальное обучение PyTorch LSTM
Обучение запускается на основной Windows-машине, а Raspberry Pi остается только для исполнения торгового цикла. PyTorch нужен только на машине обучения; в JSON экспортируются веса, а runtime на Raspberry Pi считает inference обычным Python-кодом: Обучение запускается на основной Windows-машине, а Dell остается для исполнения торгового цикла. PyTorch нужен только на машине обучения; в JSON экспортируются веса, а runtime на Dell считает inference обычным Python-кодом:
```powershell ```powershell
.\.venv\Scripts\python.exe -m pip install torch --index-url https://download.pytorch.org/whl/cpu .\.venv\Scripts\python.exe -m pip install torch --index-url https://download.pytorch.org/whl/cpu
.\.venv\Scripts\python.exe tools\train_torch_recurrent_forecaster.py ` .\.venv\Scripts\python.exe tools\train_torch_recurrent_forecaster.py `
--limit 3000 ` --limit 3000 `
--architectures lstm,gru ` --architectures lstm `
--lookbacks 64 ` --lookbacks 64 `
--hidden-sizes 64,96 ` --hidden-sizes 64,96 `
--layers 2 ` --layers 2 `
@@ -78,34 +90,33 @@ 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 прогнозы удалены.
Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков: Локальный retrain на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1 powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1
powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1
``` ```
Для удалённого запуска с телефона или с бота используется 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. Сервер выдаёт каждой попытке 10-минутную возобновляемую lease; зависшая попытка автоматически возвращается в очередь, а устаревший процесс не может загрузить артефакты по старой lease.
По умолчанию 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 обучает одну pooled PyTorch LSTM на динамическом наборе пар и `4000` часовых свечах на пару. Базовый профиль использует lookback `64`, hidden size `64`, два recurrent-слоя, dropout `0.20`, до `50` эпох, seed-ensemble `7/19`, три validation-fold и AdamW с learning rate `0.0007`/weight decay `0.0005`. Untouched holdout и quality gate не ослабляются. Параметр задания `pooled=false` включает независимые модели по парам; `architectures=gru` оставлен только как явная экспериментальная опция. Параметры можно переопределить через 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`.
Если retrain запускается с `-DeployToPi`, после успешного guard он синхронизирует `runtime/lstm_forecaster.json`, `runtime/torch_retrain_guard.json` и `runtime/torch_threshold_calibration.json` на Raspberry Pi через SSH-ключ и перезапускает сервис `tradebot`. Отдельный запуск sync: 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.
```powershell Основной 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` остаётся только в финальном отчёте и никогда не участвует в фильтрации входов или подборе порогов.
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)`. Принятый bundle загружается агентом через защищённый API `tb.kusoft.xyz`, проходит серверную проверку SHA-256/guard/calibration и атомарно становится активным на Dell.
## Docker ## Docker
@@ -115,18 +126,21 @@ docker compose up -d --build
docker compose logs -f tradebot docker compose logs -f tradebot
``` ```
Dashboard: `http://<host>:8787/` Локальная проверка: `http://127.0.0.1:8787/api/health`; внешний адрес: `https://tb.kusoft.xyz`.
Для Raspberry Pi 5 проект использует `python:3.12-slim`, без Node.js build step. Runtime-данные лежат в volume `./runtime:/app/runtime`; на внешнем диске можно разместить папку проекта или заменить volume на абсолютный путь внешнего диска. На Dell проект использует `python:3.12-slim`, без Node.js build step. Runtime-данные лежат в bind mount `./runtime:/app/runtime`; корневая файловая система контейнера read-only, процесс работает как UID/GID 1000, а container logs ротируются по `10 MiB × 3`. Порт по умолчанию привязан к `127.0.0.1`; для текущей схемы с Caddy на отдельном LAN-хосте в серверном `.env` задаётся `TRADEBOT_BIND_ADDRESS=0.0.0.0`, чтобы сохранить `https://tb.kusoft.xyz`.
## Основные env-параметры ## Основные env-параметры
```env ```env
TRADING_MODE=paper TRADING_MODE=paper
STARTING_BALANCE_USDT=100 STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false TRADEBOT_BIND_ADDRESS=127.0.0.1
BYBIT_REST_BASE_URL=https://api.bybit.kz
BYBIT_WEBSOCKET_URL=wss://stream.bybit.kz/v5/public/spot
AUTO_SELECT_SYMBOLS=true
TOP_SYMBOLS_COUNT=12 TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT SYMBOLS=
STRATEGY_MODE=torch_forecast STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60 BASE_INTERVAL=60
TREND_INTERVAL=D TREND_INTERVAL=D
@@ -137,6 +151,8 @@ FAST_LOOP_INTERVAL_SECONDS=1
FAST_ENTRY_COOLDOWN_SECONDS=20 FAST_ENTRY_COOLDOWN_SECONDS=20
MAX_ENTRIES_PER_MINUTE=12 MAX_ENTRIES_PER_MINUTE=12
WEBSOCKET_ENABLED=true WEBSOCKET_ENABLED=true
MARKET_OBSERVATION_ENABLED=true
MARKET_OBSERVATION_SAMPLE_SECONDS=30
MIN_SIGNAL_CONFIDENCE=0.64 MIN_SIGNAL_CONFIDENCE=0.64
PATTERN_ANALYSIS_ENABLED=true PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18 PATTERN_SCORE_WEIGHT=0.18
@@ -185,11 +201,20 @@ 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_TREND_FALLBACK_ENABLED=true
TIME_SERIES_FALLBACK_MODE=legacy
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
STOP_LOSS_EXIT_ENABLED=false
TAKE_PROFIT_PERCENT=0.035 TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015 TRAILING_STOP_PERCENT=0.015
MIN_HOLD_SECONDS=180 MIN_HOLD_SECONDS=180
PROFIT_ONLY_EXIT_ENABLED=true
MIN_EXIT_NET_PERCENT=0.31
ENTRY_COOLDOWN_SECONDS=180 ENTRY_COOLDOWN_SECONDS=180
MAX_DAILY_DRAWDOWN_USDT=6 MAX_DAILY_DRAWDOWN_USDT=6
TAKER_FEE_RATE=0.001 TAKER_FEE_RATE=0.001
@@ -202,6 +227,12 @@ SLIPPAGE_RATE=0.0003
Для быстрого режима рекомендуется оставлять `WEBSOCKET_ENABLED=true`: WebSocket дает частые рыночные обновления, а REST используется как периодическая сверка. Я не могу подтвердить, что быстрый режим повысит прибыльность; он только уменьшает техническую задержку реакции стратегии. Для быстрого режима рекомендуется оставлять `WEBSOCKET_ENABLED=true`: WebSocket дает частые рыночные обновления, а REST используется как периодическая сверка. Я не могу подтвердить, что быстрый режим повысит прибыльность; он только уменьшает техническую задержку реакции стратегии.
## Profit-only выходы
При `PROFIT_ONLY_EXIT_ENABLED=true` единый gate перед исполнением блокирует любой обычный `SELL`, если ожидаемый чистый результат с учетом входной и выходной комиссии, bid и проскальзывания ниже `MIN_EXIT_NET_PERCENT`. Это распространяется на RSI, EMA/MACD, ослабление прогноза, trailing и адаптивное снижение экспозиции. Явно помеченные аварийные выходы не блокируются; к ним относятся включенный оператором stop-loss, отказ установки защитного ордера в live и удаление старой paper-пары из торговой вселенной.
Количество зависших позиций ограничивается `MAX_OPEN_POSITIONS`, `MAX_POSITIONS_PER_SYMBOL`, `MAX_SYMBOL_EXPOSURE_USDT` и `MAX_TOTAL_EXPOSURE_USDT`. Когда лимит достигнут, новые покупки блокируются, но существующие позиции продолжают отслеживаться.
## Live-режим ## Live-режим
Live-режим специально заблокирован. Для включения нужны все значения: Live-режим специально заблокирован. Для включения нужны все значения:
@@ -213,15 +244,29 @@ 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
- `GET /api/health` — healthcheck. - `GET /api/health` — healthcheck.
- `GET /api/status` — статус бота, account snapshot, позиции. - `GET /api/status` — статус бота, account snapshot, позиции.
- `GET /api/dashboard/snapshot` — компактный защищённый snapshot для веб-панели; внешний UI использует эквивалентный `/web-api/dashboard/snapshot`, чтобы не смешивать Basic-auth браузера с токеном мобильного API.
- `GET /api/markets` — пары, ticker, свечи, инструменты. - `GET /api/markets` — пары, ticker, свечи, инструменты.
- `GET /api/training/market-observations?symbol=BTCUSDT&after_id=0&limit=5000` — защищённая training-token выгрузка L1-наблюдений.
- `GET /api/training/market-observations/manifest` — training-token manifest для инкрементальной синхронизации forward L1-данных.
- `GET /api/training/shadow` — состояние изолированной shadow-модели и повторного forward-gate.
- `POST /api/training/shadow/promote` — атомарное продвижение shadow-модели; возвращает `409`, пока forward-gate не пройден.
- `POST /api/training/retrain/auto` — ограниченная training-token команда Windows-agent; ставит только orderbook-retrain без произвольных параметров.
- `GET /api/trades` — последние сделки. - `GET /api/trades` — последние сделки.
- `GET /api/signals` — последние сигналы стратегии. - `GET /api/signals` — последние сигналы стратегии.
- `GET /api/events` — события. - `GET /api/events` — события.
@@ -234,5 +279,6 @@ LIVE_ORDER_MAX_USDT=10
## Проверка ## Проверка
```bash ```bash
python -m pip install -r requirements-dev.txt
python -m pytest python -m pytest
``` ```
+4 -8
View File
@@ -7,11 +7,11 @@
- Русский интерфейс без 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-компьютер обучения.
- Расписание retrain на телефоне: Android отправляет команду по расписанию, но обучение идёт на Windows-машине. - Расписание retrain через Android WorkManager: команда отправляется только при наличии сети, а обучение идёт на Windows-машине.
- Настройки API, токена команд, тёмной/светлой темы. - Настройки API, токена команд, тёмной/светлой темы.
- Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой. - Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой.
@@ -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 на этой машине сам выходит в интернет, забирает задание, обучает модель и отправляет проверенный bundle обратно боту. Имя и путь активного worker приложение получает от сервера, без прошитого имени компьютера.
## Live-торговля ## Live-торговля
+8 -4
View File
@@ -4,13 +4,17 @@ plugins {
android { android {
namespace = "xyz.kusoft.tradebotmonitor" namespace = "xyz.kusoft.tradebotmonitor"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "xyz.kusoft.tradebotmonitor" applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 37
versionCode = 12 versionCode = 24
versionName = "0.2.9" versionName = "0.5.2"
} }
} }
dependencies {
implementation("androidx.work:work-runtime:2.11.2")
}
@@ -2,16 +2,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher" android:icon="@drawable/ic_launcher"
android:label="TradeBot AI" android:label="TradeBot AI"
android:networkSecurityConfig="@xml/network_security_config"
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"
@@ -22,16 +23,5 @@
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name=".RetrainAlarmReceiver"
android:exported="false" />
<receiver
android:name=".BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -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,11 @@ 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()
val staleFallback = trainingComputerName in setOf("SEVENHILL", "DESKTOP-TMFDL0H") ||
trainingComputerPath in setOf("G:\\Repos\\TradeBot", "C:\\Repos\\TradeBot")
if (trainingComputerName.isNullOrBlank() || trainingComputerPath.isNullOrBlank() || staleFallback) {
pinDefaultTrainingComputer() pinDefaultTrainingComputer()
} }
} }
@@ -20,8 +30,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 +84,62 @@ 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 = "Ожидание Windows-agent"
const val DEFAULT_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot" const val DEFAULT_TRAINING_COMPUTER_PATH = "Имя и путь поступят от сервера"
const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1"
} }
} }
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
@@ -1,63 +1,55 @@
package xyz.kusoft.tradebotmonitor package xyz.kusoft.tradebotmonitor
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import androidx.work.Constraints
import java.util.concurrent.Executors import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import java.util.concurrent.TimeUnit
object RetrainScheduler { object RetrainScheduler {
private const val ACTION_RETRAIN = "xyz.kusoft.tradebotmonitor.RETRAIN" private const val UNIQUE_WORK_NAME = "tradebot-periodic-retrain"
private const val REQUEST_CODE = 6406
fun schedule(context: Context, hours: Int) { fun schedule(context: Context, hours: Int) {
val interval = hours.coerceAtLeast(1) * 60L * 60L * 1000L val constraints = Constraints.Builder()
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager .setRequiredNetworkType(NetworkType.CONNECTED)
manager.setInexactRepeating( .build()
AlarmManager.RTC_WAKEUP, val request = PeriodicWorkRequest.Builder(
System.currentTimeMillis() + interval, RetrainWorker::class.java,
interval, hours.coerceAtLeast(1).toLong(),
pendingIntent(context), TimeUnit.HOURS,
)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
) )
} }
fun cancel(context: Context) { fun cancel(context: Context) {
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_WORK_NAME)
manager.cancel(pendingIntent(context))
} }
private fun pendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
REQUEST_CODE,
Intent(context, RetrainAlarmReceiver::class.java).setAction(ACTION_RETRAIN),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
} }
class RetrainAlarmReceiver : BroadcastReceiver() { class RetrainWorker(
override fun onReceive(context: Context, intent: Intent) { context: Context,
val pending = goAsync() parameters: WorkerParameters,
Executors.newSingleThreadExecutor().execute { ) : Worker(context, parameters) {
try { override fun doWork(): Result {
val prefs = AppPrefs(context) val prefs = AppPrefs(applicationContext)
if (prefs.retrainScheduleEnabled) { if (!prefs.retrainScheduleEnabled || prefs.commandToken.isBlank()) {
TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain() return Result.success()
}
} finally {
pending.finish()
}
} }
} return try {
} TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
Result.success()
class BootReceiver : BroadcastReceiver() { } catch (_: Exception) {
override fun onReceive(context: Context, intent: Intent) { Result.retry()
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled) {
RetrainScheduler.schedule(context, prefs.retrainIntervalHours)
} }
} }
} }
@@ -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,
@@ -175,7 +183,7 @@ class TradeBotApi(
qualityScore = quality.optDouble("score", 0.0), qualityScore = quality.optDouble("score", 0.0),
) )
} }
return output.sortedBy { it.symbol } return output
} }
private fun parseTicker(row: JSONObject): TickerData = private fun parseTicker(row: JSONObject): TickerData =
@@ -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)
} }
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</full-backup-content>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -142,7 +142,7 @@
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/> <rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
<text x="338" y="48" class="text small">91%</text> <text x="338" y="48" class="text small">91%</text>
<text x="34" y="88" class="text h2">Рынки</text> <text x="34" y="88" class="text h2">Рынки</text>
<text x="34" y="114" class="muted small">12 фиксированных spot-пар</text> <text x="34" y="114" class="muted small">Динамические Bybit spot-пары</text>
<rect x="34" y="136" width="340" height="42" rx="7" fill="#11141a" stroke="#242a36"/> <rect x="34" y="136" width="340" height="42" rx="7" fill="#11141a" stroke="#242a36"/>
<text x="52" y="162" class="dim small">Поиск пары или сигнала</text> <text x="52" y="162" class="dim small">Поиск пары или сигнала</text>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

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=60000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -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" "$@"
+93
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Crypto spot trading bot package.""" """Crypto spot trading bot package."""
__version__ = "0.1.0" __version__ = "1.1.2"
+6 -3
View File
@@ -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]]:
+70
View File
@@ -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 ""
+308 -21
View File
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import math
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,9 +13,16 @@ 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,
apply_profit_only_exit_policy,
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, _barrier_outcome
logger = logging.getLogger(__name__)
class CryptoSpotBot: class CryptoSpotBot:
@@ -26,6 +36,7 @@ class CryptoSpotBot:
pattern_analyzer: PatternAnalyzer, pattern_analyzer: PatternAnalyzer,
learner: TradeLearner, learner: TradeLearner,
forecaster: TimeSeriesForecaster | None = None, forecaster: TimeSeriesForecaster | None = None,
shadow_forecaster: TimeSeriesForecaster | None = None,
llm_advisor=None, llm_advisor=None,
): ):
self.settings = settings self.settings = settings
@@ -36,6 +47,7 @@ class CryptoSpotBot:
self.pattern_analyzer = pattern_analyzer self.pattern_analyzer = pattern_analyzer
self.learner = learner self.learner = learner
self.forecaster = forecaster self.forecaster = forecaster
self.shadow_forecaster = shadow_forecaster
self.llm_advisor = llm_advisor self.llm_advisor = llm_advisor
self.running = False self.running = False
self.started_at: datetime | None = None self.started_at: datetime | None = None
@@ -44,6 +56,11 @@ 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
self._orderbook_feature_cache_key: tuple[tuple[str, int], ...] = ()
self._orderbook_feature_cache: dict[str, dict[int, dict[str, float]]] = {}
async def start(self) -> None: async def start(self) -> None:
if self.running: if self.running:
@@ -51,6 +68,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 +86,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 +103,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 +117,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 +126,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 +150,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 +169,42 @@ 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) if ticker is not None:
signal = apply_profit_only_exit_policy(self.settings, position, ticker, 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 +231,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 +247,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 +293,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 +305,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")
@@ -272,14 +377,28 @@ class CryptoSpotBot:
volume_24h=0.0, volume_24h=0.0,
change_24h=0.0, change_24h=0.0,
) )
self.broker.sell( candidate = Signal(
position, position.symbol,
synthetic_ticker, "SELL",
f"{self.settings.strategy_mode}: закрыта старая paper-позиция вне списка разрешенных пар", 0.5,
) f"{self.settings.strategy_mode}: старая paper-позиция вне списка разрешенных пар",
self.storage.event( {
f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}" "emergency_exit": True,
"emergency_exit_type": "symbol_removed_from_universe",
},
) )
decision = apply_profit_only_exit_policy(self.settings, position, synthetic_ticker, candidate)
self._record_signal(decision)
if decision.action == "SELL":
self.broker.sell(position, synthetic_ticker, decision.reason)
self.storage.event(
f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}"
)
else:
self.storage.event(
f"{position.symbol}: старая paper-позиция сохранена политикой profit-only",
"WARN",
)
def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None: def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None:
rules = self._with_exposure_context(self.learner.state.adaptive_rules or {}) rules = self._with_exposure_context(self.learner.state.adaptive_rules or {})
@@ -303,6 +422,11 @@ class CryptoSpotBot:
self.settings.pattern_analysis_enabled self.settings.pattern_analysis_enabled
or self.settings.grid_trading_enabled or self.settings.grid_trading_enabled
or self.settings.rebound_trading_enabled or self.settings.rebound_trading_enabled
or (
self.settings.strategy_mode == "torch_forecast"
and self.settings.time_series_trend_fallback_enabled
and self.settings.time_series_fallback_mode == "legacy"
)
) )
if self.settings.strategy_mode == "trend_macd" or not patterns_needed: if self.settings.strategy_mode == "trend_macd" or not patterns_needed:
self.market.patterns = {} self.market.patterns = {}
@@ -317,6 +441,25 @@ class CryptoSpotBot:
self.market.patterns = patterns self.market.patterns = patterns
def _update_forecasts(self) -> None: def _update_forecasts(self) -> None:
cache_key = tuple(
(symbol, rows[-1].timestamp if rows else 0)
for symbol, rows in sorted(self.market.candles.items())
)
earliest_timestamp = min(
(rows[0].timestamp for rows in self.market.candles.values() if rows),
default=0,
)
if cache_key != self._orderbook_feature_cache_key:
orderbook_features, _manifest = self.storage.recent_aggregated_orderbook_features(
interval=self.settings.base_interval,
symbols=self.market.symbols,
after_timestamp_ms=earliest_timestamp,
min_samples_per_bucket=20,
)
self._orderbook_feature_cache = orderbook_features
self._orderbook_feature_cache_key = cache_key
else:
orderbook_features = self._orderbook_feature_cache
if ( if (
self.forecaster is None self.forecaster is None
or not self.settings.time_series_forecast_enabled or not self.settings.time_series_forecast_enabled
@@ -330,20 +473,164 @@ class CryptoSpotBot:
symbol=symbol, symbol=symbol,
market_candles=self.market.candles, market_candles=self.market.candles,
trend_candles=self.market.trend_candles.get(symbol, []), trend_candles=self.market.trend_candles.get(symbol, []),
orderbook_features=orderbook_features,
).as_dict() ).as_dict()
self.market.forecasts = forecasts self.market.forecasts = forecasts
self._update_shadow_forecasts(orderbook_features)
def _update_shadow_forecasts(
self,
orderbook_features: dict[str, dict[int, dict[str, float]]],
) -> None:
if self.shadow_forecaster is None:
self.market.shadow_forecasts = {}
return
model_sha256 = self.shadow_forecaster.artifact_sha256()
if not model_sha256:
self.market.shadow_forecasts = {}
return
forecasts: dict[str, dict] = {}
for symbol in self.market.symbols:
candles = self.market.candles.get(symbol, [])
forecast = self.shadow_forecaster.forecast(
candles,
symbol=symbol,
market_candles=self.market.candles,
trend_candles=self.market.trend_candles.get(symbol, []),
orderbook_features=orderbook_features,
).as_dict()
forecast["shadow"] = True
forecast["model_sha256"] = model_sha256
forecasts[symbol] = forecast
self._record_and_settle_shadow(symbol, candles, forecast, model_sha256)
self.market.shadow_forecasts = forecasts
def _record_and_settle_shadow(
self,
symbol: str,
candles: list,
forecast: dict,
model_sha256: str,
) -> None:
if candles and forecast.get("usable"):
probability = float(
forecast.get("probability_take_profit_first")
if forecast.get("probability_take_profit_first") is not None
else forecast.get("probability_up", 0.5)
)
expected = float(forecast.get("expected_return_percent", 0.0) or 0.0)
eligible = bool(
not forecast.get("block_entry")
and expected >= float(forecast.get("calibrated_min_edge_percent", 0.0) or 0.0)
and probability >= float(forecast.get("calibrated_min_probability_up", 0.5) or 0.5)
)
self.storage.insert_shadow_prediction(
model_sha256=model_sha256,
symbol=symbol,
forecast_timestamp_ms=candles[-1].timestamp,
horizon=max(1, int(forecast.get("horizon", 1) or 1)),
reference_price=float(candles[-1].close),
expected_return_percent=expected,
probability_up=probability,
eligible_signal=eligible,
)
if not candles:
return
indexes = {candle.timestamp: index for index, candle in enumerate(candles)}
round_trip_cost = 2.0 * (
float(self.settings.taker_fee_rate) + float(self.settings.slippage_rate)
)
for row in self.storage.pending_shadow_predictions(
model_sha256=model_sha256,
symbol=symbol,
):
index = indexes.get(int(row.get("forecast_timestamp_ms", 0) or 0))
horizon = max(1, int(row.get("horizon", 1) or 1))
if index is None or index + horizon >= len(candles):
continue
outcome = _barrier_outcome(
candles,
end_index=index,
horizon=horizon,
stop_loss_percent=float(self.settings.stop_loss_percent),
take_profit_percent=float(self.settings.take_profit_percent),
round_trip_cost=round_trip_cost,
)
if outcome is None:
continue
actual_log_return, take_profit_first = outcome
actual_return_percent = (math.exp(actual_log_return) - 1.0) * 100.0
self.storage.settle_shadow_prediction(
int(row["id"]),
actual_return_percent=actual_return_percent,
take_profit_first=take_profit_first >= 0.5,
)
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)
+193 -10
View File
@@ -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 = self.session.get( response = None
f"{self.settings.rest_base_url}{path}", for attempt in range(3):
params=params, try:
timeout=12, response = self.session.get(
) f"{self.settings.rest_base_url}{path}",
params=params,
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())
@@ -185,6 +220,10 @@ class BybitClient:
return candles return candles
def orderbook_top(self, symbol: str) -> tuple[float, float]: def orderbook_top(self, symbol: str) -> tuple[float, float]:
bid, _bid_size, ask, _ask_size = self.orderbook_level_one(symbol)
return bid, ask
def orderbook_level_one(self, symbol: str) -> tuple[float, float, float, float]:
result = self.public_get( result = self.public_get(
"/v5/market/orderbook", "/v5/market/orderbook",
{"category": "spot", "symbol": symbol, "limit": 1}, {"category": "spot", "symbol": symbol, "limit": 1},
@@ -192,8 +231,10 @@ class BybitClient:
bids = result.get("b") or [] bids = result.get("b") or []
asks = result.get("a") or [] asks = result.get("a") or []
bid = _float(bids[0][0]) if bids else 0.0 bid = _float(bids[0][0]) if bids else 0.0
bid_size = _float(bids[0][1]) if bids and len(bids[0]) > 1 else 0.0
ask = _float(asks[0][0]) if asks else 0.0 ask = _float(asks[0][0]) if asks else 0.0
return bid, ask ask_size = _float(asks[0][1]) if asks and len(asks[0]) > 1 else 0.0
return bid, bid_size, ask, ask_size
def place_spot_market_order( def place_spot_market_order(
self, self,
@@ -208,27 +249,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 +433,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(".")
+102 -4
View File
@@ -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,17 +155,41 @@ class Settings:
database_path: Path database_path: Path
log_path: Path log_path: Path
env_file_path: Path env_file_path: Path
profit_only_exit_enabled: bool = True
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
bybit_rest_base_url_override: str = ""
bybit_websocket_url_override: str = ""
time_series_fallback_mode: str = "trend_macd"
market_observation_enabled: bool = True
market_observation_sample_seconds: float = 30.0
@property @property
def rest_base_url(self) -> str: def rest_base_url(self) -> str:
return "https://api-testnet.bybit.com" if self.bybit_testnet else "https://api.bybit.com" if self.bybit_rest_base_url_override:
return self.bybit_rest_base_url_override.rstrip("/")
return "https://api-testnet.bybit.com" if self.bybit_testnet else "https://api.bybit.kz"
@property @property
def websocket_url(self) -> str: def websocket_url(self) -> str:
if self.bybit_websocket_url_override:
return self.bybit_websocket_url_override
return ( return (
"wss://stream-testnet.bybit.com/v5/public/spot" "wss://stream-testnet.bybit.com/v5/public/spot"
if self.bybit_testnet if self.bybit_testnet
else "wss://stream.bybit.com/v5/public/spot" else "wss://stream.bybit.kz/v5/public/spot"
) )
@property @property
@@ -204,7 +230,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
strategy_mode = os.getenv("STRATEGY_MODE", "torch_forecast").strip().lower() strategy_mode = os.getenv("STRATEGY_MODE", "torch_forecast").strip().lower()
if strategy_mode not in STRATEGY_MODES: if strategy_mode not in STRATEGY_MODES:
raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast") raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast")
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False) auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", True)
top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS)) top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS))
requested_symbols = _symbols_env("SYMBOLS") requested_symbols = _symbols_env("SYMBOLS")
symbols = requested_symbols if requested_symbols else (() if auto_select_symbols else FIXED_SPOT_SYMBOLS) symbols = requested_symbols if requested_symbols else (() if auto_select_symbols else FIXED_SPOT_SYMBOLS)
@@ -288,12 +314,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 +333,41 @@ 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,
profit_only_exit_enabled=_bool_env("PROFIT_ONLY_EXIT_ENABLED", True),
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),
bybit_rest_base_url_override=os.getenv(
"BYBIT_REST_BASE_URL",
"" if _bool_env("BYBIT_TESTNET", False) else "https://api.bybit.kz",
).strip(),
bybit_websocket_url_override=os.getenv("BYBIT_WEBSOCKET_URL", "").strip(),
time_series_fallback_mode=os.getenv(
"TIME_SERIES_FALLBACK_MODE", "trend_macd"
).strip().lower(),
market_observation_enabled=_bool_env("MARKET_OBSERVATION_ENABLED", True),
market_observation_sample_seconds=_float_env(
"MARKET_OBSERVATION_SAMPLE_SECONDS", 30.0
),
) )
_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 +376,42 @@ 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 not 0 <= settings.min_exit_net_percent <= 5:
errors.append("MIN_EXIT_NET_PERCENT must be in range 0..5")
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 settings.time_series_fallback_mode not in {"trend_macd", "legacy"}:
errors.append("TIME_SERIES_FALLBACK_MODE must be trend_macd or legacy")
if settings.market_observation_sample_seconds <= 0:
errors.append("MARKET_OBSERVATION_SAMPLE_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] = []
+313 -38
View File
@@ -1,14 +1,21 @@
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 datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
from fastapi import FastAPI, HTTPException, Response from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
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 import __version__
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
from crypto_spot_bot.execution import LiveBroker, PaperBroker from crypto_spot_bot.execution import LiveBroker, PaperBroker
@@ -16,13 +23,16 @@ from crypto_spot_bot.learning import TradeLearner
from crypto_spot_bot.market_data import MarketData from crypto_spot_bot.market_data import MarketData
from crypto_spot_bot.patterns import PatternAnalyzer from crypto_spot_bot.patterns import PatternAnalyzer
from crypto_spot_bot.reconciliation import reconciliation_snapshot from crypto_spot_bot.reconciliation import reconciliation_snapshot
from crypto_spot_bot.shadow import shadow_gate_snapshot
from crypto_spot_bot.storage import Storage from crypto_spot_bot.storage import Storage
from crypto_spot_bot.strategy import SpotStrategy from crypto_spot_bot.strategy import SpotStrategy
from crypto_spot_bot.time_series import TimeSeriesForecaster from crypto_spot_bot.time_series import TimeSeriesForecaster
from crypto_spot_bot.training_coordination import TrainingCoordinator 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_ROOT = Path(__file__).with_name("web")
WEB_INDEX = WEB_ROOT / "index.html"
logger = logging.getLogger(__name__)
def create_app(settings: Settings | None = None) -> FastAPI: def create_app(settings: Settings | None = None) -> FastAPI:
@@ -42,8 +52,25 @@ def create_app(settings: Settings | None = None) -> FastAPI:
pattern_analyzer = PatternAnalyzer() pattern_analyzer = PatternAnalyzer()
learner = TradeLearner(settings, storage) learner = TradeLearner(settings, storage)
forecaster = TimeSeriesForecaster(settings) forecaster = TimeSeriesForecaster(settings)
bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster) runtime_dir = settings.time_series_lstm_model_path.parent
shadow_forecaster = TimeSeriesForecaster(
settings,
model_path=runtime_dir / "lstm_forecaster.shadow.json",
calibration_path=runtime_dir / "torch_shadow_calibration.json",
)
bot = CryptoSpotBot(
settings,
storage,
market,
broker,
strategy,
pattern_analyzer,
learner,
forecaster,
shadow_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):
@@ -53,63 +80,93 @@ def create_app(settings: Settings | None = None) -> FastAPI:
finally: finally:
await bot.stop() await bot.stop()
app = FastAPI(title="Крипто спот-бот", lifespan=lifespan) app = FastAPI(title="Крипто спот-бот", version=__version__, lifespan=lifespan)
app.state.settings = settings app.state.settings = settings
app.state.storage = storage app.state.storage = storage
app.state.bot = bot app.state.bot = bot
app.state.market = market app.state.market = market
app.state.training = training app.state.training = training
app.mount("/assets", StaticFiles(directory=WEB_ROOT), name="dashboard-assets")
@app.get("/", response_class=PlainTextResponse, status_code=410) @app.middleware("http")
async def index() -> str: async def security_headers(request: Request, call_next) -> Response:
return WEB_UI_REMOVED_MESSAGE response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; style-src 'self'; script-src 'self'; "
"img-src 'self' data:; connect-src 'self'; font-src 'self'; "
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
)
if request.url.path == "/":
response.headers.setdefault("Cache-Control", "no-store")
return response
@app.get("/", response_class=FileResponse)
async def index() -> FileResponse:
return FileResponse(WEB_INDEX, media_type="text/html")
@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(),
"version": __version__,
}
@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,71 +174,230 @@ 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()
data["shadow"] = shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256())
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.get("/api/training/shadow")
async def training_shadow_status(
_: None = Depends(authorizer.require),
) -> dict[str, Any]:
return shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256())
@app.post("/api/training/shadow/promote")
async def training_shadow_promote(
_: None = Depends(authorizer.require),
) -> dict[str, Any]:
gate = shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256())
if not gate.get("passed"):
raise HTTPException(status_code=409, detail={"message": "shadow forward gate has not passed", "gate": gate})
try:
return training.promote_shadow(gate)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/training/market-observations")
async def training_market_observations(
symbol: str,
after_id: int = 0,
limit: int = 5000,
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
normalized_symbol = symbol.strip().upper()
if not normalized_symbol:
raise HTTPException(status_code=400, detail="symbol is required")
items = storage.market_observations_after(
symbol=normalized_symbol,
after_id=max(0, after_id),
limit=max(1, min(limit, 5000)),
)
return {
"symbol": normalized_symbol,
"items": items,
"next_after_id": int(items[-1]["id"]) if items else max(0, after_id),
}
@app.get("/api/training/market-observations/manifest")
async def training_market_observation_manifest(
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
items = storage.market_observation_manifest()
return {
"items": items,
"total_samples": sum(int(item.get("samples", 0) or 0) for item in items),
}
@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/retrain/auto")
async def training_retrain_auto(
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
return training.request_retrain(
{
"source": "windows-agent-auto",
"parameters": {"use_orderbook": True},
}
)
@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()
retrain_data["shadow"] = shadow_gate_snapshot(
storage,
shadow_forecaster.artifact_sha256(),
)
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(),
"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.get("/web-api/dashboard/snapshot", include_in_schema=False)
@app.get("/api/dashboard/snapshot")
async def dashboard_snapshot(_: None = Depends(authorizer.require)) -> dict[str, Any]:
market_data = market.snapshot()
retrain_data = _runtime_json(settings, "torch_retrain_guard.json")
retrain_data["coordination"] = training.status()
retrain_data["shadow"] = shadow_gate_snapshot(
storage,
shadow_forecaster.artifact_sha256(),
)
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"health": {
"ok": True,
"running": bot.running,
"mode": settings.trading_mode,
"version": __version__,
},
"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": _compact_markets(market_data),
"signals": {"items": storage.recent_signals(16)},
"trades": {
"items": storage.recent_trades(16, mode=settings.trading_mode),
"closed_items": storage.closed_trades(16, mode=settings.trading_mode),
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
},
"events": {"items": storage.recent_events(20)},
"retrain": retrain_data,
"config": _safe_config(settings),
}
@app.post("/web-api/config/fast-trading", include_in_schema=False)
@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)
response["env_persisted"] = env_persisted response["env_persisted"] = env_persisted
return response return response
@app.post("/web-api/control/start", include_in_schema=False)
@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("/web-api/control/stop", include_in_schema=False)
@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,17 +423,57 @@ 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:
storage.event(f"API error: {exc}", "ERROR") try:
return JSONResponse({"error": str(exc)}, status_code=500) storage.event(f"API error: {exc}", "ERROR")
except Exception:
logger.exception("Could not persist API error event")
return JSONResponse({"error": "internal server error"}, status_code=500)
return app return app
def _compact_markets(snapshot: dict[str, Any]) -> dict[str, Any]:
markets: list[dict[str, Any]] = []
for market in snapshot.get("markets", []):
candles = market.get("candles") or []
markets.append(
{
"ticker": market.get("ticker"),
"sparkline": [
{
"timestamp": candle.get("timestamp"),
"close": candle.get("close"),
}
for candle in candles[-48:]
],
"forecast": market.get("forecast"),
"quality": market.get("quality"),
}
)
return {
"symbols": snapshot.get("symbols", []),
"ws_connected": snapshot.get("ws_connected", False),
"rest_error_count": snapshot.get("rest_error_count", 0),
"last_rest_error": snapshot.get("last_rest_error", ""),
"last_rest_refresh_at": snapshot.get("last_rest_refresh_at"),
"last_ws_message_at": snapshot.get("last_ws_message_at"),
"observation_collector": snapshot.get("observation_collector", {}),
"quality": snapshot.get("quality", {}),
"markets": markets,
}
def _limit(value: int) -> int: def _limit(value: int) -> int:
return max(1, min(int(value), 500)) return max(1, min(int(value), 500))
@@ -317,12 +573,23 @@ 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_fallback_mode": settings.time_series_fallback_mode,
"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,
"market_observation_enabled": settings.market_observation_enabled,
"market_observation_sample_seconds": settings.market_observation_sample_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,
"profit_only_exit_enabled": settings.profit_only_exit_enabled,
"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 +597,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
),
} }
+495 -15
View File
@@ -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)
response = self.client.place_spot_market_order( client_order_id = f"tb-buy-{uuid4().hex[:18]}"
self.storage.upsert_order(
client_order_id=client_order_id,
symbol=ticker.symbol, symbol=ticker.symbol,
side="Buy", side="Buy",
qty=notional, order_kind="MARKET",
market_unit="quoteCoin", status="PENDING_SUBMIT",
order_link_id=f"tb-buy-{uuid4().hex[:18]}", requested_notional=requested_quote,
raw={"signal": signal.as_dict()},
) )
self.storage.event(f"{ticker.symbol}: реальная покупка отправлена orderId={response.get('orderId')}") try:
return self._record_buy(signal, ticker, instrument, "реальная покупка, локальная запись") response = self.client.place_spot_market_order(
symbol=ticker.symbol,
side="Buy",
qty=requested_quote,
market_unit="quoteCoin",
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 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]:
+4 -1
View File
@@ -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")
+7 -1
View File
@@ -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(),
], ],
) )
+199 -42
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import threading
import time
from dataclasses import asdict from dataclasses import asdict
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -50,12 +52,22 @@ class MarketData:
self.candles: dict[str, list[Candle]] = {} self.candles: dict[str, list[Candle]] = {}
self.trend_candles: dict[str, list[Candle]] = {} self.trend_candles: dict[str, list[Candle]] = {}
self.orderbook_top: dict[str, tuple[float, float]] = {} self.orderbook_top: dict[str, tuple[float, float]] = {}
self.orderbook_metrics: dict[str, dict[str, Any]] = {}
self.patterns: dict[str, dict[str, Any]] = {} self.patterns: dict[str, dict[str, Any]] = {}
self.forecasts: dict[str, dict[str, Any]] = {} self.forecasts: dict[str, dict[str, Any]] = {}
self.shadow_forecasts: dict[str, dict[str, Any]] = {}
self.last_rest_refresh_at: datetime | None = None self.last_rest_refresh_at: datetime | None = None
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 = ""
self.observation_samples = 0
self.last_observation_at: datetime | None = None
self.observation_error_count = 0
self.last_observation_error = ""
self._last_observation_monotonic: dict[str, float] = {}
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,47 +88,49 @@ 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:
ticker_map = {ticker.symbol: ticker for ticker in self.client.spot_tickers()} if not self._refresh_lock.acquire(blocking=False):
for symbol in self.symbols: return
ticker = ticker_map.get(symbol) try:
if ticker: ticker_map = {ticker.symbol: ticker for ticker in self.client.spot_tickers()}
self.tickers[symbol] = ticker for symbol in self.symbols:
try: ticker = ticker_map.get(symbol)
candles = self.client.klines( if ticker:
symbol=symbol, self.tickers[symbol] = ticker
interval=self.settings.base_interval, try:
limit=self.settings.kline_limit, if force_candles or _candles_due(self.candles.get(symbol, []), self.settings.base_interval):
) candles = self.client.klines(
candles = _closed_candles(candles, self.settings.base_interval) symbol=symbol,
add_indicators(candles) interval=self.settings.base_interval,
self.candles[symbol] = candles limit=self.settings.kline_limit,
trend_candles = self.client.klines( )
symbol=symbol, candles = _closed_candles(candles, self.settings.base_interval)
interval=self.settings.trend_interval, add_indicators(candles)
limit=self.settings.trend_kline_limit, self.candles[symbol] = candles
) if force_candles or _candles_due(
trend_candles = _closed_candles(trend_candles, self.settings.trend_interval) self.trend_candles.get(symbol, []), self.settings.trend_interval
add_indicators(trend_candles) ):
self.trend_candles[symbol] = trend_candles trend_candles = self.client.klines(
bid, ask = self.client.orderbook_top(symbol) symbol=symbol,
self.orderbook_top[symbol] = (bid, ask) interval=self.settings.trend_interval,
if symbol in self.tickers: limit=self.settings.trend_kline_limit,
current = self.tickers[symbol] )
self.tickers[symbol] = Ticker( trend_candles = _closed_candles(trend_candles, self.settings.trend_interval)
symbol=current.symbol, add_indicators(trend_candles)
last_price=current.last_price, self.trend_candles[symbol] = trend_candles
bid=bid or current.bid, bid, bid_size, ask, ask_size = self.client.orderbook_level_one(symbol)
ask=ask or current.ask, self._update_orderbook(symbol, bid, bid_size, ask, ask_size)
turnover_24h=current.turnover_24h, except Exception as exc:
volume_24h=current.volume_24h, self.rest_error_count += 1
change_24h=current.change_24h, self.last_rest_error = str(exc)
) self.storage.event(f"{symbol}: ошибка обновления REST данных: {exc}", "ERROR")
except Exception as exc: self.last_rest_refresh_at = utc_now()
self.storage.event(f"{symbol}: ошибка обновления REST данных: {exc}", "ERROR") if ticker_map:
self.last_rest_refresh_at = utc_now() 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:
@@ -163,7 +177,11 @@ class MarketData:
elif topic.startswith("orderbook.") and isinstance(data, dict): elif topic.startswith("orderbook.") and isinstance(data, dict):
parts = topic.split(".") parts = topic.split(".")
if len(parts) >= 3: if len(parts) >= 3:
self._handle_orderbook(parts[2], data) self._handle_orderbook(
parts[2],
data,
source_timestamp_ms=int(_float(message.get("ts"))),
)
def _handle_ticker(self, symbol: str, data: dict[str, Any]) -> None: def _handle_ticker(self, symbol: str, data: dict[str, Any]) -> None:
current = self.tickers.get(symbol) current = self.tickers.get(symbol)
@@ -205,14 +223,66 @@ class MarketData:
add_indicators(candles) add_indicators(candles)
self.candles[symbol] = candles self.candles[symbol] = candles
def _handle_orderbook(self, symbol: str, data: dict[str, Any]) -> None: def _handle_orderbook(
self,
symbol: str,
data: dict[str, Any],
source_timestamp_ms: int = 0,
) -> None:
bids = data.get("b") or [] bids = data.get("b") or []
asks = data.get("a") or [] asks = data.get("a") or []
bid = _float(bids[0][0]) if bids else 0.0 bid = _float(bids[0][0]) if bids else 0.0
bid_size = _float(bids[0][1]) if bids and len(bids[0]) > 1 else 0.0
ask = _float(asks[0][0]) if asks else 0.0 ask = _float(asks[0][0]) if asks else 0.0
ask_size = _float(asks[0][1]) if asks and len(asks[0]) > 1 else 0.0
self._update_orderbook(
symbol,
bid,
bid_size,
ask,
ask_size,
source_timestamp_ms=source_timestamp_ms,
)
def _update_orderbook(
self,
symbol: str,
bid: float,
bid_size: float,
ask: float,
ask_size: float,
*,
source_timestamp_ms: int = 0,
) -> None:
if bid > 0 and ask > 0: if bid > 0 and ask > 0:
self.orderbook_top[symbol] = (bid, ask) self.orderbook_top[symbol] = (bid, ask)
current = self.tickers.get(symbol) current = self.tickers.get(symbol)
size_total = max(0.0, bid_size) + max(0.0, ask_size)
mid_price = (bid + ask) / 2.0
imbalance = (
(max(0.0, bid_size) - max(0.0, ask_size)) / size_total
if size_total > 0
else 0.0
)
microprice = (
(ask * max(0.0, bid_size) + bid * max(0.0, ask_size)) / size_total
if size_total > 0
else mid_price
)
observed_at = utc_now()
metrics = {
"bid_price": bid,
"bid_size": max(0.0, bid_size),
"ask_price": ask,
"ask_size": max(0.0, ask_size),
"mid_price": mid_price,
"microprice": microprice,
"spread_bps": ((ask - bid) / mid_price) * 10_000 if mid_price > 0 else 0.0,
"imbalance": imbalance,
"source_timestamp_ms": max(0, source_timestamp_ms),
"observed_at": observed_at.isoformat(),
}
self.orderbook_metrics[symbol] = metrics
if current: if current:
self.tickers[symbol] = Ticker( self.tickers[symbol] = Ticker(
symbol=symbol, symbol=symbol,
@@ -223,14 +293,78 @@ class MarketData:
volume_24h=current.volume_24h, volume_24h=current.volume_24h,
change_24h=current.change_24h, change_24h=current.change_24h,
) )
self._sample_orderbook(symbol, metrics, current.last_price if current else mid_price, observed_at)
def _sample_orderbook(
self,
symbol: str,
metrics: dict[str, Any],
last_price: float,
observed_at: datetime,
) -> None:
if not self.settings.market_observation_enabled:
return
now = time.monotonic()
previous = self._last_observation_monotonic.get(symbol)
if previous is not None and now - previous < self.settings.market_observation_sample_seconds:
return
try:
self.storage.insert_market_observation(
symbol=symbol,
bid_price=float(metrics["bid_price"]),
bid_size=float(metrics["bid_size"]),
ask_price=float(metrics["ask_price"]),
ask_size=float(metrics["ask_size"]),
mid_price=float(metrics["mid_price"]),
microprice=float(metrics["microprice"]),
spread_bps=float(metrics["spread_bps"]),
imbalance=float(metrics["imbalance"]),
last_price=last_price,
source_timestamp_ms=int(metrics["source_timestamp_ms"]),
created_at=observed_at,
)
except Exception as exc: # Storage errors must not disconnect market data.
self.observation_error_count += 1
self.last_observation_error = str(exc)
return
self._last_observation_monotonic[symbol] = now
self.observation_samples += 1
self.last_observation_at = observed_at
self.last_observation_error = ""
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,
@@ -243,6 +377,16 @@ class MarketData:
"last_ws_message_at": self.last_ws_message_at.isoformat() "last_ws_message_at": self.last_ws_message_at.isoformat()
if self.last_ws_message_at if self.last_ws_message_at
else None, else None,
"observation_collector": {
"enabled": self.settings.market_observation_enabled,
"sample_seconds": self.settings.market_observation_sample_seconds,
"samples_since_start": self.observation_samples,
"last_observation_at": self.last_observation_at.isoformat()
if self.last_observation_at
else None,
"error_count": self.observation_error_count,
"last_error": self.last_observation_error,
},
"markets": [ "markets": [
{ {
"ticker": self.tickers[symbol].as_dict() if symbol in self.tickers else None, "ticker": self.tickers[symbol].as_dict() if symbol in self.tickers else None,
@@ -250,6 +394,8 @@ class MarketData:
"trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]], "trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]],
"pattern": self.patterns.get(symbol), "pattern": self.patterns.get(symbol),
"forecast": self.forecasts.get(symbol), "forecast": self.forecasts.get(symbol),
"shadow_forecast": self.shadow_forecasts.get(symbol),
"orderbook": self.orderbook_metrics.get(symbol),
"quality": analyze_symbol_quality( "quality": analyze_symbol_quality(
symbol=symbol, symbol=symbol,
candles=self.candles.get(symbol, []), candles=self.candles.get(symbol, []),
@@ -291,3 +437,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
+4
View File
@@ -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)
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import math
import sqlite3
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable
ORDERBOOK_FEATURES = (
"l1_imbalance_mean",
"l1_imbalance_std",
"l1_spread_bps_mean",
"l1_spread_bps_p90",
"l1_microprice_deviation_bps_mean",
"l1_microprice_deviation_bps_std",
"l1_sample_count_log1p",
)
def interval_milliseconds(interval: str) -> int:
normalized = str(interval).strip().upper()
if normalized.isdigit():
return max(1, int(normalized)) * 60_000
units = {
"D": 86_400_000,
"W": 7 * 86_400_000,
"M": 30 * 86_400_000,
}
return units.get(normalized, 0)
def load_orderbook_feature_map(
path: str | Path,
*,
interval: str,
symbols: Iterable[str] | None = None,
min_samples_per_bucket: int = 20,
) -> tuple[dict[str, dict[int, dict[str, float]]], dict[str, dict[str, Any]]]:
database_path = Path(path)
if not database_path.is_file():
return {}, {}
selected = sorted({str(symbol).strip().upper() for symbol in symbols or [] if str(symbol).strip()})
query = (
"SELECT symbol, bid_price, bid_size, ask_price, ask_size, mid_price, "
"microprice, spread_bps, imbalance, source_timestamp_ms, created_at "
"FROM market_observations"
)
parameters: list[Any] = []
if selected:
placeholders = ",".join("?" for _ in selected)
query += f" WHERE symbol IN ({placeholders})"
parameters.extend(selected)
query += " ORDER BY symbol, source_timestamp_ms, created_at"
with sqlite3.connect(database_path) as connection:
connection.row_factory = sqlite3.Row
try:
rows = connection.execute(query, parameters).fetchall()
except sqlite3.Error:
return {}, {}
return aggregate_orderbook_observations(
(dict(row) for row in rows),
interval=interval,
min_samples_per_bucket=min_samples_per_bucket,
)
def aggregate_orderbook_observations(
rows: Iterable[dict[str, Any]],
*,
interval: str,
min_samples_per_bucket: int = 20,
) -> tuple[dict[str, dict[int, dict[str, float]]], dict[str, dict[str, Any]]]:
interval_ms = interval_milliseconds(interval)
if interval_ms <= 0:
raise ValueError(f"unsupported orderbook aggregation interval: {interval}")
minimum = max(1, int(min_samples_per_bucket))
buckets: dict[tuple[str, int], list[tuple[float, float, float]]] = defaultdict(list)
raw_counts: dict[str, int] = defaultdict(int)
first_timestamp: dict[str, int] = {}
last_timestamp: dict[str, int] = {}
for row in rows:
symbol = str(row.get("symbol") or "").strip().upper()
timestamp_ms = _observation_timestamp_ms(row)
mid_price = _float(row.get("mid_price"))
microprice = _float(row.get("microprice"), mid_price)
spread_bps = max(0.0, _float(row.get("spread_bps")))
imbalance = max(-1.0, min(1.0, _float(row.get("imbalance"))))
if not symbol or timestamp_ms <= 0 or mid_price <= 0:
continue
microprice_deviation_bps = ((microprice - mid_price) / mid_price) * 10_000.0
if not all(math.isfinite(value) for value in (imbalance, spread_bps, microprice_deviation_bps)):
continue
bucket_timestamp = (timestamp_ms // interval_ms) * interval_ms
buckets[(symbol, bucket_timestamp)].append(
(imbalance, spread_bps, microprice_deviation_bps)
)
raw_counts[symbol] += 1
first_timestamp[symbol] = min(first_timestamp.get(symbol, timestamp_ms), timestamp_ms)
last_timestamp[symbol] = max(last_timestamp.get(symbol, timestamp_ms), timestamp_ms)
features: dict[str, dict[int, dict[str, float]]] = defaultdict(dict)
rejected_buckets: dict[str, int] = defaultdict(int)
for (symbol, bucket_timestamp), samples in sorted(buckets.items()):
if len(samples) < minimum:
rejected_buckets[symbol] += 1
continue
imbalances = [sample[0] for sample in samples]
spreads = [sample[1] for sample in samples]
microprice_deviations = [sample[2] for sample in samples]
features[symbol][bucket_timestamp] = {
"l1_imbalance_mean": _mean(imbalances),
"l1_imbalance_std": _standard_deviation(imbalances),
"l1_spread_bps_mean": _mean(spreads),
"l1_spread_bps_p90": _percentile(spreads, 0.90),
"l1_microprice_deviation_bps_mean": _mean(microprice_deviations),
"l1_microprice_deviation_bps_std": _standard_deviation(microprice_deviations),
"l1_sample_count_log1p": math.log1p(len(samples)),
}
manifest: dict[str, dict[str, Any]] = {}
all_symbols = sorted(set(raw_counts) | set(features))
for symbol in all_symbols:
accepted = features.get(symbol, {})
manifest[symbol] = {
"raw_samples": raw_counts.get(symbol, 0),
"covered_buckets": len(accepted),
"rejected_buckets": rejected_buckets.get(symbol, 0),
"first_timestamp_ms": first_timestamp.get(symbol, 0),
"last_timestamp_ms": last_timestamp.get(symbol, 0),
"min_samples_per_bucket": minimum,
}
return {symbol: dict(rows) for symbol, rows in features.items()}, manifest
def _observation_timestamp_ms(row: dict[str, Any]) -> int:
source_timestamp = int(_float(row.get("source_timestamp_ms")))
if source_timestamp > 0:
return source_timestamp
raw = str(row.get("created_at") or "").strip()
if not raw:
return 0
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return 0
return int(parsed.timestamp() * 1000)
def _mean(values: list[float]) -> float:
return sum(values) / len(values) if values else 0.0
def _standard_deviation(values: list[float]) -> float:
if len(values) < 2:
return 0.0
mean = _mean(values)
return math.sqrt(sum((value - mean) ** 2 for value in values) / len(values))
def _percentile(values: list[float], quantile: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
position = max(0.0, min(1.0, quantile)) * (len(ordered) - 1)
lower = int(math.floor(position))
upper = int(math.ceil(position))
if lower == upper:
return ordered[lower]
fraction = position - lower
return ordered[lower] * (1.0 - fraction) + ordered[upper] * fraction
def _float(value: Any, default: float = 0.0) -> float:
try:
result = float(value)
except (TypeError, ValueError):
return default
return result if math.isfinite(result) else default
+2 -1
View File
@@ -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
] ]
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import math
import os
from typing import Any
from crypto_spot_bot.storage import Storage
def shadow_gate_snapshot(storage: Storage, model_sha256: str) -> dict[str, Any]:
minimum_settled = _int_env("SHADOW_GATE_MIN_SETTLED", 300)
minimum_eligible = _int_env("SHADOW_GATE_MIN_ELIGIBLE", 30)
minimum_symbols = _int_env("SHADOW_GATE_MIN_SYMBOLS", 2)
minimum_profit_factor = _float_env("SHADOW_GATE_MIN_PROFIT_FACTOR", 1.10)
minimum_direction_accuracy = _float_env("SHADOW_GATE_MIN_DIRECTION_ACCURACY", 0.52)
maximum_brier = _float_env("SHADOW_GATE_MAX_BRIER", 0.25)
rows = storage.shadow_prediction_rows(model_sha256=model_sha256) if model_sha256 else []
settled = [row for row in rows if row.get("settled_at")]
eligible = [row for row in settled if bool(row.get("eligible_signal"))]
eligible_returns = [float(row.get("actual_return_percent", 0.0) or 0.0) for row in eligible]
gross_profit = sum(max(0.0, value) for value in eligible_returns)
gross_loss = abs(sum(min(0.0, value) for value in eligible_returns))
profit_factor = gross_profit / gross_loss if gross_loss > 1e-12 else (float("inf") if gross_profit > 0 else 0.0)
correct = sum(
1
for row in settled
if (float(row.get("expected_return_percent", 0.0) or 0.0) >= 0)
== (float(row.get("actual_return_percent", 0.0) or 0.0) >= 0)
)
direction_accuracy = correct / len(settled) if settled else 0.0
brier_values = [
(
max(0.0, min(1.0, float(row.get("probability_up", 0.5) or 0.5)))
- float(int(row.get("take_profit_first", 0) or 0))
)
** 2
for row in settled
if row.get("take_profit_first") is not None
]
brier = sum(brier_values) / len(brier_values) if brier_values else 1.0
symbols = sorted({str(row.get("symbol") or "") for row in eligible if row.get("symbol")})
checks = {
"minimum_settled": len(settled) >= minimum_settled,
"minimum_eligible": len(eligible) >= minimum_eligible,
"minimum_symbols": len(symbols) >= minimum_symbols,
"positive_average_net": bool(eligible_returns) and sum(eligible_returns) / len(eligible_returns) > 0.0,
"profit_factor": profit_factor >= minimum_profit_factor,
"direction_accuracy": direction_accuracy >= minimum_direction_accuracy,
"brier": brier <= maximum_brier,
}
enough_data = checks["minimum_settled"] and checks["minimum_eligible"] and checks["minimum_symbols"]
passed = enough_data and all(checks.values())
state = "passed" if passed else ("failed" if enough_data else "collecting")
return {
"available": bool(model_sha256),
"model_sha256": model_sha256,
"state": state,
"passed": passed,
"active_model_unchanged": True,
"total_predictions": len(rows),
"pending_predictions": len(rows) - len(settled),
"settled_predictions": len(settled),
"eligible_predictions": len(eligible),
"eligible_symbols": symbols,
"average_net_percent": round(sum(eligible_returns) / len(eligible_returns), 6) if eligible_returns else 0.0,
"total_net_percent": round(sum(eligible_returns), 6),
"win_rate": round(sum(value > 0 for value in eligible_returns) / len(eligible_returns), 6) if eligible_returns else 0.0,
"profit_factor": round(profit_factor, 6) if math.isfinite(profit_factor) else None,
"direction_accuracy": round(direction_accuracy, 6),
"brier": round(brier, 6),
"criteria": {
"minimum_settled": minimum_settled,
"minimum_eligible": minimum_eligible,
"minimum_symbols": minimum_symbols,
"minimum_profit_factor": minimum_profit_factor,
"minimum_direction_accuracy": minimum_direction_accuracy,
"maximum_brier": maximum_brier,
},
"checks": checks,
}
def _int_env(name: str, default: int) -> int:
try:
return max(1, int(os.environ.get(name, str(default))))
except ValueError:
return default
def _float_env(name: str, default: float) -> float:
try:
return float(os.environ.get(name, str(default)))
except ValueError:
return default
+641 -32
View File
@@ -2,23 +2,70 @@ 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 datetime, 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
from crypto_spot_bot.orderbook_features import aggregate_orderbook_observations, load_orderbook_feature_map
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,
"market_observations": 1_200_000,
"shadow_predictions": 250_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 +74,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 +95,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 +115,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 +133,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 +157,74 @@ 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 TABLE IF NOT EXISTS market_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
bid_price REAL NOT NULL,
bid_size REAL NOT NULL,
ask_price REAL NOT NULL,
ask_size REAL NOT NULL,
mid_price REAL NOT NULL,
microprice REAL NOT NULL,
spread_bps REAL NOT NULL,
imbalance REAL NOT NULL,
last_price REAL NOT NULL,
source_timestamp_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS shadow_predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_sha256 TEXT NOT NULL,
symbol TEXT NOT NULL,
forecast_timestamp_ms INTEGER NOT NULL,
horizon INTEGER NOT NULL,
reference_price REAL NOT NULL,
expected_return_percent REAL NOT NULL,
probability_up REAL NOT NULL,
eligible_signal INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
settled_at TEXT,
actual_return_percent REAL,
take_profit_first INTEGER,
UNIQUE(model_sha256, symbol, forecast_timestamp_ms, horizon)
);
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);
CREATE INDEX IF NOT EXISTS idx_market_observations_symbol_id
ON market_observations(symbol, id);
CREATE INDEX IF NOT EXISTS idx_market_observations_created
ON market_observations(created_at);
CREATE INDEX IF NOT EXISTS idx_market_observations_symbol_source_timestamp
ON market_observations(symbol, source_timestamp_ms);
CREATE INDEX IF NOT EXISTS idx_shadow_predictions_model_status
ON shadow_predictions(model_sha256, settled_at, symbol);
""" """
) )
columns = { columns = {
@@ -116,6 +240,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 +254,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 +275,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 +293,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,11 +311,52 @@ 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:
rows = conn.execute( conn.execute(
"SELECT * FROM positions WHERE status='OPEN' ORDER BY opened_at" """
).fetchall() 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(
"SELECT * FROM positions WHERE status='OPEN' ORDER BY opened_at"
).fetchall()
return [ return [
Position( Position(
id=int(row["id"]), id=int(row["id"]),
@@ -191,6 +373,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 +388,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 +406,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:
rows = conn.execute("SELECT * FROM trades ORDER BY id DESC LIMIT ?", (limit,)).fetchall() 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()
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,
@@ -255,8 +450,12 @@ class Storage:
COALESCE(SUM(CASE WHEN net_pnl < 0 THEN 1 ELSE 0 END), 0) AS losses COALESCE(SUM(CASE WHEN net_pnl < 0 THEN 1 ELSE 0 END), 0) AS losses
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 +469,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,26 +489,257 @@ 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_market_observation(
self,
*,
symbol: str,
bid_price: float,
bid_size: float,
ask_price: float,
ask_size: float,
mid_price: float,
microprice: float,
spread_bps: float,
imbalance: float,
last_price: float,
source_timestamp_ms: int = 0,
created_at: datetime | None = None,
) -> int:
timestamp = (created_at or utc_now()).isoformat()
with self.connect() as conn:
cursor = conn.execute(
"""
INSERT INTO market_observations (
symbol, bid_price, bid_size, ask_price, ask_size,
mid_price, microprice, spread_bps, imbalance, last_price,
source_timestamp_ms, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
symbol.upper(),
bid_price,
bid_size,
ask_price,
ask_size,
mid_price,
microprice,
spread_bps,
imbalance,
last_price,
max(0, source_timestamp_ms),
timestamp,
),
)
return int(cursor.lastrowid)
def market_observations_after(
self,
*,
symbol: str,
after_id: int = 0,
limit: int = 5000,
) -> list[dict[str, Any]]:
row_limit = max(1, min(limit, 5000))
with self.connect() as conn:
rows = conn.execute(
"""
SELECT * FROM market_observations
WHERE symbol = ? AND id > ?
ORDER BY id
LIMIT ?
""",
(symbol.upper(), max(0, after_id), row_limit),
).fetchall()
return [dict(row) for row in rows]
def market_observation_manifest(self) -> list[dict[str, Any]]:
with self.connect() as conn:
rows = conn.execute(
"""
SELECT symbol, COUNT(*) AS samples, MIN(id) AS min_id, MAX(id) AS max_id,
MIN(source_timestamp_ms) AS first_source_timestamp_ms,
MAX(source_timestamp_ms) AS last_source_timestamp_ms,
MIN(created_at) AS first_created_at,
MAX(created_at) AS last_created_at
FROM market_observations
GROUP BY symbol
ORDER BY symbol
"""
).fetchall()
return [dict(row) for row in rows]
def aggregated_orderbook_features(
self,
*,
interval: str,
symbols: list[str] | None = None,
min_samples_per_bucket: int = 20,
) -> tuple[dict[str, dict[int, dict[str, float]]], dict[str, dict[str, Any]]]:
return load_orderbook_feature_map(
self.path,
interval=interval,
symbols=symbols,
min_samples_per_bucket=min_samples_per_bucket,
)
def recent_aggregated_orderbook_features(
self,
*,
interval: str,
symbols: list[str],
after_timestamp_ms: int,
min_samples_per_bucket: int = 20,
) -> tuple[dict[str, dict[int, dict[str, float]]], dict[str, dict[str, Any]]]:
selected = sorted({symbol.strip().upper() for symbol in symbols if symbol.strip()})
if not selected:
return {}, {}
placeholders = ",".join("?" for _ in selected)
with self.connect() as conn:
rows = conn.execute(
f"""
SELECT symbol, bid_price, bid_size, ask_price, ask_size, mid_price,
microprice, spread_bps, imbalance, source_timestamp_ms, created_at
FROM market_observations
WHERE symbol IN ({placeholders}) AND source_timestamp_ms >= ?
ORDER BY symbol, source_timestamp_ms
""",
(*selected, max(0, int(after_timestamp_ms))),
).fetchall()
return aggregate_orderbook_observations(
(dict(row) for row in rows),
interval=interval,
min_samples_per_bucket=min_samples_per_bucket,
)
def insert_shadow_prediction(
self,
*,
model_sha256: str,
symbol: str,
forecast_timestamp_ms: int,
horizon: int,
reference_price: float,
expected_return_percent: float,
probability_up: float,
eligible_signal: bool,
) -> bool:
with self.connect() as conn:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO shadow_predictions (
model_sha256, symbol, forecast_timestamp_ms, horizon,
reference_price, expected_return_percent, probability_up,
eligible_signal, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
model_sha256,
symbol.upper(),
max(0, int(forecast_timestamp_ms)),
max(1, int(horizon)),
max(0.0, float(reference_price)),
float(expected_return_percent),
max(0.0, min(1.0, float(probability_up))),
1 if eligible_signal else 0,
utc_now().isoformat(),
),
)
return bool(cursor.rowcount)
def pending_shadow_predictions(
self,
*,
model_sha256: str,
symbol: str,
limit: int = 500,
) -> list[dict[str, Any]]:
with self.connect() as conn:
rows = conn.execute(
"""
SELECT * FROM shadow_predictions
WHERE model_sha256 = ? AND symbol = ? AND settled_at IS NULL
ORDER BY forecast_timestamp_ms
LIMIT ?
""",
(model_sha256, symbol.upper(), max(1, min(5000, int(limit)))),
).fetchall()
return [dict(row) for row in rows]
def settle_shadow_prediction(
self,
prediction_id: int,
*,
actual_return_percent: float,
take_profit_first: bool,
) -> bool:
with self.connect() as conn:
cursor = conn.execute(
"""
UPDATE shadow_predictions
SET settled_at = ?, actual_return_percent = ?, take_profit_first = ?
WHERE id = ? AND settled_at IS NULL
""",
(
utc_now().isoformat(),
float(actual_return_percent),
1 if take_profit_first else 0,
int(prediction_id),
),
)
return bool(cursor.rowcount)
def shadow_prediction_rows(
self,
*,
model_sha256: str,
settled_only: bool = False,
limit: int = 250_000,
) -> list[dict[str, Any]]:
where = "WHERE model_sha256 = ?"
if settled_only:
where += " AND settled_at IS NOT NULL"
with self.connect() as conn:
rows = conn.execute(
f"SELECT * FROM shadow_predictions {where} ORDER BY id DESC LIMIT ?",
(model_sha256, max(1, min(250_000, int(limit)))),
).fetchall()
return [dict(row) for row in rows]
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:
row = conn.execute("SELECT * FROM equity ORDER BY id DESC LIMIT 1").fetchone() 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()
return dict(row) if row else None return dict(row) if row else None
def event(self, message: str, level: str = "INFO") -> None: def event(self, message: str, level: str = "INFO") -> None:
@@ -376,12 +814,183 @@ 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",
"market_observations",
"shadow_predictions",
):
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",
"market_observations",
):
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)
+318 -21
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from crypto_spot_bot.config import Settings from crypto_spot_bot.config import Settings
from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now
@@ -25,6 +27,59 @@ 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_mode = _effective_fallback_mode(self.settings)
if fallback_mode == "legacy":
fallback_settings = replace(
self.settings,
strategy_mode="legacy",
time_series_forecast_enabled=False,
)
fallback = SpotStrategy(fallback_settings).entry_signal(
symbol,
candles,
ticker,
open_positions_for_symbol,
pattern,
learning,
llm,
{},
account,
trend_candles,
)
trade_mode = "LEGACY_FALLBACK"
entry_path = "legacy_fallback"
else:
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,
)
trade_mode = "TREND_MACD_FALLBACK"
entry_path = "trend_macd_fallback"
diagnostics = dict(fallback.diagnostics)
diagnostics.update(
{
"strategy_mode": "torch_forecast",
"trade_mode": trade_mode,
"entry_path": entry_path,
"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,
@@ -316,6 +371,7 @@ class SpotStrategy:
latest = candles[-1] latest = candles[-1]
previous = candles[-2] if len(candles) >= 2 else latest previous = candles[-2] if len(candles) >= 2 else latest
price = ticker.last_price price = ticker.last_price
adaptive = _adaptive_rules(learning or {})
trailing = position.trailing_stop(self.settings.trailing_stop_percent) trailing = position.trailing_stop(self.settings.trailing_stop_percent)
diagnostics = { diagnostics = {
"price": price, "price": price,
@@ -328,8 +384,11 @@ class SpotStrategy:
"rsi_14": latest.rsi_14, "rsi_14": latest.rsi_14,
"ema_20": latest.ema_20, "ema_20": latest.ema_20,
"ema_50": latest.ema_50, "ema_50": latest.ema_50,
"adaptive_rules": adaptive,
} }
if self.settings.stop_loss_exit_enabled and price <= position.stop_loss: if self.settings.stop_loss_exit_enabled and price <= position.stop_loss:
diagnostics["emergency_exit"] = True
diagnostics["emergency_exit_type"] = "configured_stop_loss"
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
if price >= position.take_profit: if price >= position.take_profit:
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
@@ -368,6 +427,53 @@ 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":
entry_path = str(position.entry_diagnostics.get("entry_path", ""))
if entry_path == "legacy_fallback":
fallback_settings = replace(
self.settings,
strategy_mode="legacy",
time_series_forecast_enabled=False,
)
fallback = SpotStrategy(fallback_settings)._legacy_exit_signal(
position,
candles,
ticker,
learning,
)
diagnostics = dict(fallback.diagnostics)
diagnostics.update(
{
"strategy_mode": "torch_forecast",
"trade_mode": "LEGACY_FALLBACK",
"entry_path": "legacy_fallback",
"forecast_fallback_active": True,
}
)
return Signal(
fallback.symbol,
fallback.action,
fallback.confidence,
f"torch_forecast fallback: {fallback.reason}",
diagnostics,
)
if 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 +500,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,9 +515,12 @@ 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:
diagnostics["emergency_exit"] = True
diagnostics["emergency_exit_type"] = "configured_stop_loss"
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics) return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
if price >= effective_take_profit: if price >= effective_take_profit:
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics) return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
@@ -438,6 +548,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
@@ -484,6 +595,12 @@ def _has_entry_indicators(candle: Candle) -> bool:
) )
def _effective_fallback_mode(settings: Settings) -> str:
if settings.trading_mode != "paper":
return "trend_macd"
return settings.time_series_fallback_mode
def _trend_macd_entry_signal( def _trend_macd_entry_signal(
*, *,
settings: Settings, settings: Settings,
@@ -605,6 +722,8 @@ def _trend_macd_exit_signal(
"close_below_ema50": close_below_ema50, "close_below_ema50": close_below_ema50,
} }
if effective_stop_loss is not None and price <= effective_stop_loss: if effective_stop_loss is not None and price <= effective_stop_loss:
diagnostics["emergency_exit"] = True
diagnostics["emergency_exit_type"] = "configured_stop_loss"
return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics) return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics)
if atr_trailing_stop is not None and price <= atr_trailing_stop: if atr_trailing_stop is not None and price <= atr_trailing_stop:
return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics) return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics)
@@ -639,10 +758,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 +798,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 +830,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 +868,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 +906,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 +1014,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,26 +1047,53 @@ 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()
diagnostics["hold_seconds"] = hold_seconds diagnostics["hold_seconds"] = hold_seconds
diagnostics["min_hold_seconds"] = settings.min_hold_seconds diagnostics["min_hold_seconds"] = settings.min_hold_seconds
if effective_stop_loss is not None and price <= effective_stop_loss: if effective_stop_loss is not None and price <= effective_stop_loss:
diagnostics["emergency_exit"] = True
diagnostics["emergency_exit_type"] = "configured_stop_loss"
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics) return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics)
if price >= position.take_profit: 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 < 0: if estimated_exit_net_percent < min_exit_net_percent:
diagnostics["atr_exit_blocked_by_cost"] = True diagnostics["atr_exit_blocked_by_min_profit"] = True
if estimated_exit_net_percent < 0:
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 +1131,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_cost"] = True diagnostics["forecast_exit_blocked_by_min_profit"] = True
if estimated_exit_net_percent < 0:
diagnostics["forecast_exit_blocked_by_cost"] = True
return Signal( return Signal(
position.symbol, position.symbol,
"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 +1161,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 +1171,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 +1211,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 +1239,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 +1394,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 +1431,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 +1732,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 +1835,100 @@ 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 apply_profit_only_exit_policy(
settings: Settings,
position: Position,
ticker: Ticker,
signal: Signal,
) -> Signal:
"""Block every ordinary exit that would realize less than the configured net profit.
The estimate mirrors the paper broker fill calculation. Live fills can still differ,
so the configured minimum also acts as a safety margin. A loss-making exit is only
allowed when the producing subsystem marks it explicitly as an emergency.
"""
if signal.action != "SELL" or not settings.profit_only_exit_enabled:
return signal
diagnostics = dict(signal.diagnostics)
expected_fill_price = _expected_sell_fill_price(ticker, settings)
expected_net_usdt = _expected_exit_net_usdt(position, expected_fill_price, settings)
expected_net_percent = (
expected_net_usdt / position.notional_usdt * 100
if position.notional_usdt > 0
else 0.0
)
adaptive = diagnostics.get("adaptive_rules")
adaptive_minimum = (
_safe_float(adaptive.get("min_exit_profit_percent"), 0.0)
if isinstance(adaptive, dict)
else 0.0
)
signal_minimum = _safe_float(diagnostics.get("min_exit_profit_percent"), 0.0)
minimum_net_percent = max(
_min_exit_net_percent(settings),
adaptive_minimum,
signal_minimum,
)
emergency = diagnostics.get("emergency_exit") is True
diagnostics.update(
{
"exit_policy": "profit_only",
"profit_only_exit_enabled": True,
"expected_exit_fill_price": round(expected_fill_price, 12),
"expected_exit_net_usdt": round(expected_net_usdt, 8),
"expected_exit_net_percent": round(expected_net_percent, 4),
"required_exit_net_percent": round(minimum_net_percent, 4),
"emergency_exit": emergency,
}
)
if emergency or expected_net_percent + 1e-9 >= minimum_net_percent:
diagnostics["exit_policy_blocked"] = False
return Signal(
signal.symbol,
signal.action,
signal.confidence,
signal.reason,
diagnostics,
signal.created_at,
)
diagnostics.update(
{
"exit_policy_blocked": True,
"blocked_sell_reason": signal.reason,
"blocked_sell_confidence": signal.confidence,
}
)
return Signal(
signal.symbol,
"HOLD",
min(signal.confidence, 0.49),
(
"profit-only: продажа заблокирована, ожидаемая чистая доходность "
f"{expected_net_percent:.4f}% ниже минимума {minimum_net_percent:.4f}%"
),
diagnostics,
signal.created_at,
)
def _expected_sell_fill_price(ticker: Ticker, settings: Settings) -> float:
base = ticker.bid if ticker.bid > 0 else ticker.last_price
return base * (1 - settings.slippage_rate)
def _expected_exit_net_usdt(position: Position, fill_price: float, settings: Settings) -> float:
exit_notional = position.qty * fill_price
exit_fee = exit_notional * settings.taker_fee_rate
gross_pnl = (fill_price - position.entry_price) * position.qty
return gross_pnl - position.entry_fee_usdt - exit_fee
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 +1945,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)
+333 -20
View File
@@ -2,12 +2,16 @@ from __future__ import annotations
import json import json
import math import math
import hashlib
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 pathlib import Path
from typing import Any from typing import Any
from crypto_spot_bot.config import Settings from crypto_spot_bot.config import Settings
from crypto_spot_bot.models import Candle from crypto_spot_bot.models import Candle
from crypto_spot_bot.orderbook_features import ORDERBOOK_FEATURES
DEFAULT_TORCH_FEATURES = ( DEFAULT_TORCH_FEATURES = (
@@ -156,14 +160,31 @@ 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)
class TimeSeriesForecaster: class TimeSeriesForecaster:
def __init__(self, settings: Settings): def __init__(
self,
settings: Settings,
*,
model_path: Path | None = None,
calibration_path: Path | None = None,
):
self.settings = settings self.settings = settings
self.model_path = model_path or settings.time_series_lstm_model_path
self.calibration_path = calibration_path or (
self.model_path.parent / "torch_threshold_calibration.json"
)
self._lstm_artifact_mtime: float | None = None self._lstm_artifact_mtime: float | None = None
self._lstm_artifact: dict[str, Any] = {} self._lstm_artifact: dict[str, Any] = {}
self._calibration_mtime: float | None = None self._calibration_mtime: float | None = None
@@ -176,6 +197,7 @@ class TimeSeriesForecaster:
*, *,
market_candles: dict[str, list[Candle]] | None = None, market_candles: dict[str, list[Candle]] | None = None,
trend_candles: list[Candle] | None = None, trend_candles: list[Candle] | None = None,
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
) -> TimeSeriesForecast: ) -> TimeSeriesForecast:
if not self.settings.time_series_forecast_enabled: if not self.settings.time_series_forecast_enabled:
return _empty_forecast(False, "time-series forecast is disabled") return _empty_forecast(False, "time-series forecast is disabled")
@@ -188,8 +210,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)
@@ -200,6 +239,7 @@ class TimeSeriesForecaster:
symbol=symbol, symbol=symbol,
market_candles=market_candles, market_candles=market_candles,
trend_candles=trend_candles, trend_candles=trend_candles,
orderbook_features=orderbook_features,
) )
if entry if entry
else [] else []
@@ -230,6 +270,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 +282,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 +292,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 = (
model=model, _barrier_reason(model, expected_return_percent, probability_up, skill, block_entry)
expected_return_percent=expected_return_percent, if target_transform == "barrier_net_return"
probability_up=probability_up, else _reason(
skill=skill, model=model,
block_entry=block_entry, expected_return_percent=expected_return_percent,
probability_up=probability_up,
skill=skill,
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 +334,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 +370,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 +378,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 +388,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,12 +417,18 @@ 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]:
if not self.settings.time_series_lstm_enabled: if not self.settings.time_series_lstm_enabled:
return {} return {}
path = self.settings.time_series_lstm_model_path path = self.model_path
try: try:
stat = path.stat() stat = path.stat()
except OSError: except OSError:
@@ -373,7 +446,7 @@ class TimeSeriesForecaster:
return self._lstm_artifact return self._lstm_artifact
def _load_quality_gate(self) -> dict[str, Any]: def _load_quality_gate(self) -> dict[str, Any]:
path = self.settings.time_series_lstm_model_path.parent / "torch_threshold_calibration.json" path = self.calibration_path
try: try:
stat = path.stat() stat = path.stat()
except OSError: except OSError:
@@ -386,11 +459,16 @@ 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
def artifact_sha256(self) -> str:
try:
return hashlib.sha256(self.model_path.read_bytes()).hexdigest()
except OSError:
return ""
def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast: def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
return TimeSeriesForecast( return TimeSeriesForecast(
@@ -420,12 +498,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 +520,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))]
@@ -447,6 +575,7 @@ def _feature_matrix(
symbol: str | None = None, symbol: str | None = None,
market_candles: dict[str, list[Candle]] | None = None, market_candles: dict[str, list[Candle]] | None = None,
trend_candles: list[Candle] | None = None, trend_candles: list[Candle] | None = None,
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
) -> list[list[float]]: ) -> list[list[float]]:
names = list(feature_names or DEFAULT_TORCH_FEATURES) names = list(feature_names or DEFAULT_TORCH_FEATURES)
context = _feature_context( context = _feature_context(
@@ -454,6 +583,7 @@ def _feature_matrix(
symbol=symbol, symbol=symbol,
market_candles=market_candles, market_candles=market_candles,
trend_candles=trend_candles, trend_candles=trend_candles,
orderbook_features=orderbook_features,
) )
rows: list[list[float]] = [] rows: list[list[float]] = []
for index, candle in enumerate(candles): for index, candle in enumerate(candles):
@@ -467,6 +597,7 @@ def _feature_context(
symbol: str | None, symbol: str | None,
market_candles: dict[str, list[Candle]] | None, market_candles: dict[str, list[Candle]] | None,
trend_candles: list[Candle] | None, trend_candles: list[Candle] | None,
orderbook_features: dict[str, dict[int, dict[str, float]]] | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
market_candles = market_candles or {} market_candles = market_candles or {}
normalized_market = {key.upper(): value for key, value in market_candles.items()} normalized_market = {key.upper(): value for key, value in market_candles.items()}
@@ -488,12 +619,23 @@ def _feature_context(
"context_indexes": context_indexes, "context_indexes": context_indexes,
"trend_candles": trend_rows, "trend_candles": trend_rows,
"trend_positions": trend_positions, "trend_positions": trend_positions,
"orderbook_features": {
key.upper(): value for key, value in (orderbook_features or {}).items()
},
} }
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 in ORDERBOOK_FEATURES:
symbol_features = (context.get("orderbook_features") or {}).get(
context.get("symbol"), {}
)
values = symbol_features.get(candle.timestamp, {})
return _safe_feature(float(values.get(name, 0.0) or 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 +1064,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 +1106,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 +1191,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 +1331,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 +1366,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 +1801,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 +1836,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 +1884,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 +1960,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)))
+517 -57
View File
@@ -2,8 +2,12 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import hmac
import json import json
import os import os
import re
import secrets
import shutil
import uuid import uuid
from datetime import UTC from datetime import UTC
from datetime import datetime from datetime import datetime
@@ -13,13 +17,26 @@ from threading import Lock
from typing import Any from typing import Any
ALLOWED_TRAINING_ARTIFACTS = { ACTIVE_TRAINING_ARTIFACTS = {
"lstm_forecaster.json", "lstm_forecaster.json",
"torch_retrain_guard.json", "torch_retrain_guard.json",
"torch_threshold_calibration.json", "torch_threshold_calibration.json",
} }
RUNNING_TIMEOUT = timedelta(hours=12) SHADOW_TRAINING_ARTIFACTS = {
"lstm_forecaster.shadow.json",
"torch_shadow_guard.json",
"torch_shadow_calibration.json",
}
ALLOWED_TRAINING_ARTIFACTS = ACTIVE_TRAINING_ARTIFACTS | SHADOW_TRAINING_ARTIFACTS
RUNNING_LEASE_TIMEOUT = timedelta(minutes=10)
ONLINE_WINDOW = timedelta(minutes=3) ONLINE_WINDOW = timedelta(minutes=3)
MAX_JOB_ATTEMPTS = 3
MAX_ARTIFACT_CHUNK_BYTES = 1024 * 1024
# Keep uploads bounded while leaving room for explicitly requested per-symbol bundles.
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
MAX_ARTIFACT_CHUNKS = 1024
REQUIRED_MODEL_BUNDLE = set(ACTIVE_TRAINING_ARTIFACTS)
REQUIRED_SHADOW_BUNDLE = set(SHADOW_TRAINING_ARTIFACTS)
class TrainingCoordinator: class TrainingCoordinator:
@@ -36,6 +53,61 @@ class TrainingCoordinator:
self._save_state(state) self._save_state(state)
return self._public_status(state) return self._public_status(state)
def promote_shadow(self, forward_gate: dict[str, Any]) -> dict[str, Any]:
with self._lock:
if not bool(forward_gate.get("passed")):
raise ValueError("shadow forward gate has not passed")
shadow_model = self.runtime_dir / "lstm_forecaster.shadow.json"
shadow_calibration = self.runtime_dir / "torch_shadow_calibration.json"
shadow_guard = self.runtime_dir / "torch_shadow_guard.json"
missing = [
path.name
for path in (shadow_model, shadow_calibration, shadow_guard)
if not path.is_file()
]
if missing:
raise ValueError("shadow bundle is incomplete: " + ", ".join(missing))
model_sha256 = hashlib.sha256(shadow_model.read_bytes()).hexdigest()
if str(forward_gate.get("model_sha256") or "") != model_sha256:
raise ValueError("shadow forward gate is bound to another model")
calibration = _read_json(shadow_calibration)
guard = _read_json(shadow_guard)
if calibration.get("artifact_sha256") != model_sha256:
raise ValueError("shadow calibration is not bound to the model")
if not bool(guard.get("accepted")) or guard.get("candidate_artifact_sha256") != model_sha256:
raise ValueError("shadow offline guard is invalid")
promotion_id = str(uuid.uuid4())
backup_dir = self.runtime_dir / ".model_backups" / f"{_compact_now()}-shadow-{promotion_id}"
backup_dir.mkdir(parents=True, exist_ok=True)
targets = {
"lstm_forecaster.json": shadow_model,
"torch_threshold_calibration.json": shadow_calibration,
"torch_retrain_guard.json": shadow_guard,
}
for target_name in targets:
current = self.runtime_dir / target_name
if current.is_file():
shutil.copy2(current, backup_dir / target_name)
for target_name, source in targets.items():
target_tmp = self.runtime_dir / f".{target_name}.{promotion_id}.promote"
shutil.copy2(source, target_tmp)
os.replace(target_tmp, self.runtime_dir / target_name)
gate_path = self.runtime_dir / "torch_shadow_forward_gate.json"
gate_tmp = gate_path.with_suffix(".tmp")
gate_tmp.write_text(
json.dumps(forward_gate, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.replace(gate_tmp, gate_path)
return {
"promoted": True,
"model_sha256": model_sha256,
"promotion_id": promotion_id,
"backup_dir": str(backup_dir),
"promoted_at": _now(),
}
def request_retrain(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: def request_retrain(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {} payload = payload or {}
with self._lock: with self._lock:
@@ -44,7 +116,12 @@ class TrainingCoordinator:
existing = self._active_job(state) existing = self._active_job(state)
if existing is not None: if existing is not None:
self._save_state(state) self._save_state(state)
return {"queued": False, "reason": "active_job_exists", "job": existing, "status": self._public_status(state)} return {
"queued": False,
"reason": "active_job_exists",
"job": self._public_job(existing),
"status": self._public_status(state),
}
now = _now() now = _now()
job = { job = {
@@ -55,11 +132,16 @@ class TrainingCoordinator:
"parameters": _safe_parameters(payload.get("parameters")), "parameters": _safe_parameters(payload.get("parameters")),
"message": "", "message": "",
"artifacts": [], "artifacts": [],
"attempts": 0,
} }
state.setdefault("jobs", []).append(job) state.setdefault("jobs", []).append(job)
self._trim_jobs(state) self._trim_jobs(state)
self._save_state(state) self._save_state(state)
return {"queued": True, "job": job, "status": self._public_status(state)} return {
"queued": True,
"job": self._public_job(job),
"status": self._public_status(state),
}
def heartbeat(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: def heartbeat(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {} payload = payload or {}
@@ -83,14 +165,24 @@ class TrainingCoordinator:
return {"claimed": False, "job": None, "status": self._public_status(state)} return {"claimed": False, "job": None, "status": self._public_status(state)}
now = _now() now = _now()
lease_token = secrets.token_urlsafe(32)
job["status"] = "running" job["status"] = "running"
job["claimed_at"] = now job["claimed_at"] = now
job["updated_at"] = now
job["claimed_by"] = worker["id"] job["claimed_by"] = worker["id"]
job["worker"] = worker job["worker"] = worker
job["lease_token"] = lease_token
job["attempts"] = int(job.get("attempts", 0)) + 1
self._save_state(state) self._save_state(state)
return {"claimed": True, "job": job, "status": self._public_status(state)} return {
"claimed": True,
"job": self._public_job(job),
"lease_token": lease_token,
"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,73 +191,126 @@ 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
chunk_dir.mkdir(parents=True, exist_ok=True)
(chunk_dir / f"{index:06d}.part").write_bytes(chunk)
if not all((chunk_dir / f"{part:06d}.part").is_file() for part in range(total)):
return {"complete": False, "received": index + 1, "total": total}
target_tmp = self.runtime_dir / f".{name}.{job_id}.tmp"
digest = hashlib.sha256()
with target_tmp.open("wb") as output:
for part in range(total):
data = (chunk_dir / f"{part:06d}.part").read_bytes()
digest.update(data)
output.write(data)
if digest.hexdigest().lower() != sha256:
target_tmp.unlink(missing_ok=True)
raise ValueError("artifact sha256 mismatch")
self.runtime_dir.mkdir(parents=True, exist_ok=True)
os.replace(target_tmp, self.runtime_dir / name)
_remove_tree(chunk_dir)
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is not None:
artifacts = job.setdefault("artifacts", [])
artifacts = [item for item in artifacts if item.get("name") != name]
artifacts.append({"name": name, "sha256": sha256, "uploaded_at": _now()})
job["artifacts"] = artifacts
self._save_state(state)
return {"complete": True, "name": name, "sha256": sha256}
def progress(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock: 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")
self._require_lease(job, payload)
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 / f"{index:06d}.part").write_bytes(chunk)
received = sum(1 for part in range(total) if (chunk_dir / f"{part:06d}.part").is_file())
if received < total:
upload["received"] = received
job["updated_at"] = _now()
self._save_state(state)
return {"complete": False, "received": received, "total": total}
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()
size = 0
with target_tmp.open("wb") as output:
for part in range(total):
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)
output.write(data)
if digest.hexdigest().lower() != sha256:
target_tmp.unlink(missing_ok=True)
raise ValueError("artifact sha256 mismatch")
target = ready_dir / name
os.replace(target_tmp, target)
_remove_tree(chunk_dir)
artifacts = job.setdefault("artifacts", [])
artifacts = [item for item in artifacts if item.get("name") != name]
artifacts.append(
{"name": name, "sha256": sha256, "size": size, "staged_at": _now()}
)
job["artifacts"] = artifacts
job["updated_at"] = _now()
upload["received"] = total
upload["complete"] = True
self._save_state(state)
return {"complete": True, "staged": True, "name": name, "sha256": sha256}
def progress(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
job_id = _valid_job_id(job_id)
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")
self._require_lease(job, payload)
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):
job["details"] = payload["details"] job["details"] = payload["details"]
self._save_state(state) self._save_state(state)
return {"ok": True, "job": job, "status": self._public_status(state)} return {
"ok": True,
"job": self._public_job(job),
"status": self._public_status(state),
}
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")
self._require_lease(job, payload)
success = bool(payload.get("success", payload.get("status") == "completed")) success = bool(payload.get("success", payload.get("status") == "completed"))
if success and job.get("artifacts"):
artifact_names = {
str(item.get("name"))
for item in job.get("artifacts", [])
if isinstance(item, dict)
}
if artifact_names & REQUIRED_SHADOW_BUNDLE:
staged = self._validate_and_stage_shadow(job_id, job)
job["shadow_artifacts"] = staged
else:
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,8 +318,132 @@ 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 str(payload["summary"].get("state") or "").startswith("collecting"):
job["model_decision"] = "collecting"
elif isinstance(payload["summary"].get("accepted"), bool):
job["model_decision"] = (
"accepted" if payload["summary"]["accepted"] else "rejected"
)
job.pop("lease_token", None)
self._save_state(state) self._save_state(state)
return {"ok": True, "job": job, "status": self._public_status(state)} return {
"ok": True,
"job": self._public_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 _validate_and_stage_shadow(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_SHADOW_BUNDLE - staged
if missing:
raise ValueError("shadow training bundle is incomplete: " + ", ".join(sorted(missing)))
model_path = ready_dir / "lstm_forecaster.shadow.json"
calibration_path = ready_dir / "torch_shadow_calibration.json"
guard_path = ready_dir / "torch_shadow_guard.json"
model = _read_json(model_path)
calibration = _read_json(calibration_path)
guard = _read_json(guard_path)
if model.get("type") != "pytorch_recurrent_forecaster":
raise ValueError("shadow candidate model type is invalid")
symbols = model.get("symbols")
if not isinstance(symbols, dict) or not symbols:
raise ValueError("shadow candidate model has no symbol models")
_validate_symbol_models(symbols)
model_sha256 = hashlib.sha256(model_path.read_bytes()).hexdigest()
if calibration.get("artifact_sha256") != model_sha256:
raise ValueError("shadow calibration is not bound to the uploaded model")
if not bool(guard.get("accepted")):
raise ValueError("shadow candidate did not pass the offline guard")
if guard.get("candidate_artifact_sha256") != model_sha256:
raise ValueError("shadow guard is not bound to the uploaded model")
validation = calibration.get("validation")
if not isinstance(validation, dict) or not _validation_passed(validation):
raise ValueError("shadow candidate offline quality gate did not pass")
if validation.get("protocol") != "untouched_model_holdout_with_threshold_walk_forward":
raise ValueError("shadow candidate validation protocol is not an untouched holdout")
self.runtime_dir.mkdir(parents=True, exist_ok=True)
artifact_rows = {
str(item.get("name")): item
for item in job.get("artifacts", [])
if isinstance(item, dict)
}
installed: list[dict[str, Any]] = []
for name in sorted(REQUIRED_SHADOW_BUNDLE):
target_tmp = self.runtime_dir / f".{name}.{job_id}.stage"
shutil.copy2(ready_dir / name, target_tmp)
os.replace(target_tmp, self.runtime_dir / name)
row = artifact_rows.get(name, {})
installed.append(
{
"name": name,
"sha256": row.get("sha256", ""),
"staged_at": _now(),
}
)
_remove_tree(self.upload_root / job_id)
return installed
def _load_state(self) -> dict[str, Any]: def _load_state(self) -> dict[str, Any]:
try: try:
@@ -193,10 +462,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(),
} }
@@ -219,11 +489,27 @@ class TrainingCoordinator:
"agent_recently_seen": recently_seen, "agent_recently_seen": recently_seen,
"agent_busy": agent_busy, "agent_busy": agent_busy,
"worker": worker, "worker": worker,
"active_job": active, "active_job": self._public_job(active),
"latest_job": latest, "latest_job": self._public_job(latest),
"pending_jobs": sum(1 for job in state.get("jobs", []) if job.get("status") == "pending"), "pending_jobs": sum(1 for job in state.get("jobs", []) if job.get("status") == "pending"),
} }
@staticmethod
def _public_job(job: dict[str, Any] | None) -> dict[str, Any] | None:
if job is None:
return None
public = dict(job)
public.pop("lease_token", None)
public.pop("uploads", None)
return public
@staticmethod
def _require_lease(job: dict[str, Any], payload: dict[str, Any]) -> None:
expected = str(job.get("lease_token") or "")
supplied = str(payload.get("lease_token") or "")
if not expected or not supplied or not hmac.compare_digest(expected, supplied):
raise ValueError("training job lease is invalid or expired")
def _active_job(self, state: dict[str, Any]) -> dict[str, Any] | None: def _active_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
for job in reversed(state.get("jobs", [])): for job in reversed(state.get("jobs", [])):
if job.get("status") in {"pending", "running"}: if job.get("status") in {"pending", "running"}:
@@ -247,11 +533,30 @@ class TrainingCoordinator:
for job in state.get("jobs", []): for job in state.get("jobs", []):
if job.get("status") != "running": if job.get("status") != "running":
continue continue
claimed_at = _parse_time(str(job.get("claimed_at") or "")) lease_updated_at = _parse_time(
if claimed_at and now - claimed_at > RUNNING_TIMEOUT: str(job.get("updated_at") or job.get("claimed_at") or "")
)
if not lease_updated_at or now - lease_updated_at <= RUNNING_LEASE_TIMEOUT:
continue
job_id = str(job.get("id") or "")
if job_id:
_remove_tree(self.upload_root / job_id)
job.pop("lease_token", None)
job.pop("uploads", None)
attempts = int(job.get("attempts", 0))
if attempts < MAX_JOB_ATTEMPTS:
job["status"] = "pending"
job["phase"] = "queued"
job["progress_percent"] = 0
job["message"] = "training worker lease expired; queued for retry"
job["retry_queued_at"] = _now()
for key in ("claimed_at", "claimed_by", "worker", "updated_at"):
job.pop(key, None)
else:
job["status"] = "failed" job["status"] = "failed"
job["phase"] = "failed"
job["completed_at"] = _now() job["completed_at"] = _now()
job["message"] = "training worker timeout" job["message"] = "training worker lease expired after maximum retries"
def _trim_jobs(self, state: dict[str, Any]) -> None: def _trim_jobs(self, state: dict[str, Any]) -> None:
jobs = state.get("jobs", []) jobs = state.get("jobs", [])
@@ -262,8 +567,163 @@ 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",
"use_orderbook",
"orderbook_min_samples_per_bucket",
"orderbook_min_covered_buckets",
"orderbook_min_symbols",
}
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),
("orderbook_min_samples_per_bucket", 1, 5000),
("orderbook_min_covered_buckets", 96, 20000),
("orderbook_min_symbols", 1, 30),
):
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
if "use_orderbook" in result:
result["use_orderbook"] = result["use_orderbook"] 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:
+410
View File
@@ -0,0 +1,410 @@
:root {
--bg: #090b0f;
--surface: #101319;
--surface-2: #151920;
--surface-3: #1b2029;
--line: #242a34;
--line-soft: #1b2028;
--text: #f4f6f8;
--muted: #8d96a5;
--dim: #626b79;
--green: #26d99a;
--green-soft: #10271f;
--red: #ff6275;
--red-soft: #2b151b;
--amber: #f3b34c;
--amber-soft: #2a2113;
--blue: #7891ff;
--sidebar: 224px;
--radius: 10px;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
font-synthesis: none;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body {
min-height: 100vh;
margin: 0;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
button, input { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
a { color: inherit; text-decoration: none; }
.app-shell { min-height: 100vh; }
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: 20;
display: flex;
width: var(--sidebar);
flex-direction: column;
border-right: 1px solid var(--line-soft);
background: #0c0f14;
}
.brand {
display: flex;
min-height: 86px;
align-items: center;
gap: 12px;
padding: 0 24px;
border-bottom: 1px solid var(--line-soft);
}
.brand-mark, .dialog-icon {
display: grid;
width: 35px;
height: 35px;
place-items: center;
border: 1px solid #2d8c6d;
border-radius: 8px;
background: #10231d;
color: var(--green);
font-weight: 850;
letter-spacing: -.04em;
}
.brand strong { display: block; font-size: 15px; letter-spacing: .01em; }
.brand small { display: block; margin-top: 3px; color: var(--dim); font-size: 10px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }
.nav-list { display: grid; gap: 5px; padding: 22px 14px; }
.nav-item {
display: flex;
width: 100%;
align-items: center;
gap: 13px;
padding: 11px 12px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 13px;
font-weight: 650;
text-align: left;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.nav-item:hover { background: var(--surface); color: var(--text); }
.nav-item.is-active { border-color: #25352f; background: #111b18; color: var(--green); }
.nav-item svg { width: 18px; height: 18px; flex: 0 0 auto; fill: currentColor; }
.sidebar-foot { margin-top: auto; padding: 20px 18px 22px; border-top: 1px solid var(--line-soft); }
.connection-mini { display: flex; align-items: center; gap: 10px; }
.connection-mini strong { display: block; font-size: 11px; font-weight: 700; }
.connection-mini small { display: block; margin-top: 3px; color: var(--dim); font-size: 10px; }
.live-dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 4px rgba(243,179,76,.09); }
.live-dot.is-online { background: var(--green); box-shadow: 0 0 0 4px rgba(38,217,154,.09); }
.live-dot.is-offline { background: var(--red); box-shadow: 0 0 0 4px rgba(255,98,117,.09); }
.version-line { margin-top: 17px; color: var(--dim); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; }
.main { min-height: 100vh; margin-left: var(--sidebar); }
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
min-height: 86px;
align-items: center;
justify-content: space-between;
padding: 0 32px;
border-bottom: 1px solid var(--line-soft);
background: rgba(9,11,15,.94);
backdrop-filter: blur(14px);
}
.eyebrow { margin: 0 0 7px; color: var(--dim); font-size: 9px; font-weight: 800; letter-spacing: .17em; text-transform: uppercase; }
.topbar h1 { margin: 0; font-size: 21px; font-weight: 720; letter-spacing: -.02em; }
.topbar-actions { display: flex; align-items: center; gap: 12px; }
.sync-label { color: var(--dim); font-size: 11px; }
.badge {
display: inline-flex;
min-height: 24px;
align-items: center;
justify-content: center;
padding: 0 9px;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--surface-2);
color: var(--muted);
font-size: 9px;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
}
.badge-mode { color: var(--amber); }
.badge.is-good { border-color: #245c49; background: var(--green-soft); color: var(--green); }
.badge.is-warn { border-color: #5e4927; background: var(--amber-soft); color: var(--amber); }
.badge.is-bad { border-color: #63313a; background: var(--red-soft); color: var(--red); }
.icon-button {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface);
cursor: pointer;
}
.icon-button:hover { background: var(--surface-2); }
.icon-button svg { width: 16px; height: 16px; fill: var(--muted); }
.icon-button.is-spinning svg { animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.page { display: none; max-width: 1480px; margin: 0 auto; padding: 26px 32px 48px; }
.page.is-active { display: block; }
.offline-banner {
margin: 18px 32px 0;
padding: 11px 14px;
border: 1px solid #63313a;
border-radius: 7px;
background: var(--red-soft);
color: var(--red);
font-size: 12px;
}
.offline-banner span { margin-left: 5px; color: #dba3aa; }
.card { border: 1px solid var(--line-soft); border-radius: var(--radius); background: var(--surface); }
.hero {
position: relative;
display: grid;
min-height: 250px;
grid-template-columns: minmax(0, 1.5fr) minmax(300px, .7fr);
overflow: hidden;
border-color: #25322e;
background-color: #0f1514;
background-image: linear-gradient(rgba(38,217,154,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(38,217,154,.035) 1px, transparent 1px);
background-size: 28px 28px;
}
.hero::after { position: absolute; inset: auto 0 0; height: 2px; background: var(--green); content: ""; opacity: .7; }
.hero-copy { align-self: center; padding: 35px 38px; }
.status-kicker { display: flex; align-items: center; gap: 9px; color: var(--green); font-size: 11px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; }
.status-orb { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); }
.status-orb.is-good { background: var(--green); box-shadow: 0 0 14px rgba(38,217,154,.5); }
.status-orb.is-bad { background: var(--red); }
.hero h2 { max-width: 670px; margin: 17px 0 10px; font-size: clamp(27px, 3vw, 43px); font-weight: 760; letter-spacing: -.045em; line-height: 1.05; }
.hero-copy > p { max-width: 650px; margin: 0; color: var(--muted); font-size: 13px; line-height: 1.65; }
.hero-actions { display: flex; gap: 9px; margin-top: 25px; }
.button {
min-height: 37px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 7px;
background: var(--surface-2);
cursor: pointer;
font-size: 11px;
font-weight: 750;
transition: transform .12s ease, filter .12s ease;
}
.button:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-1px); }
.button:disabled { cursor: not-allowed; opacity: .42; }
.button-primary { border-color: #287258; background: #143c30; color: var(--green); }
.button-danger { border-color: #66333b; background: #32181e; color: var(--red); }
.button-secondary { background: var(--surface-3); color: var(--muted); }
.button-wide { width: 100%; }
.hero-telemetry { display: grid; align-content: center; gap: 0; padding: 25px 34px; border-left: 1px solid rgba(38,217,154,.12); background: rgba(5,10,9,.62); }
.hero-telemetry div { display: flex; align-items: baseline; justify-content: space-between; gap: 15px; padding: 18px 0; border-bottom: 1px solid #1c2925; }
.hero-telemetry div:last-child { border-bottom: 0; }
.hero-telemetry span { color: var(--dim); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
.hero-telemetry strong { font: 650 13px ui-monospace, SFMono-Regular, Consolas, monospace; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 12px; }
.metric { min-height: 126px; padding: 21px 22px; }
.metric > span { color: var(--muted); font-size: 10px; font-weight: 650; }
.metric strong { display: block; margin-top: 15px; font: 700 clamp(22px, 2.5vw, 30px) ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: -.04em; }
.metric small { display: block; margin-top: 8px; color: var(--dim); font-size: 10px; }
.positive { color: var(--green) !important; }
.negative { color: var(--red) !important; }
.warning { color: var(--amber) !important; }
.content-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 12px; margin-top: 12px; }
.content-grid > .card, .system-grid > .card { min-width: 0; padding: 22px; }
.span-2 { grid-column: 1; }
.card-head { display: flex; min-height: 40px; align-items: flex-start; justify-content: space-between; gap: 15px; margin-bottom: 18px; }
.card-head h2 { margin: 0; font-size: 16px; font-weight: 700; letter-spacing: -.02em; }
.subtext { margin: 8px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.text-button { padding: 3px 0; border: 0; background: transparent; color: var(--green); cursor: pointer; font-size: 10px; font-weight: 700; }
.table-wrap { width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { padding: 9px 12px; border-bottom: 1px solid var(--line); color: var(--dim); font-size: 9px; font-weight: 750; letter-spacing: .08em; text-align: right; text-transform: uppercase; white-space: nowrap; }
th:first-child, td:first-child { padding-left: 0; text-align: left; }
th:last-child, td:last-child { padding-right: 0; }
td { height: 54px; padding: 9px 12px; border-bottom: 1px solid var(--line-soft); color: #cad0d8; font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-align: right; white-space: nowrap; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover td { background: rgba(255,255,255,.012); }
.symbol-cell { color: var(--text); font: 750 12px Inter, ui-sans-serif, sans-serif; }
.symbol-cell small { display: block; margin-top: 4px; color: var(--dim); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; }
.sparkline { display: block; width: 88px; height: 28px; margin-left: auto; overflow: visible; }
.sparkline polyline { fill: none; stroke: var(--green); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.sparkline.is-down polyline { stroke: var(--red); }
.row-state { display: inline-flex; align-items: center; gap: 6px; font: 700 9px Inter, ui-sans-serif, sans-serif; letter-spacing: .04em; text-transform: uppercase; }
.row-state::before { width: 6px; height: 6px; border-radius: 50%; background: currentColor; content: ""; }
.empty { height: 120px; color: var(--dim); font: 11px Inter, ui-sans-serif, sans-serif; text-align: center !important; }
.empty-block { display: grid; min-height: 110px; place-items: center; color: var(--dim); font-size: 11px; }
.readiness-card { grid-column: 2; grid-row: 1; }
.readiness-score { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 16px 0 19px; border-bottom: 1px solid var(--line-soft); }
.readiness-score strong { font: 720 31px ui-monospace, SFMono-Regular, Consolas, monospace; }
.readiness-score span { padding-bottom: 4px; color: var(--dim); font-size: 10px; }
.check-list { display: grid; gap: 11px; margin: 18px 0 0; padding: 0; list-style: none; }
.check-list li { display: flex; align-items: flex-start; gap: 9px; color: var(--muted); font-size: 10px; line-height: 1.45; }
.check-dot { width: 7px; height: 7px; margin-top: 3px; flex: 0 0 auto; border-radius: 50%; background: var(--green); }
.check-dot.is-bad { background: var(--red); }
.check-dot.is-warn { background: var(--amber); }
.position-list { display: grid; gap: 0; }
.position-row { display: grid; grid-template-columns: 1.1fr repeat(4, minmax(85px, .8fr)); align-items: center; gap: 14px; min-height: 60px; border-bottom: 1px solid var(--line-soft); }
.position-row:last-child { border-bottom: 0; }
.position-row > div { min-width: 0; text-align: right; }
.position-row > div:first-child { text-align: left; }
.position-row span { display: block; margin-bottom: 4px; color: var(--dim); font-size: 9px; }
.position-row strong { display: block; overflow: hidden; font: 650 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
.model-card { grid-column: 2; grid-row: 2; }
.model-glyph { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid #2d385b; border-radius: 7px; background: #151a2c; color: var(--blue); font: 700 18px Georgia, serif; }
.model-state { padding: 14px 0 20px; border-bottom: 1px solid var(--line-soft); }
.model-state strong { display: block; font-size: 15px; }
.model-state span { display: block; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.45; }
.compact-dl, .system-dl { margin: 14px 0 0; }
.compact-dl div, .system-dl div { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--line-soft); }
.compact-dl div:last-child, .system-dl div:last-child { border-bottom: 0; }
.compact-dl dt, .system-dl dt { color: var(--dim); font-size: 10px; }
.compact-dl dd, .system-dl dd { margin: 0; font: 650 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-align: right; }
.page-card { min-height: 460px; padding: 24px; }
.page-card-head { align-items: center; margin-bottom: 24px; }
.table-large td { height: 61px; }
.search-box { display: flex; width: 220px; height: 36px; align-items: center; gap: 8px; padding: 0 11px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); }
.search-box svg { width: 15px; height: 15px; fill: var(--dim); }
.search-box input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 11px; }
.search-box input::placeholder { color: var(--dim); }
.positions-metrics { margin-top: 0; margin-bottom: 12px; }
.activity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.activity-card { min-width: 0; padding: 22px; }
.feed { max-height: calc(100vh - 205px); min-height: 420px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
.feed-item { padding: 14px 0; border-bottom: 1px solid var(--line-soft); }
.feed-item:first-child { padding-top: 2px; }
.feed-item:last-child { border-bottom: 0; }
.feed-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.feed-top strong { font-size: 11px; }
.feed-top time { color: var(--dim); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; }
.feed-item p { margin: 7px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
.feed-meta { display: flex; gap: 10px; margin-top: 8px; color: var(--dim); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; }
.action-label { font-size: 9px !important; letter-spacing: .05em; }
.system-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.system-grid > .card { min-height: 290px; }
.control-buttons { display: flex; gap: 9px; margin-top: 24px; }
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 26px; padding-top: 20px; border-top: 1px solid var(--line-soft); }
.setting-row strong { display: block; font-size: 11px; }
.setting-row span { display: block; margin-top: 5px; color: var(--dim); font-size: 10px; }
.switch { position: relative; display: inline-flex; cursor: pointer; }
.switch input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.switch > span { position: relative; width: 40px; height: 22px; margin: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-3); transition: background .15s ease; }
.switch > span::after { position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: var(--muted); content: ""; transition: transform .15s ease, background .15s ease; }
.switch input:checked + span { border-color: #287258; background: #143c30; }
.switch input:checked + span::after { background: var(--green); transform: translateX(18px); }
.switch input:focus-visible + span { outline: 2px solid var(--blue); outline-offset: 2px; }
.guard-note { margin: 17px 0 0; padding: 11px 12px; border-left: 2px solid var(--amber); background: #17140f; color: #a99c88; font-size: 9px; line-height: 1.55; }
.dialog { width: min(430px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--line); border-radius: 11px; background: #11151b; color: var(--text); box-shadow: 0 28px 90px rgba(0,0,0,.62); }
.dialog::backdrop { background: rgba(3,5,8,.82); backdrop-filter: blur(8px); }
.dialog form { padding: 28px; }
.dialog-icon { margin-bottom: 24px; }
.dialog h2 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
.dialog p:not(.eyebrow):not(.form-error) { margin: 11px 0 22px; color: var(--muted); font-size: 11px; line-height: 1.6; }
.dialog label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 10px; }
.dialog input { width: 100%; height: 40px; padding: 0 11px; border: 1px solid var(--line); border-radius: 7px; outline: 0; background: #090c10; color: var(--text); }
.auth-fields { display: grid; gap: 14px; }
.dialog .button-wide { margin-top: 14px; }
.form-error { min-height: 16px; margin: 8px 0 0; color: var(--red); font-size: 9px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; }
.toast { position: fixed; right: 24px; bottom: 24px; z-index: 50; max-width: 360px; padding: 12px 15px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-3); color: var(--text); font-size: 11px; opacity: 0; pointer-events: none; transform: translateY(12px); transition: opacity .16s ease, transform .16s ease; }
.toast.is-visible { opacity: 1; transform: translateY(0); }
.toast.is-error { border-color: #63313a; color: var(--red); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
@media (max-width: 1100px) {
:root { --sidebar: 76px; }
.brand { justify-content: center; padding: 0; }
.brand > span:last-child, .nav-item span, .sidebar-foot { display: none; }
.nav-list { padding-inline: 11px; }
.nav-item { justify-content: center; padding-inline: 0; }
.content-grid { grid-template-columns: 1fr; }
.span-2, .readiness-card, .model-card { grid-column: 1; grid-row: auto; }
.activity-grid { grid-template-columns: 1fr; }
.feed { max-height: 520px; min-height: 280px; }
}
@media (max-width: 760px) {
:root { --sidebar: 0px; }
.sidebar { inset: auto 0 0; width: 100%; height: 64px; border-top: 1px solid var(--line); border-right: 0; }
.brand, .sidebar-foot { display: none; }
.nav-list { display: grid; height: 100%; grid-template-columns: repeat(5, 1fr); gap: 0; padding: 5px 8px; }
.nav-item { flex-direction: column; justify-content: center; gap: 4px; padding: 4px; font-size: 8px; }
.nav-item span { display: block; }
.nav-item svg { width: 16px; height: 16px; }
.main { margin-left: 0; padding-bottom: 64px; }
.topbar { min-height: 70px; padding: 0 18px; }
.topbar .eyebrow, .sync-label { display: none; }
.topbar h1 { font-size: 18px; }
.page { padding: 18px 14px 32px; }
.offline-banner { margin: 12px 14px 0; }
.hero { min-height: 0; grid-template-columns: 1fr; }
.hero-copy { padding: 27px 23px; }
.hero h2 { font-size: 30px; }
.hero-telemetry { grid-template-columns: repeat(3, 1fr); padding: 0 20px; border-top: 1px solid #1c2925; border-left: 0; }
.hero-telemetry div { display: block; padding: 15px 7px; border-right: 1px solid #1c2925; border-bottom: 0; text-align: center; }
.hero-telemetry div:last-child { border-right: 0; }
.hero-telemetry strong { display: block; margin-top: 6px; font-size: 10px; }
.metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
.metric { min-height: 112px; padding: 17px; }
.metric strong { font-size: 21px; }
.content-grid, .system-grid { grid-template-columns: 1fr; }
.system-grid > .card { min-height: 0; }
.page-card { padding: 18px; }
.page-card-head { align-items: flex-start; }
.search-box { width: 150px; }
.position-row { grid-template-columns: 1fr 1fr; padding: 12px 0; }
.position-row > div:nth-child(n+4) { display: none; }
.toast { right: 14px; bottom: 78px; left: 14px; max-width: none; }
}
@media (max-width: 440px) {
.badge-mode { display: none; }
.hero-actions, .control-buttons { display: grid; grid-template-columns: 1fr 1fr; }
.button { padding-inline: 11px; }
.metric-grid { gap: 8px; }
.metric { padding: 15px; }
.metric > span { font-size: 9px; }
.page-card-head { display: block; }
.search-box { width: 100%; margin-top: 16px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
+569
View File
@@ -0,0 +1,569 @@
"use strict";
const state = {
snapshot: null,
token: "",
loading: false,
timer: null,
marketFilter: "",
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
class AuthRequiredError extends Error {}
const reasonLabels = {
bot_not_running: "Торговый цикл остановлен",
decision_loop_stale: "Цикл принятия решений не обновлялся вовремя",
stale_market_data: "Есть устаревшие рыночные данные",
repeated_loop_errors: "Обнаружены повторяющиеся ошибки цикла",
forecast_model_not_ready: "Прогнозная модель не готова",
live_reconciliation_blocking: "Сверка live-счёта блокирует новые действия",
};
const actionLabels = {
BUY: "Покупка",
SELL: "Продажа",
HOLD: "Ожидание",
};
document.addEventListener("DOMContentLoaded", () => {
bindNavigation();
bindControls();
selectPage(location.hash.slice(1) || "overview", false);
showAuthDialog();
});
function bindNavigation() {
$$("[data-page]").forEach((button) => {
button.addEventListener("click", () => selectPage(button.dataset.page));
});
$$("[data-go]").forEach((button) => {
button.addEventListener("click", () => selectPage(button.dataset.go));
});
window.addEventListener("hashchange", () => selectPage(location.hash.slice(1) || "overview", false));
}
function selectPage(pageName, updateHash = true) {
const valid = ["overview", "markets", "positions", "activity", "system"];
const page = valid.includes(pageName) ? pageName : "overview";
$$(".page").forEach((item) => item.classList.toggle("is-active", item.id === `page-${page}`));
$$("[data-page]").forEach((item) => {
const active = item.dataset.page === page;
item.classList.toggle("is-active", active);
if (active) item.setAttribute("aria-current", "page");
else item.removeAttribute("aria-current");
});
const activePage = $(`#page-${page}`);
setText("pageTitle", activePage?.dataset.title || "Обзор");
if (updateHash && location.hash !== `#${page}`) history.pushState(null, "", `#${page}`);
window.scrollTo({ top: 0, behavior: "smooth" });
}
function bindControls() {
$("#refreshButton").addEventListener("click", () => loadSnapshot(true));
["#startButton", "#systemStartButton"].forEach((selector) => {
$(selector).addEventListener("click", () => requestControl("start"));
});
["#stopButton", "#systemStopButton"].forEach((selector) => {
$(selector).addEventListener("click", () => requestControl("stop"));
});
$("#fastTradingToggle").addEventListener("change", onFastTradingChange);
$("#marketSearch").addEventListener("input", (event) => {
state.marketFilter = event.target.value.trim().toUpperCase();
renderMarkets(state.snapshot?.markets?.markets || []);
});
$("#authForm").addEventListener("submit", async (event) => {
event.preventDefault();
const username = $("#usernameInput").value.trim();
const password = $("#passwordInput").value;
const submitButton = $("#authSubmitButton");
setText("authError", "");
if (!username || !password) return;
if (username !== "sevenhill") {
setText("authError", "Неверный логин или пароль.");
return;
}
state.token = password.trim();
submitButton.disabled = true;
submitButton.setAttribute("aria-busy", "true");
setText("authSubmitButton", "Входим…");
setText("authError", "Проверяем доступ…");
try {
await loadSnapshot(true, true);
} finally {
submitButton.disabled = false;
submitButton.removeAttribute("aria-busy");
setText("authSubmitButton", "Войти");
}
});
}
async function api(path, options = {}) {
const headers = { Accept: "application/json", ...(options.headers || {}) };
if (options.body) headers["Content-Type"] = "application/json";
if (state.token) headers["X-TradeBot-Token"] = state.token;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
let response;
try {
response = await fetch(path, {
...options,
headers,
signal: controller.signal,
credentials: "omit",
cache: "no-store",
});
} catch (error) {
if (error?.name === "AbortError") throw new Error("Сервер не ответил за 30 секунд.");
throw error;
} finally {
clearTimeout(timeout);
}
if (response.status === 401) throw new AuthRequiredError("Требуется авторизация");
let payload = null;
try { payload = await response.json(); } catch (_) { payload = null; }
if (!response.ok) {
const detail = payload?.detail?.message || payload?.detail || payload?.error || `HTTP ${response.status}`;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
return payload;
}
async function loadSnapshot(manual = false, fromAuth = false) {
if (state.loading) return false;
state.loading = true;
clearTimeout(state.timer);
$("#refreshButton").classList.add("is-spinning");
if (manual) setText("syncLabel", "Обновление…");
try {
const snapshot = await api("/web-api/dashboard/snapshot");
state.snapshot = snapshot;
render(snapshot);
setOffline(false);
if ($("#authDialog").open) $("#authDialog").close();
$("#passwordInput").value = "";
setText("authError", "");
scheduleRefresh(10000);
return true;
} catch (error) {
if (error instanceof AuthRequiredError) {
if (fromAuth) setText("authError", "Неверный логин или пароль.");
state.token = "";
showAuthDialog();
} else if (fromAuth) {
setText("authError", `Ошибка подключения: ${error.message}`);
showAuthDialog();
} else {
setOffline(true, error.message);
scheduleRefresh(12000);
}
return false;
} finally {
state.loading = false;
$("#refreshButton").classList.remove("is-spinning");
}
}
function scheduleRefresh(delay) {
clearTimeout(state.timer);
state.timer = setTimeout(() => loadSnapshot(), delay);
}
function showAuthDialog() {
const dialog = $("#authDialog");
if (!dialog.open) dialog.showModal();
setText("syncLabel", "Нужна авторизация");
setTimeout(() => $("#usernameInput").focus(), 50);
}
function setOffline(offline, message = "") {
$("#offlineBanner").hidden = !offline;
setText("offlineMessage", message || "Повторное подключение выполняется автоматически.");
setText("sideConnection", offline ? "Нет связи" : "Сервер доступен");
$("#sideConnectionDot").classList.toggle("is-offline", offline);
$("#sideConnectionDot").classList.toggle("is-online", !offline);
if (offline) setText("syncLabel", "Соединение потеряно");
}
function render(data) {
const health = data.health || {};
const envelope = data.status || {};
const status = envelope.status || {};
const readiness = envelope.readiness || {};
const account = envelope.account || {};
const positions = envelope.positions || [];
const markets = data.markets || {};
const closed = data.trades?.closed_summary || {};
const config = data.config || {};
setText("appVersion", health.version || "—");
setText("modeBadge", String(health.mode || "—").toUpperCase());
setText("syncLabel", `Обновлено ${formatClock(data.generated_at)}`);
setText("sideConnection", "Сервер доступен");
$("#sideConnectionDot").classList.add("is-online");
renderHero(status, readiness, markets);
renderMetrics(account, positions, closed);
renderReadiness(status, readiness, markets);
renderModel(config, readiness, data.retrain || {}, markets);
renderOverviewMarkets(markets.markets || []);
renderMarkets(markets.markets || []);
renderPositions(positions, config);
renderActivity(data);
renderSystem(data);
}
function renderHero(status, readiness, markets) {
const running = Boolean(status.running);
const ready = Boolean(readiness.ready);
const orb = $("#heroOrb");
orb.classList.toggle("is-good", running && ready);
orb.classList.toggle("is-bad", !running);
if (!running) {
setText("heroKicker", "Цикл остановлен");
setText("heroTitle", "Бот сейчас не торгует");
setText("heroText", "Данные и позиции сохранены. Запуск возобновит анализ рынка и обработку торговых решений.");
} else if (ready) {
setText("heroKicker", "Контур готов");
setText("heroTitle", "Бот работает штатно");
setText("heroText", "Торговый цикл активен, рыночные данные свежие, обязательные проверки пройдены.");
} else {
setText("heroKicker", "Работа с ограничениями");
setText("heroTitle", "Бот активен, но есть предупреждения");
setText("heroText", (readiness.reasons || []).map(reasonLabel).join(" · ") || "Сервер сообщил об ограниченной готовности.");
}
setText("lastLoop", status.last_loop_at ? timeAgo(status.last_loop_at) : "нет данных");
setText("wsState", markets.ws_connected ? "подключён" : "нет связи");
setText("symbolCount", String((markets.symbols || []).length));
toggleControlButtons(running);
}
function toggleControlButtons(running) {
["#startButton", "#systemStartButton"].forEach((selector) => { $(selector).disabled = running; });
["#stopButton", "#systemStopButton"].forEach((selector) => { $(selector).disabled = !running; });
const badge = $("#controlBadge");
badge.textContent = running ? "Работает" : "Остановлен";
badge.className = `badge ${running ? "is-good" : "is-bad"}`;
}
function renderMetrics(account, positions, closed) {
const equity = number(account.equity);
const cash = number(account.cash);
const net = number(account.net_pnl);
const exposure = positions.reduce((sum, row) => sum + number(row.market_value), 0);
setText("metricEquity", money(equity));
setText("metricCash", money(cash));
setText("metricPositions", String(positions.length));
setText("metricExposure", `Экспозиция ${money(exposure)}`);
setText("metricTrades", String(closed.trades ?? 0));
setText("metricWinRate", `Win rate ${percent(number(closed.win_rate) * 100, 1)}`);
const delta = $("#metricEquityDelta");
delta.textContent = `${signedMoney(net)} · ${signedPercent(number(account.net_pnl_percent), 2)}`;
applyTone(delta, net);
}
function renderReadiness(status, readiness, markets) {
const ready = Boolean(readiness.ready);
const badge = $("#readyBadge");
badge.textContent = ready ? "Готов" : status.running ? "Ограничен" : "Стоп";
badge.className = `badge ${ready ? "is-good" : status.running ? "is-warn" : "is-bad"}`;
setText("readyScore", ready ? "READY" : "CHECK");
const checks = [
{ ok: Boolean(status.running), label: status.running ? "Торговый цикл запущен" : "Торговый цикл остановлен" },
{ ok: !(readiness.reasons || []).includes("decision_loop_stale"), label: "Цикл решений обновляется вовремя" },
{ ok: Boolean(markets.ws_connected), label: markets.ws_connected ? "Bybit WebSocket подключён" : "Bybit WebSocket не подключён" },
{ ok: !(readiness.stale_symbols || []).length, label: (readiness.stale_symbols || []).length ? `Устарели: ${readiness.stale_symbols.join(", ")}` : "Рыночные данные свежие" },
{
ok: Boolean(readiness.forecast_model_ready),
warn: Boolean(readiness.forecast_fallback_active),
label: readiness.forecast_model_ready ? "Прогнозная модель готова" : readiness.forecast_fallback_active ? "Активен резервный режим прогноза" : "Модель прогноза не готова",
},
];
$("#readinessList").innerHTML = checks.map((item) => `<li><span class="check-dot ${item.ok ? "" : item.warn ? "is-warn" : "is-bad"}"></span>${escapeHtml(item.label)}</li>`).join("");
}
function renderModel(config, readiness, retrain, markets) {
const artifact = config.time_series_model_artifact || {};
const shadow = retrain.shadow || {};
const collector = markets.observation_collector || {};
setText("modelTitle", artifact.available ? artifact.label || artifact.type || "Модель загружена" : "Артефакт недоступен");
setText("modelSubtitle", artifact.available ? `${artifact.symbol_count ?? 0} пар · создана ${formatDateTime(artifact.created_at)}` : "Сервер не подтвердил наличие модели");
setText("shadowGate", shadowStateLabel(shadow));
setText("fallbackState", readiness.forecast_fallback_active ? "активен" : "не активен");
setText("collectorState", collector.enabled ? `${collector.samples_since_start ?? 0} с запуска` : "выключен");
}
function renderOverviewMarkets(markets) {
const rows = markets.filter((market) => market.ticker).slice(0, 6);
$("#overviewMarkets").innerHTML = rows.length ? rows.map((market) => {
const ticker = market.ticker || {};
const forecast = market.forecast || {};
const quality = market.quality || {};
const change = number(ticker.change_24h);
const edge = forecastValue(forecast);
const forecastUsable = isForecastUsable(forecast);
return `<tr>
<td class="symbol-cell">${escapeHtml(ticker.symbol || "—")}<small>${escapeHtml(modelName(forecast))}</small></td>
<td>${formatPrice(ticker.last_price)}</td>
<td class="${toneClass(change)}">${signedPercent(change, 2)}</td>
<td class="${toneClass(edge)}">${forecastUsable ? signedPercent(edge, 2) : "—"}</td>
<td>${sparkline(market.sparkline || [], change)}</td>
<td>${qualityLabel(quality)}</td>
</tr>`;
}).join("") : `<tr><td colspan="6" class="empty">Рынок пока не вернул котировки.</td></tr>`;
}
function renderMarkets(markets) {
const filter = state.marketFilter;
const rows = markets.filter((market) => {
const symbol = market.ticker?.symbol || "";
return market.ticker && (!filter || symbol.includes(filter));
});
$("#marketsTable").innerHTML = rows.length ? rows.map((market) => {
const ticker = market.ticker || {};
const forecast = market.forecast || {};
const quality = market.quality || {};
const change = number(ticker.change_24h);
const edge = forecastValue(forecast);
const probability = probabilityValue(forecast);
const forecastUsable = isForecastUsable(forecast);
return `<tr>
<td class="symbol-cell">${escapeHtml(ticker.symbol || "—")}<small>${escapeHtml(modelName(forecast))}</small></td>
<td>${formatPrice(ticker.last_price)}</td>
<td>${formatPrice(ticker.bid)} / ${formatPrice(ticker.ask)}</td>
<td class="${toneClass(change)}">${signedPercent(change, 2)}</td>
<td class="${toneClass(edge)}">${forecastUsable ? signedPercent(edge, 2) : "—"}</td>
<td>${!forecastUsable || probability == null ? "—" : percent(probability * 100, 1)}</td>
<td>${percent(number(ticker.spread_percent), 3)}</td>
<td>${qualityLabel(quality)}</td>
</tr>`;
}).join("") : `<tr><td colspan="8" class="empty">${filter ? "Совпадений не найдено." : "Рынок пока не вернул котировки."}</td></tr>`;
}
function renderPositions(positions, config) {
const totalValue = positions.reduce((sum, row) => sum + number(row.market_value), 0);
const totalPnl = positions.reduce((sum, row) => sum + number(row.unrealized_pnl), 0);
const totalNotional = positions.reduce((sum, row) => sum + number(row.notional_usdt), 0);
const totalPnlPercent = totalNotional ? totalPnl / totalNotional * 100 : 0;
setText("positionCount", String(positions.length));
setText("positionValue", money(totalValue));
setText("positionPnl", signedMoney(totalPnl));
setText("positionPnlPercent", signedPercent(totalPnlPercent, 2));
setText("exposureLimit", money(config.max_total_exposure_usdt));
applyTone($("#positionPnl"), totalPnl);
applyTone($("#positionPnlPercent"), totalPnl);
$("#overviewPositions").innerHTML = positions.length ? positions.slice(0, 6).map((position) => `<div class="position-row">
<div><span>Пара</span><strong>${escapeHtml(position.symbol || "")}</strong></div>
<div><span>Стоимость</span><strong>${money(position.market_value)}</strong></div>
<div><span>Цена сейчас</span><strong>${formatPrice(position.mark_price)}</strong></div>
<div><span>PnL</span><strong class="${toneClass(number(position.unrealized_pnl))}">${signedMoney(position.unrealized_pnl)}</strong></div>
<div><span>План</span><strong>${escapeHtml(actionLabels[position.exit_plan?.action] || position.exit_plan?.action || "Ожидание")}</strong></div>
</div>`).join("") : `<div class="empty-block">Открытых позиций нет.</div>`;
$("#positionsTable").innerHTML = positions.length ? positions.map((position) => `<tr>
<td class="symbol-cell">${escapeHtml(position.symbol || "—")}<small>${escapeHtml(position.mode || "")}</small></td>
<td>${formatQuantity(position.qty)}</td>
<td>${formatPrice(position.entry_price)}</td>
<td>${formatPrice(position.mark_price)}</td>
<td>${money(position.market_value)}</td>
<td class="${toneClass(number(position.unrealized_pnl))}">${signedMoney(position.unrealized_pnl)}<br><small>${signedPercent(number(position.unrealized_pnl_percent), 2)}</small></td>
<td>${escapeHtml(actionLabels[position.exit_plan?.action] || position.exit_plan?.action || "Ожидание")}</td>
<td>${formatDateTime(position.opened_at)}</td>
</tr>`).join("") : `<tr><td colspan="8" class="empty">Открытых позиций нет.</td></tr>`;
}
function renderActivity(data) {
const signals = data.signals?.items || [];
const trades = data.trades?.items || [];
const events = data.events?.items || [];
$("#signalFeed").innerHTML = signals.length ? signals.map((item) => {
const action = String(item.action || "HOLD").toUpperCase();
const tone = action === "BUY" ? "positive" : action === "SELL" ? "negative" : "warning";
return `<article class="feed-item"><div class="feed-top"><strong>${escapeHtml(item.symbol || "—")}</strong><time>${formatDateTime(item.created_at)}</time></div><p>${escapeHtml(item.reason || "Причина не указана")}</p><div class="feed-meta"><span class="action-label ${tone}">${escapeHtml(actionLabels[action] || action)}</span><span>confidence ${percent(number(item.confidence) * 100, 1)}</span></div></article>`;
}).join("") : `<div class="empty-block">Сигналов пока нет.</div>`;
$("#tradeFeed").innerHTML = trades.length ? trades.map((item) => {
const side = String(item.side || "").toUpperCase();
const pnl = number(item.net_pnl);
return `<article class="feed-item"><div class="feed-top"><strong>${escapeHtml(item.symbol || "—")} · <span class="${side === "SELL" ? "negative" : "positive"}">${escapeHtml(side)}</span></strong><time>${formatDateTime(item.closed_at || item.opened_at)}</time></div><p>${escapeHtml(item.reason || (side === "BUY" ? "Позиция открыта" : "Сделка исполнена"))}</p><div class="feed-meta"><span>${formatQuantity(item.qty)} ед.</span><span class="${toneClass(pnl)}">PnL ${signedMoney(pnl)}</span><span>fee ${money(item.fee_usdt)}</span></div></article>`;
}).join("") : `<div class="empty-block">Сделок пока нет.</div>`;
$("#eventFeed").innerHTML = events.length ? events.map((item) => {
const level = String(item.level || "INFO").toUpperCase();
const tone = level === "ERROR" ? "negative" : level === "WARN" ? "warning" : "";
return `<article class="feed-item"><div class="feed-top"><strong class="${tone}">${escapeHtml(level)}</strong><time>${formatDateTime(item.created_at)}</time></div><p>${escapeHtml(item.message || "—")}</p></article>`;
}).join("") : `<div class="empty-block">Событий пока нет.</div>`;
}
function renderSystem(data) {
const config = data.config || {};
const markets = data.markets || {};
const retrain = data.retrain || {};
const coordination = retrain.coordination || {};
const shadow = retrain.shadow || {};
const activeJob = coordination.active_job || coordination.latest_job;
$("#fastTradingToggle").checked = Boolean(config.fast_trading_enabled);
setText("fastTradingHint", `Интервал ${formatDuration(config.effective_loop_interval_seconds)} · cooldown ${formatDuration(config.effective_entry_cooldown_seconds)}`);
$("#trainingDetails").innerHTML = definitionRows([
["Модель", config.time_series_model_artifact?.label || "нет данных"],
["Windows-агент", coordination.agent_online ? coordination.agent_busy ? "занят" : "онлайн" : "не в сети"],
["Последняя задача", activeJob?.status || "нет задач"],
["Shadow gate", shadowStateLabel(shadow)],
["Forward predictions", `${shadow.settled_predictions ?? 0} settled / ${shadow.eligible_predictions ?? 0} eligible`],
]);
$("#networkDetails").innerHTML = definitionRows([
["WebSocket", markets.ws_connected ? "подключён" : "нет связи"],
["Последнее WS-сообщение", formatDateTime(markets.last_ws_message_at)],
["Последний REST refresh", formatDateTime(markets.last_rest_refresh_at)],
["REST-ошибки", String(markets.rest_error_count ?? 0)],
["Сбор L1", markets.observation_collector?.enabled ? `включён · ${markets.observation_collector.samples_since_start ?? 0}` : "выключен"],
]);
$("#configDetails").innerHTML = definitionRows([
["Стратегия", config.strategy_mode || "—"],
["Базовый интервал", config.base_interval ? `${config.base_interval} мин` : "—"],
["Profit-only выход", config.profit_only_exit_enabled ? `включён · min ${percent(config.min_exit_net_percent, 2)}` : "выключен"],
["Risk guard", config.risk_guard_enabled ? "включён" : "выключен"],
["Общая экспозиция", `${money(config.max_total_exposure_usdt)} USDT`],
["Макс. позиций", String(config.max_open_positions ?? "—")],
]);
}
async function requestControl(action) {
const start = action === "start";
const confirmed = await askConfirm(
start ? "Запустить торговый цикл?" : "Остановить торговый цикл?",
start
? "Бот возобновит анализ рынка и обработку решений. Текущий режим торговли не изменится."
: "Новые решения перестанут обрабатываться. Открытые позиции и история останутся сохранены.",
start ? "Запустить" : "Остановить",
);
if (!confirmed) return;
setControlsBusy(true);
try {
await api(`/web-api/control/${action}`, { method: "POST" });
toast(start ? "Торговый цикл запущен." : "Торговый цикл остановлен.");
await loadSnapshot(true);
} catch (error) {
if (error instanceof AuthRequiredError) showAuthDialog();
else toast(error.message, true);
} finally {
toggleControlButtons(Boolean(state.snapshot?.status?.status?.running));
}
}
async function onFastTradingChange(event) {
const toggle = event.target;
const previous = !toggle.checked;
const enabled = toggle.checked;
const confirmed = await askConfirm(
enabled ? "Включить быструю торговлю?" : "Выключить быструю торговлю?",
enabled
? "Сервер уменьшит интервал цикла и cooldown входа согласно текущей конфигурации."
: "Сервер вернётся к обычному интервалу принятия решений.",
enabled ? "Включить" : "Выключить",
);
if (!confirmed) { toggle.checked = previous; return; }
toggle.disabled = true;
try {
const result = await api("/web-api/config/fast-trading", { method: "POST", body: JSON.stringify({ enabled }) });
toast(`Быстрая торговля ${enabled ? "включена" : "выключена"}${result.env_persisted === false ? " только в runtime" : ""}.`);
await loadSnapshot(true);
} catch (error) {
toggle.checked = previous;
if (error instanceof AuthRequiredError) showAuthDialog();
else toast(error.message, true);
} finally {
toggle.disabled = false;
}
}
function askConfirm(title, text, actionLabel) {
const dialog = $("#confirmDialog");
setText("confirmTitle", title);
setText("confirmText", text);
setText("confirmAction", actionLabel);
dialog.showModal();
return new Promise((resolve) => {
dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true });
});
}
function setControlsBusy(busy) {
["#startButton", "#stopButton", "#systemStartButton", "#systemStopButton"].forEach((selector) => { $(selector).disabled = busy; });
}
function definitionRows(rows) {
return rows.map(([term, value]) => `<div><dt>${escapeHtml(term)}</dt><dd>${escapeHtml(value ?? "—")}</dd></div>`).join("");
}
function sparkline(points, change) {
const values = points.map((point) => number(point.close)).filter((value) => Number.isFinite(value) && value > 0);
if (values.length < 2) return "—";
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const path = values.map((value, index) => `${(index / (values.length - 1) * 88).toFixed(1)},${(26 - ((value - min) / range) * 22).toFixed(1)}`).join(" ");
return `<svg class="sparkline ${change < 0 ? "is-down" : ""}" viewBox="0 0 88 28" aria-hidden="true"><polyline points="${path}"/></svg>`;
}
function qualityLabel(quality) {
const status = quality?.status || "unknown";
const text = status === "ok" ? "Норма" : status === "warn" ? "Внимание" : status === "error" ? "Ошибка" : "Нет данных";
const tone = status === "ok" ? "positive" : status === "warn" ? "warning" : status === "error" ? "negative" : "";
return `<span class="row-state ${tone}">${text}</span>`;
}
function modelName(forecast) {
if (!isForecastUsable(forecast)) return "нет модели";
return forecast?.model_label || forecast?.model || "прогноз";
}
function isForecastUsable(forecast) {
if (!forecast || forecast.usable === false) return false;
return Boolean(forecast.usable || (forecast.model && forecast.model !== "none"));
}
function forecastValue(forecast) {
return number(forecast?.expected_return_percent ?? forecast?.edge_percent ?? forecast?.expected_percent);
}
function probabilityValue(forecast) {
const raw = forecast?.probability_up ?? forecast?.probability;
if (raw === null || raw === undefined || raw === "") return null;
const value = number(raw);
return value > 1 ? value / 100 : value;
}
function reasonLabel(reason) { return reasonLabels[reason] || String(reason || "Неизвестное ограничение"); }
function shadowStateLabel(shadow) { if (shadow?.passed) return "пройден"; return ({ collecting: "сбор данных", failed: "не пройден", passed: "пройден" })[shadow?.state] || "нет данных"; }
function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }
function setText(id, value) { const node = document.getElementById(id); if (node) node.textContent = String(value ?? "—"); }
function money(value) { return number(value).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
function signedMoney(value) { const amount = number(value); return `${amount > 0 ? "+" : ""}${money(amount)} USDT`; }
function percent(value, digits = 2) { return `${number(value).toLocaleString("ru-RU", { minimumFractionDigits: digits, maximumFractionDigits: digits })}%`; }
function signedPercent(value, digits = 2) { const amount = number(value); return `${amount > 0 ? "+" : ""}${percent(amount, digits)}`; }
function formatPrice(value) { const amount = number(value); if (!amount) return "—"; const digits = amount >= 1000 ? 2 : amount >= 1 ? 4 : 6; return amount.toLocaleString("ru-RU", { maximumFractionDigits: digits }); }
function formatQuantity(value) { return number(value).toLocaleString("ru-RU", { maximumFractionDigits: 8 }); }
function formatDuration(value) { const seconds = number(value); return seconds < 60 ? `${seconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с` : `${(seconds / 60).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мин`; }
function formatClock(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? "—" : date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
function formatDateTime(value) { const date = new Date(value); return !value || Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); }
function timeAgo(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; const seconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000)); if (seconds < 5) return "сейчас"; if (seconds < 60) return `${seconds} с назад`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes} мин назад`; return formatDateTime(value); }
function toneClass(value) { return number(value) > 0 ? "positive" : number(value) < 0 ? "negative" : ""; }
function applyTone(node, value) { node.classList.remove("positive", "negative"); if (number(value) > 0) node.classList.add("positive"); if (number(value) < 0) node.classList.add("negative"); }
function escapeHtml(value) { return String(value ?? "").replace(/[&<>'"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[char])); }
let toastTimer = null;
function toast(message, error = false) {
const node = $("#toast");
node.textContent = message;
node.classList.toggle("is-error", error);
node.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => node.classList.remove("is-visible"), 3500);
}
+226
View File
@@ -0,0 +1,226 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#090b0f">
<meta name="description" content="Операционная панель TradeBot">
<title>TradeBot — панель управления</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111318'/%3E%3Cpath d='M14 17h36v8H36v24h-8V25H14z' fill='%2326d99a'/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/dashboard.css?v=3">
</head>
<body>
<div class="app-shell">
<aside class="sidebar" aria-label="Основная навигация">
<a class="brand" href="#overview" aria-label="TradeBot — обзор">
<span class="brand-mark" aria-hidden="true">T</span>
<span><strong>TradeBot</strong><small>Operations</small></span>
</a>
<nav class="nav-list">
<button class="nav-item is-active" type="button" data-page="overview" aria-label="Обзор" aria-current="page">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6v-9h-6v9Zm0-16v5h6V4h-6Z"/></svg>
<span>Обзор</span>
</button>
<button class="nav-item" type="button" data-page="markets" aria-label="Рынки">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="m4 17 5-5 4 3 7-8v3l-7 8-4-3-5 5v-3Z"/></svg>
<span>Рынки</span>
</button>
<button class="nav-item" type="button" data-page="positions" aria-label="Позиции">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16v12H4V6Zm2 3v6h12V9H6Zm2 1h4v4H8v-4Z"/></svg>
<span>Позиции</span>
</button>
<button class="nav-item" type="button" data-page="activity" aria-label="Активность">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 4h14v3H5V4Zm0 6h14v3H5v-3Zm0 6h14v3H5v-3Z"/></svg>
<span>Активность</span>
</button>
<button class="nav-item" type="button" data-page="system" aria-label="Система">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm9 4-2.1-1.2.1-2.4-2.2-2.2-2.4.1L13.2 4h-2.4L9.6 6.3l-2.4-.1L5 8.4l.1 2.4L3 12l2.1 1.2-.1 2.4 2.2 2.2 2.4-.1 1.2 2.3h2.4l1.2-2.3 2.4.1 2.2-2.2-.1-2.4L21 12Z"/></svg>
<span>Система</span>
</button>
</nav>
<div class="sidebar-foot">
<div class="connection-mini">
<span class="live-dot" id="sideConnectionDot"></span>
<span><strong id="sideConnection">Подключение…</strong><small>tb.kusoft.xyz</small></span>
</div>
<div class="version-line">Версия <span id="appVersion"></span></div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<p class="eyebrow" id="pageEyebrow">Операционная панель</p>
<h1 id="pageTitle">Обзор</h1>
</div>
<div class="topbar-actions">
<span class="sync-label" id="syncLabel">Получение данных…</span>
<span class="badge badge-mode" id="modeBadge"></span>
<button class="icon-button" id="refreshButton" type="button" aria-label="Обновить данные" title="Обновить">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M19 8V4l-1.6 1.6A8 8 0 1 0 20 12h-2a6 6 0 1 1-2-4.5L14 9h5V8Z"/></svg>
</button>
</div>
</header>
<div class="offline-banner" id="offlineBanner" role="alert" hidden>
<strong>Нет связи с сервером.</strong>
<span id="offlineMessage">Повторное подключение выполняется автоматически.</span>
</div>
<section class="page is-active" id="page-overview" data-title="Обзор">
<article class="hero card">
<div class="hero-copy">
<div class="status-kicker"><span class="status-orb" id="heroOrb"></span><span id="heroKicker">Проверка контура</span></div>
<h2 id="heroTitle">Получаем состояние бота</h2>
<p id="heroText">Панель сверяет торговый цикл, рыночные данные и готовность модели.</p>
<div class="hero-actions">
<button class="button button-primary" id="startButton" type="button">Запустить цикл</button>
<button class="button button-danger" id="stopButton" type="button">Остановить</button>
</div>
</div>
<div class="hero-telemetry">
<div><span>Последний цикл</span><strong id="lastLoop"></strong></div>
<div><span>WebSocket</span><strong id="wsState"></strong></div>
<div><span>Торговых пар</span><strong id="symbolCount"></strong></div>
</div>
</article>
<div class="metric-grid" aria-label="Ключевые показатели">
<article class="metric card"><span>Капитал</span><strong id="metricEquity"></strong><small id="metricEquityDelta">С начала работы</small></article>
<article class="metric card"><span>Свободно</span><strong id="metricCash"></strong><small>USDT для новых позиций</small></article>
<article class="metric card"><span>Открытые позиции</span><strong id="metricPositions"></strong><small id="metricExposure">Экспозиция —</small></article>
<article class="metric card"><span>Закрытые сделки</span><strong id="metricTrades"></strong><small id="metricWinRate">Win rate —</small></article>
</div>
<div class="content-grid overview-grid">
<article class="card span-2">
<div class="card-head"><div><p class="eyebrow">Live market</p><h2>Рынок и прогноз</h2></div><button class="text-button" type="button" data-go="markets">Все пары</button></div>
<div class="table-wrap"><table><thead><tr><th>Пара</th><th>Цена</th><th>24 часа</th><th>Прогноз</th><th>Динамика</th><th>Качество</th></tr></thead><tbody id="overviewMarkets"><tr><td colspan="6" class="empty">Загрузка рынка…</td></tr></tbody></table></div>
</article>
<article class="card readiness-card">
<div class="card-head"><div><p class="eyebrow">Safety</p><h2>Готовность</h2></div><span class="badge" id="readyBadge">Проверка</span></div>
<div class="readiness-score"><strong id="readyScore"></strong><span>торговый контур</span></div>
<ul class="check-list" id="readinessList"><li><span class="check-dot"></span>Получение состояния…</li></ul>
</article>
<article class="card span-2">
<div class="card-head"><div><p class="eyebrow">Portfolio</p><h2>Открытые позиции</h2></div><button class="text-button" type="button" data-go="positions">Подробнее</button></div>
<div id="overviewPositions" class="position-list"><div class="empty-block">Позиции загружаются…</div></div>
</article>
<article class="card model-card">
<div class="card-head"><div><p class="eyebrow">Model</p><h2>Прогнозная модель</h2></div><span class="model-glyph">ƒ</span></div>
<div class="model-state"><strong id="modelTitle"></strong><span id="modelSubtitle">Проверка артефакта</span></div>
<dl class="compact-dl">
<div><dt>Forward gate</dt><dd id="shadowGate"></dd></div>
<div><dt>Fallback</dt><dd id="fallbackState"></dd></div>
<div><dt>Сбор L1</dt><dd id="collectorState"></dd></div>
</dl>
</article>
</div>
</section>
<section class="page" id="page-markets" data-title="Рынки">
<article class="card page-card">
<div class="card-head page-card-head"><div><p class="eyebrow">Bybit spot</p><h2>Торговая вселенная</h2><p class="subtext">Котировки, прогноз, спред и состояние данных по активным парам.</p></div><div class="search-box"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M10 4a6 6 0 1 0 3.9 10.6L19.3 20l.7-.7-5.4-5.4A6 6 0 0 0 10 4Zm0 2a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z"/></svg><input id="marketSearch" type="search" placeholder="Найти пару" aria-label="Найти торговую пару"></div></div>
<div class="table-wrap table-large"><table><thead><tr><th>Пара</th><th>Цена</th><th>Bid / Ask</th><th>24 часа</th><th>Прогноз</th><th>P(up)</th><th>Спред</th><th>Данные</th></tr></thead><tbody id="marketsTable"><tr><td colspan="8" class="empty">Загрузка рынка…</td></tr></tbody></table></div>
</article>
</section>
<section class="page" id="page-positions" data-title="Позиции">
<div class="metric-grid positions-metrics">
<article class="metric card"><span>Открыто</span><strong id="positionCount"></strong><small>позиций</small></article>
<article class="metric card"><span>Рыночная стоимость</span><strong id="positionValue"></strong><small>USDT</small></article>
<article class="metric card"><span>Нереализованный PnL</span><strong id="positionPnl"></strong><small id="positionPnlPercent"></small></article>
<article class="metric card"><span>Лимит экспозиции</span><strong id="exposureLimit"></strong><small>USDT</small></article>
</div>
<article class="card page-card">
<div class="card-head"><div><p class="eyebrow">Portfolio</p><h2>Все открытые позиции</h2></div></div>
<div class="table-wrap table-large"><table><thead><tr><th>Пара</th><th>Объём</th><th>Вход</th><th>Сейчас</th><th>Стоимость</th><th>PnL</th><th>План выхода</th><th>Открыта</th></tr></thead><tbody id="positionsTable"><tr><td colspan="8" class="empty">Загрузка позиций…</td></tr></tbody></table></div>
</article>
</section>
<section class="page" id="page-activity" data-title="Активность">
<div class="activity-grid">
<article class="card activity-card">
<div class="card-head"><div><p class="eyebrow">Strategy</p><h2>Последние сигналы</h2></div></div>
<div class="feed" id="signalFeed"><div class="empty-block">Загрузка сигналов…</div></div>
</article>
<article class="card activity-card">
<div class="card-head"><div><p class="eyebrow">Execution</p><h2>Сделки</h2></div></div>
<div class="feed" id="tradeFeed"><div class="empty-block">Загрузка сделок…</div></div>
</article>
<article class="card activity-card">
<div class="card-head"><div><p class="eyebrow">System log</p><h2>События</h2></div></div>
<div class="feed" id="eventFeed"><div class="empty-block">Загрузка событий…</div></div>
</article>
</div>
</section>
<section class="page" id="page-system" data-title="Система">
<div class="system-grid">
<article class="card control-card">
<div class="card-head"><div><p class="eyebrow">Control</p><h2>Управление циклом</h2></div><span class="badge" id="controlBadge"></span></div>
<p class="subtext">Остановка завершает торговый цикл, но не удаляет позиции и данные. Запуск возобновляет обработку рынка.</p>
<div class="control-buttons"><button class="button button-primary" id="systemStartButton" type="button">Запустить</button><button class="button button-danger" id="systemStopButton" type="button">Остановить</button></div>
<div class="setting-row"><div><strong>Быстрая торговля</strong><span id="fastTradingHint">Уменьшенный интервал принятия решений</span></div><label class="switch"><input id="fastTradingToggle" type="checkbox"><span aria-hidden="true"></span><b class="sr-only">Переключить быструю торговлю</b></label></div>
</article>
<article class="card">
<div class="card-head"><div><p class="eyebrow">Model runtime</p><h2>Обучение и модель</h2></div></div>
<dl class="system-dl" id="trainingDetails"><div><dt>Состояние</dt><dd>Загрузка…</dd></div></dl>
<p class="guard-note">Запуск обучения и продвижение shadow-модели доступны только после серверной проверки gate и намеренно не выполняются этой панелью автоматически.</p>
</article>
<article class="card">
<div class="card-head"><div><p class="eyebrow">Connectivity</p><h2>Рыночные данные</h2></div></div>
<dl class="system-dl" id="networkDetails"><div><dt>Состояние</dt><dd>Загрузка…</dd></div></dl>
</article>
<article class="card">
<div class="card-head"><div><p class="eyebrow">Runtime</p><h2>Безопасная конфигурация</h2></div></div>
<dl class="system-dl" id="configDetails"><div><dt>Состояние</dt><dd>Загрузка…</dd></div></dl>
</article>
</div>
</section>
</main>
</div>
<dialog class="dialog" id="authDialog">
<form id="authForm">
<div class="dialog-icon">T</div>
<p class="eyebrow">Защищённый доступ</p>
<h2>Вход в TradeBot</h2>
<p>Введите логин и пароль панели управления TradeBot. Данные используются только для запросов из этой вкладки и не сохраняются в браузере.</p>
<div class="auth-fields">
<div>
<label for="usernameInput">Логин</label>
<input id="usernameInput" type="text" autocomplete="username" required>
</div>
<div>
<label for="passwordInput">Пароль</label>
<input id="passwordInput" type="password" autocomplete="current-password" required>
</div>
</div>
<p class="form-error" id="authError" role="alert"></p>
<button class="button button-primary button-wide" id="authSubmitButton" type="submit">Войти</button>
</form>
</dialog>
<dialog class="dialog dialog-confirm" id="confirmDialog">
<form method="dialog">
<p class="eyebrow">Подтверждение действия</p>
<h2 id="confirmTitle">Подтвердите действие</h2>
<p id="confirmText"></p>
<div class="dialog-actions"><button class="button button-secondary" value="cancel">Отмена</button><button class="button button-danger" id="confirmAction" value="confirm">Подтвердить</button></div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="/assets/dashboard.js?v=5" defer></script>
</body>
</html>
+18 -3
View File
@@ -6,16 +6,31 @@ services:
- .env - .env
environment: environment:
HOST: 0.0.0.0 HOST: 0.0.0.0
user: "1000:1000" PYTHONDONTWRITEBYTECODE: "1"
init: true
read_only: true
pids_limit: 128
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
ports: ports:
- "127.0.0.1:8787:8787" - "${TRADEBOT_BIND_ADDRESS:-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
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 30s start_period: 30s
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
restart: unless-stopped restart: unless-stopped
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest==9.1.1
+4 -5
View File
@@ -1,5 +1,4 @@
fastapi==0.115.6 fastapi==0.139.0
uvicorn[standard]==0.34.0 uvicorn[standard]==0.51.0
requests==2.32.3 requests==2.34.2
websockets==14.1 websockets==16.1
pytest==8.4.2
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-38
View File
@@ -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
-515
View File
@@ -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
-5
View File
@@ -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
View File
Binary file not shown.
+2
View File
@@ -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,
+38
View File
@@ -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
+45
View File
@@ -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,8 +88,51 @@ 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")
assert "kline.60.BTCUSDT" in payload assert "kline.60.BTCUSDT" in payload
assert "kline.1.BTCUSDT" not in payload assert "kline.1.BTCUSDT" not in payload
def test_orderbook_level_one_preserves_sizes(make_settings, tmp_path) -> None:
client = BybitClient(make_settings(tmp_path))
client.public_get = lambda *_args, **_kwargs: {
"b": [["100.5", "2.25"]],
"a": [["100.7", "1.75"]],
}
assert client.orderbook_level_one("BTCUSDT") == (100.5, 2.25, 100.7, 1.75)
assert client.orderbook_top("BTCUSDT") == (100.5, 100.7)
+240
View File
@@ -0,0 +1,240 @@
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,
_calibration_horizon,
_calibration_symbols,
_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_symbols_follow_explicit_configured_artifact_precedence() -> None:
artifact = {"symbols": {"btcusdt": {}, "ethusdt": {}}}
assert _calibration_symbols("solusdt, xrpusdt", ("ADAUSDT",), artifact) == [
"SOLUSDT",
"XRPUSDT",
]
assert _calibration_symbols("", ("ADAUSDT",), artifact) == ["ADAUSDT"]
assert _calibration_symbols("", (), artifact) == ["BTCUSDT", "ETHUSDT"]
def test_calibration_symbols_reject_malformed_artifact_symbols() -> None:
assert _calibration_symbols("", (), {"symbols": []}) == []
def test_explicit_calibration_horizon_selects_existing_multi_horizon_output() -> None:
entry = {"target_horizon": 12, "target_horizons": [3, 6, 12, 24]}
assert _calibration_horizon(entry, 24, explicit=True) == 24
assert _calibration_horizon(entry, 20, explicit=True) == 24
assert _calibration_horizon(entry, 24, explicit=False) == 12
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"}
+38 -3
View File
@@ -78,7 +78,7 @@ def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None:
assert settings.llm_advisor_enabled is False assert settings.llm_advisor_enabled is False
def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None: def test_default_symbols_are_discovered_from_bybit(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("AUTO_SELECT_SYMBOLS", raising=False) monkeypatch.delenv("AUTO_SELECT_SYMBOLS", raising=False)
monkeypatch.delenv("TOP_SYMBOLS_COUNT", raising=False) monkeypatch.delenv("TOP_SYMBOLS_COUNT", raising=False)
monkeypatch.delenv("SYMBOLS", raising=False) monkeypatch.delenv("SYMBOLS", raising=False)
@@ -89,9 +89,9 @@ def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None:
settings = load_settings(env_file) settings = load_settings(env_file)
assert settings.auto_select_symbols is False assert settings.auto_select_symbols is True
assert settings.top_symbols_count == len(FIXED_SPOT_SYMBOLS) assert settings.top_symbols_count == len(FIXED_SPOT_SYMBOLS)
assert settings.symbols == FIXED_SPOT_SYMBOLS assert settings.symbols == ()
assert settings.strategy_mode == "torch_forecast" assert settings.strategy_mode == "torch_forecast"
assert settings.base_interval == "60" assert settings.base_interval == "60"
assert settings.trend_interval == "D" assert settings.trend_interval == "D"
@@ -154,3 +154,38 @@ 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)
def test_load_settings_rejects_unknown_fallback_mode(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("TIME_SERIES_FALLBACK_MODE", raising=False)
env_file = tmp_path / ".env"
env_file.write_text("TIME_SERIES_FALLBACK_MODE=force-trades\n", encoding="utf-8")
with pytest.raises(ValueError, match="TIME_SERIES_FALLBACK_MODE"):
load_settings(env_file)
def test_load_settings_rejects_non_positive_observation_interval(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("MARKET_OBSERVATION_SAMPLE_SECONDS", raising=False)
env_file = tmp_path / ".env"
env_file.write_text("MARKET_OBSERVATION_SAMPLE_SECONDS=0\n", encoding="utf-8")
with pytest.raises(ValueError, match="MARKET_OBSERVATION_SAMPLE_SECONDS"):
load_settings(env_file)
+51 -4
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
import json import json
from crypto_spot_bot.dashboard import _compact_markets
from crypto_spot_bot.dashboard import _apply_fast_trading from crypto_spot_bot.dashboard import _apply_fast_trading
from crypto_spot_bot.dashboard import _safe_config from crypto_spot_bot.dashboard import _safe_config
from crypto_spot_bot.dashboard import WEB_UI_REMOVED_MESSAGE from crypto_spot_bot.dashboard import WEB_INDEX
from crypto_spot_bot.storage import Storage from crypto_spot_bot.storage import Storage
@@ -45,6 +46,9 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
assert config["time_series_probe_min_probability_up"] == 0.55 assert config["time_series_probe_min_probability_up"] == 0.55
assert config["time_series_probe_size_multiplier"] == 0.40 assert config["time_series_probe_size_multiplier"] == 0.40
assert config["time_series_rebound_fallback_enabled"] is True assert config["time_series_rebound_fallback_enabled"] is True
assert config["time_series_fallback_mode"] == "trend_macd"
assert config["market_observation_enabled"] is True
assert config["market_observation_sample_seconds"] == 30.0
assert config["time_series_model_artifact"] == { assert config["time_series_model_artifact"] == {
"available": True, "available": True,
"type": "pytorch_recurrent_forecaster", "type": "pytorch_recurrent_forecaster",
@@ -58,6 +62,49 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
} }
def test_web_ui_is_removed_from_api_service() -> None: def test_web_ui_assets_are_available() -> None:
assert "Web UI removed" in WEB_UI_REMOVED_MESSAGE html = WEB_INDEX.read_text(encoding="utf-8")
assert "/api/*" in WEB_UI_REMOVED_MESSAGE script = WEB_INDEX.with_name("dashboard.js").read_text(encoding="utf-8")
assert "TradeBot — панель управления" in html
assert "/assets/dashboard.css" in html
assert "/assets/dashboard.js" in html
assert 'id="usernameInput"' in html
assert 'id="passwordInput"' in html
assert "/web-api/dashboard/snapshot" in script
assert 'headers["X-TradeBot-Token"] = state.token' in script
assert 'credentials: "omit"' in script
assert "AbortController" in script
assert 'id="authSubmitButton"' in html
def test_compact_markets_keeps_dashboard_fields_and_limits_candles() -> None:
candles = [{"timestamp": index, "close": 100 + index, "open": 1} for index in range(60)]
compact = _compact_markets(
{
"symbols": ["BTCUSDT"],
"ws_connected": True,
"rest_error_count": 2,
"markets": [
{
"ticker": {"symbol": "BTCUSDT", "last_price": 160},
"candles": candles,
"forecast": {"expected_return_percent": 0.4},
"shadow_forecast": {"expected_return_percent": 0.5},
"orderbook": {"spread_bps": 1.2},
"quality": {"status": "ok"},
"instrument": {"symbol": "BTCUSDT"},
}
],
}
)
assert compact["ws_connected"] is True
assert compact["rest_error_count"] == 2
assert compact["markets"][0]["ticker"]["symbol"] == "BTCUSDT"
assert len(compact["markets"][0]["sparkline"]) == 48
assert compact["markets"][0]["sparkline"][0] == {"timestamp": 12, "close": 112}
assert "instrument" not in compact["markets"][0]
assert "orderbook" not in compact["markets"][0]
assert "shadow_forecast" not in compact["markets"][0]
+106 -1
View File
@@ -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"
+43 -1
View File
@@ -1,7 +1,8 @@
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 MarketData, _candles_due, _closed_candles, _is_closed_kline_row
from crypto_spot_bot.models import Candle from crypto_spot_bot.models import Candle
from crypto_spot_bot.storage import Storage
def test_closed_candles_excludes_current_open_interval() -> None: def test_closed_candles_excludes_current_open_interval() -> None:
@@ -19,3 +20,44 @@ 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
def test_orderbook_handler_samples_sizes_and_microstructure(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
market_observation_enabled=True,
market_observation_sample_seconds=30.0,
)
storage = Storage(settings.database_path)
market = MarketData(settings, object(), storage)
market._handle_orderbook(
"BTCUSDT",
{"b": [["100", "3"]], "a": [["101", "1"]]},
source_timestamp_ms=1_789_000_000_000,
)
market._handle_orderbook(
"BTCUSDT",
{"b": [["100", "4"]], "a": [["101", "1"]]},
source_timestamp_ms=1_789_000_001_000,
)
metrics = market.orderbook_metrics["BTCUSDT"]
rows = storage.market_observations_after(symbol="BTCUSDT")
assert metrics["bid_size"] == 4.0
assert metrics["ask_size"] == 1.0
assert metrics["imbalance"] == 0.6
assert metrics["microprice"] == 100.8
assert len(rows) == 1
assert rows[0]["bid_size"] == 3.0
assert rows[0]["ask_size"] == 1.0
assert rows[0]["imbalance"] == 0.5
assert rows[0]["microprice"] == 100.75
assert rows[0]["source_timestamp_ms"] == 1_789_000_000_000
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
import base64
import hashlib
import json
import pytest
from crypto_spot_bot.models import Candle
from crypto_spot_bot.orderbook_features import aggregate_orderbook_observations
from crypto_spot_bot.shadow import shadow_gate_snapshot
from crypto_spot_bot.storage import Storage
from crypto_spot_bot.time_series import _feature_matrix
from crypto_spot_bot.training_coordination import TrainingCoordinator
def test_orderbook_aggregation_is_bucketed_and_rejects_sparse_hours() -> None:
rows = [
_observation(1_700_000_000_000, imbalance=0.6, spread=2.0, mid=100.0, micro=100.01),
_observation(1_700_000_030_000, imbalance=0.2, spread=4.0, mid=100.0, micro=99.99),
_observation(1_700_003_600_000, imbalance=-0.9, spread=8.0, mid=100.0, micro=100.02),
]
features, manifest = aggregate_orderbook_observations(
rows,
interval="60",
min_samples_per_bucket=2,
)
assert manifest["BTCUSDT"]["covered_buckets"] == 1
assert manifest["BTCUSDT"]["rejected_buckets"] == 1
values = next(iter(features["BTCUSDT"].values()))
assert values["l1_imbalance_mean"] == pytest.approx(0.4)
assert values["l1_imbalance_std"] == pytest.approx(0.2)
assert values["l1_spread_bps_mean"] == pytest.approx(3.0)
assert values["l1_microprice_deviation_bps_mean"] == pytest.approx(0.0)
def test_feature_matrix_uses_only_the_matching_closed_candle_bucket() -> None:
candles = [
Candle(timestamp=0, open=100, high=101, low=99, close=100, volume=1, turnover=100),
Candle(timestamp=3_600_000, open=100, high=101, low=99, close=100, volume=1, turnover=100),
]
features = {
"BTCUSDT": {
0: {"l1_imbalance_mean": 0.25},
3_600_000: {"l1_imbalance_mean": -0.75},
7_200_000: {"l1_imbalance_mean": 0.99},
}
}
matrix = _feature_matrix(
candles,
["l1_imbalance_mean"],
symbol="BTCUSDT",
orderbook_features=features,
)
assert matrix == [[0.25], [-0.75]]
def test_shadow_gate_uses_only_settled_forward_predictions(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("SHADOW_GATE_MIN_SETTLED", "2")
monkeypatch.setenv("SHADOW_GATE_MIN_ELIGIBLE", "2")
monkeypatch.setenv("SHADOW_GATE_MIN_SYMBOLS", "1")
monkeypatch.setenv("SHADOW_GATE_MIN_DIRECTION_ACCURACY", "0.5")
monkeypatch.setenv("SHADOW_GATE_MAX_BRIER", "0.25")
storage = Storage(tmp_path / "bot.sqlite3")
model_sha = "a" * 64
for timestamp, actual in ((1, 1.0), (2, 2.0)):
storage.insert_shadow_prediction(
model_sha256=model_sha,
symbol="BTCUSDT",
forecast_timestamp_ms=timestamp,
horizon=1,
reference_price=100.0,
expected_return_percent=1.0,
probability_up=0.8,
eligible_signal=True,
)
row = storage.pending_shadow_predictions(
model_sha256=model_sha,
symbol="BTCUSDT",
)[0]
storage.settle_shadow_prediction(
row["id"],
actual_return_percent=actual,
take_profit_first=True,
)
gate = shadow_gate_snapshot(storage, model_sha)
assert gate["state"] == "passed"
assert gate["settled_predictions"] == 2
assert gate["eligible_predictions"] == 2
assert gate["total_net_percent"] == pytest.approx(3.0)
def test_shadow_bundle_does_not_replace_active_model(tmp_path) -> None:
active = {"type": "active-model"}
(tmp_path / "lstm_forecaster.json").write_text(json.dumps(active), encoding="utf-8")
coordinator = TrainingCoordinator(tmp_path)
job = coordinator.request_retrain({"source": "test"})["job"]
lease_token = coordinator.claim({"worker_id": "worker-1"})["lease_token"]
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_sha = hashlib.sha256(model_payload).hexdigest()
artifacts = {
"lstm_forecaster.shadow.json": model_payload,
"torch_shadow_guard.json": (
json.dumps({"accepted": True, "candidate_artifact_sha256": model_sha}) + "\n"
).encode(),
"torch_shadow_calibration.json": (
json.dumps(
{
"artifact_sha256": model_sha,
"validation": {
"passed": True,
"protocol": "untouched_model_holdout_with_threshold_walk_forward",
},
}
)
+ "\n"
).encode(),
}
for name, payload in artifacts.items():
coordinator.save_artifact_chunk(
job["id"],
{
"name": name,
"index": 0,
"total": 1,
"sha256": hashlib.sha256(payload).hexdigest(),
"data_base64": base64.b64encode(payload).decode("ascii"),
"lease_token": lease_token,
},
)
completed = coordinator.complete(
job["id"],
{"success": True, "summary": {"accepted": True, "deployment": "shadow"}, "lease_token": lease_token},
)
assert completed["job"]["status"] == "completed"
assert json.loads((tmp_path / "lstm_forecaster.json").read_text(encoding="utf-8")) == active
assert json.loads((tmp_path / "lstm_forecaster.shadow.json").read_text(encoding="utf-8"))["type"] == "pytorch_recurrent_forecaster"
def _observation(timestamp_ms: int, *, imbalance: float, spread: float, mid: float, micro: float) -> dict:
return {
"symbol": "BTCUSDT",
"bid_price": mid - 0.01,
"bid_size": 2.0,
"ask_price": mid + 0.01,
"ask_size": 1.0,
"mid_price": mid,
"microprice": micro,
"spread_bps": spread,
"imbalance": imbalance,
"source_timestamp_ms": timestamp_ms,
"created_at": "",
}
+23
View File
@@ -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
+139
View File
@@ -0,0 +1,139 @@
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_market_observation_export_is_symbol_scoped_and_paginated(tmp_path) -> None:
storage = Storage(tmp_path / "tradebot.sqlite3")
first_id = storage.insert_market_observation(
symbol="BTCUSDT",
bid_price=100.0,
bid_size=2.0,
ask_price=101.0,
ask_size=1.0,
mid_price=100.5,
microprice=100.6666666667,
spread_bps=99.50248756,
imbalance=1 / 3,
last_price=100.4,
source_timestamp_ms=1_789_000_000_000,
)
second_id = storage.insert_market_observation(
symbol="BTCUSDT",
bid_price=101.0,
bid_size=1.0,
ask_price=102.0,
ask_size=1.0,
mid_price=101.5,
microprice=101.5,
spread_bps=98.52216749,
imbalance=0.0,
last_price=101.4,
source_timestamp_ms=1_789_000_030_000,
)
storage.insert_market_observation(
symbol="ETHUSDT",
bid_price=10.0,
bid_size=1.0,
ask_price=11.0,
ask_size=1.0,
mid_price=10.5,
microprice=10.5,
spread_bps=952.38095238,
imbalance=0.0,
last_price=10.4,
)
rows = storage.market_observations_after(
symbol="BTCUSDT",
after_id=first_id,
limit=1,
)
assert [row["id"] for row in rows] == [second_id]
assert rows[0]["source_timestamp_ms"] == 1_789_000_030_000
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
+379 -2
View File
@@ -2,9 +2,85 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from crypto_spot_bot.models import Candle, Position, Ticker, utc_now from crypto_spot_bot.models import Candle, Position, 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, apply_profit_only_exit_policy
def test_profit_only_policy_blocks_every_ordinary_loss_exit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
profit_only_exit_enabled=True,
min_exit_net_percent=0.31,
taker_fee_rate=0.001,
slippage_rate=0.0003,
)
position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 100)
ticker = Ticker("ETHUSDT", 100.2, 100.19, 100.21, 1_000_000, 100, 0)
candidate = Signal("ETHUSDT", "SELL", 0.76, "RSI high and price turned down")
decision = apply_profit_only_exit_policy(settings, position, ticker, candidate)
assert decision.action == "HOLD"
assert decision.diagnostics["exit_policy_blocked"] is True
assert decision.diagnostics["blocked_sell_reason"] == candidate.reason
assert decision.diagnostics["expected_exit_net_percent"] < settings.min_exit_net_percent
def test_profit_only_policy_allows_exit_above_net_margin(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
profit_only_exit_enabled=True,
min_exit_net_percent=0.31,
taker_fee_rate=0.001,
slippage_rate=0.0003,
)
position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 101)
ticker = Ticker("ETHUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0)
candidate = Signal("ETHUSDT", "SELL", 0.96, "take-profit")
decision = apply_profit_only_exit_policy(settings, position, ticker, candidate)
assert decision.action == "SELL"
assert decision.diagnostics["exit_policy_blocked"] is False
assert decision.diagnostics["expected_exit_net_percent"] >= settings.min_exit_net_percent
def test_profit_only_policy_uses_adaptive_minimum(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, profit_only_exit_enabled=True, min_exit_net_percent=0.20)
position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 101)
ticker = Ticker("ETHUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0)
candidate = Signal(
"ETHUSDT",
"SELL",
0.76,
"EMA exit",
{"adaptive_rules": {"min_exit_profit_percent": 0.80}},
)
decision = apply_profit_only_exit_policy(settings, position, ticker, candidate)
assert decision.action == "HOLD"
assert decision.diagnostics["required_exit_net_percent"] == 0.80
def test_profit_only_policy_allows_explicit_emergency_loss_exit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, profit_only_exit_enabled=True, min_exit_net_percent=0.31)
position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 100)
ticker = Ticker("ETHUSDT", 95, 94.99, 95.01, 1_000_000, 100, 0)
candidate = Signal(
"ETHUSDT",
"SELL",
1.0,
"configured emergency",
{"emergency_exit": True, "emergency_exit_type": "configured_stop_loss"},
)
decision = apply_profit_only_exit_policy(settings, position, ticker, candidate)
assert decision.action == "SELL"
assert decision.diagnostics["exit_policy_blocked"] is False
assert decision.diagnostics["expected_exit_net_percent"] < 0
def _ready_candles() -> list[Candle]: def _ready_candles() -> list[Candle]:
@@ -566,6 +642,232 @@ 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_uses_legacy_fallback_in_paper_mode(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
time_series_trend_fallback_enabled=True,
time_series_fallback_mode="legacy",
time_series_require_quality_gate=True,
time_series_require_fresh_model=True,
grid_trading_enabled=False,
rebound_trading_enabled=False,
kelly_sizing_enabled=False,
)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0)
signal = strategy.entry_signal(
"BTCUSDT",
_ready_candles(),
ticker,
open_positions_for_symbol=0,
forecast={"usable": False, "model": "none", "quality_gate_passed": False},
account={"equity": 100.0, "cash": 100.0, "exposure": 0.0},
)
assert signal.action == "BUY"
assert signal.diagnostics["trade_mode"] == "LEGACY_FALLBACK"
assert signal.diagnostics["entry_path"] == "legacy_fallback"
assert signal.diagnostics["forecast_fallback_active"] is True
def test_torch_forecast_forces_trend_fallback_in_live_mode(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
trading_mode="live",
strategy_mode="torch_forecast",
time_series_trend_fallback_enabled=True,
time_series_fallback_mode="legacy",
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"
def test_torch_forecast_uses_legacy_exit_for_paper_fallback_position(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
time_series_trend_fallback_enabled=True,
time_series_fallback_mode="legacy",
)
strategy = SpotStrategy(settings)
position = Position(
1,
"BTCUSDT",
1,
100,
100,
0.1,
96,
103.5,
100,
entry_diagnostics={"entry_path": "legacy_fallback"},
)
ticker = Ticker("BTCUSDT", 104, 103.99, 104.01, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(position, _ready_candles(), ticker, forecast={})
assert signal.action == "SELL"
assert signal.diagnostics["trade_mode"] == "LEGACY_FALLBACK"
assert signal.diagnostics["entry_path"] == "legacy_fallback"
def test_torch_forecast_legacy_fallback_can_hold_after_minimum_time(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
time_series_trend_fallback_enabled=True,
time_series_fallback_mode="legacy",
)
strategy = SpotStrategy(settings)
position = Position(
1,
"BTCUSDT",
1,
100,
100,
0.1,
90,
120,
101,
opened_at=utc_now() - timedelta(seconds=600),
entry_diagnostics={"entry_path": "legacy_fallback"},
)
ticker = Ticker("BTCUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_ready_candles(),
ticker,
learning={"adaptive_rules": {}},
forecast={},
)
assert signal.action == "HOLD"
assert signal.diagnostics["trade_mode"] == "LEGACY_FALLBACK"
assert signal.diagnostics["adaptive_rules"] == {}
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 +1256,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)
+174
View File
@@ -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
+5 -1
View File
@@ -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",
},
} }
+122
View File
@@ -0,0 +1,122 @@
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,
_prepare_data,
)
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)
def test_pooled_training_populates_symbol_identity_feature() -> None:
candles = [
_candle(
index,
open_=100.0 + index * 0.01,
high=100.2 + index * 0.01,
low=99.8 + index * 0.01,
close=100.0 + index * 0.01,
)
for index in range(180)
]
prepared = _prepare_data(
symbol="BTCUSDT",
candles=candles,
feature_names=["return_1", "symbol_is_BTCUSDT", "symbol_is_ETHUSDT"],
lookback=8,
target_horizons=[3],
decision_horizon=3,
round_trip_cost=0.002,
stop_loss_percent=0.04,
take_profit_percent=0.035,
market_candles={"BTCUSDT": candles},
trend_candles=candles,
validation_window=24,
holdout_window=32,
clip=8.0,
device=torch.device("cpu"),
)
assert prepared is not None
assert torch.all(prepared.train_x[:, :, 1] == 1.0)
assert torch.all(prepared.train_x[:, :, 2] == 0.0)
+232 -4
View File
@@ -3,8 +3,11 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import json import json
from datetime import UTC, datetime, timedelta
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:
@@ -14,6 +17,7 @@ def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
job_id = requested["job"]["id"] job_id = requested["job"]["id"]
heartbeat = coordinator.heartbeat({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"}) heartbeat = coordinator.heartbeat({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
claimed = coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"}) claimed = coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
lease_token = claimed["lease_token"]
assert requested["queued"] is True assert requested["queued"] is True
assert heartbeat["status"]["agent_online"] is True assert heartbeat["status"]["agent_online"] is True
@@ -23,22 +27,116 @@ def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
progress = coordinator.progress( progress = coordinator.progress(
job_id, job_id,
{"status": "running", "phase": "training", "progress_percent": 42, "message": "epoch 1"}, {
"status": "running",
"phase": "training",
"progress_percent": 42,
"message": "epoch 1",
"lease_token": lease_token,
},
) )
assert progress["job"]["phase"] == "training" assert progress["job"]["phase"] == "training"
assert progress["job"]["progress_percent"] == 42 assert progress["job"]["progress_percent"] == 42
assert coordinator.status()["active_job"]["message"] == "epoch 1" assert coordinator.status()["active_job"]["message"] == "epoch 1"
completed = coordinator.complete(job_id, {"success": True, "message": "ok"}) completed = coordinator.complete(
job_id,
{"success": True, "message": "ok", "lease_token": lease_token},
)
assert completed["job"]["status"] == "completed" assert completed["job"]["status"] == "completed"
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"]
lease_token = coordinator.claim({"worker_id": "worker-1"})["lease_token"]
completed = coordinator.complete(
job["id"],
{
"success": True,
"message": "training completed; candidate rejected by quality gate",
"summary": {"accepted": False, "reason": "candidate_failed_honest_validation"},
"lease_token": lease_token,
},
)
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"]
lease_token = coordinator.claim({"worker_id": "test-worker"})["lease_token"]
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]
@@ -52,6 +150,7 @@ def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
"total": 2, "total": 2,
"sha256": sha256, "sha256": sha256,
"data_base64": base64.b64encode(first).decode("ascii"), "data_base64": base64.b64encode(first).decode("ascii"),
"lease_token": lease_token,
}, },
) )
part_2 = coordinator.save_artifact_chunk( part_2 = coordinator.save_artifact_chunk(
@@ -62,15 +161,41 @@ def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
"total": 2, "total": 2,
"sha256": sha256, "sha256": sha256,
"data_base64": base64.b64encode(second).decode("ascii"), "data_base64": base64.b64encode(second).decode("ascii"),
"lease_token": lease_token,
}, },
) )
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 +211,106 @@ 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_stale_training_lease_is_requeued_and_old_lease_is_rejected(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
job = coordinator.request_retrain({"source": "android"})["job"]
first_claim = coordinator.claim({"worker_id": "worker-1"})
state_path = tmp_path / "training_coordination.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["jobs"][0]["updated_at"] = (
datetime.now(UTC) - timedelta(minutes=11)
).isoformat()
state_path.write_text(json.dumps(state), encoding="utf-8")
second_claim = coordinator.claim({"worker_id": "worker-2"})
assert second_claim["claimed"] is True
assert second_claim["job"]["id"] == job["id"]
assert second_claim["job"]["attempts"] == 2
assert second_claim["lease_token"] != first_claim["lease_token"]
with pytest.raises(ValueError, match="lease"):
coordinator.progress(
job["id"],
{
"phase": "training",
"progress_percent": 10,
"lease_token": first_claim["lease_token"],
},
)
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"]
lease_token = coordinator.claim({"worker_id": "worker-1"})["lease_token"]
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"),
"lease_token": lease_token,
},
)
completed = coordinator.complete(
job["id"],
{"success": True, "lease_token": lease_token},
)
assert completed["job"]["status"] == "completed"
assert json.loads((tmp_path / "lstm_forecaster.json").read_text())["symbols"]["BTCUSDT"]
+25 -9
View File
@@ -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,19 +12,25 @@ 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)
decision = _decision( candidate_artifact = Path(args.candidate_artifact)
current, candidate_sha256 = _sha256(candidate_artifact)
candidate, if candidate.get("artifact_sha256") != candidate_sha256:
min_trades=args.min_trades, decision = {"accepted": False, "reason": "candidate_report_artifact_hash_mismatch"}
min_profit_factor=args.min_profit_factor, else:
min_avg_net_percent=args.min_avg_net_percent, decision = _decision(
max_score_regression=args.max_score_regression, current,
) candidate,
min_trades=args.min_trades,
min_profit_factor=args.min_profit_factor,
min_avg_net_percent=args.min_avg_net_percent,
max_score_regression=args.max_score_regression,
)
payload = { payload = {
"accepted": decision["accepted"], "accepted": decision["accepted"],
"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()
+532 -60
View File
@@ -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
@@ -25,8 +26,10 @@ from crypto_spot_bot.bybit import BybitClient
from crypto_spot_bot.config import load_settings from crypto_spot_bot.config import load_settings
from crypto_spot_bot.indicators import add_indicators 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.orderbook_features import ORDERBOOK_FEATURES, load_orderbook_feature_map
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 +52,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 +66,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)
@@ -81,13 +89,23 @@ def main() -> None:
if torch is not None and args.threads > 0: if torch is not None and args.threads > 0:
torch.set_num_threads(args.threads) torch.set_num_threads(args.threads)
settings = load_settings(args.env) settings = load_settings(args.env)
client = BybitClient(settings)
symbols = _symbols(args.symbols, settings.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"))
client = BybitClient(settings)
symbols = _calibration_symbols(args.symbols, settings.symbols, artifact)
context_symbols = sorted(set(symbols + _symbols(args.context_symbols, ())))
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)
orderbook_features: dict[str, dict[int, dict[str, float]]] = {}
if args.orderbook_db:
orderbook_features, _orderbook_manifest = load_orderbook_feature_map(
args.orderbook_db,
interval=settings.base_interval,
symbols=symbols,
min_samples_per_bucket=args.orderbook_min_samples_per_bucket,
)
market_candles: dict[str, list[Candle]] = {} market_candles: dict[str, list[Candle]] = {}
for symbol in context_symbols: for symbol in context_symbols:
@@ -111,10 +129,12 @@ def main() -> None:
trend_candles=trend_candles, trend_candles=trend_candles,
artifact=artifact, artifact=artifact,
horizon=horizon, horizon=horizon,
horizon_is_explicit=args.horizon > 0,
round_trip_cost=round_trip_cost, round_trip_cost=round_trip_cost,
min_candles=max(30, settings.time_series_min_candles), min_candles=max(30, settings.time_series_min_candles),
calibration_window=args.calibration_window, calibration_window=args.calibration_window,
batch_size=args.batch_size, batch_size=args.batch_size,
orderbook_features=orderbook_features,
) )
records.extend(symbol_records) records.extend(symbol_records)
per_symbol_counts[symbol] = len(symbol_records) per_symbol_counts[symbol] = len(symbol_records)
@@ -123,13 +143,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 +171,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 +242,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 +267,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,
@@ -213,7 +288,11 @@ def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Calibrate TradeBot Torch forecast entry thresholds.") parser = argparse.ArgumentParser(description="Calibrate TradeBot Torch forecast entry thresholds.")
parser.add_argument("--env", default=None, help="Path to .env file.") parser.add_argument("--env", default=None, help="Path to .env file.")
parser.add_argument("--artifact", default="", help="Path to lstm_forecaster.json.") parser.add_argument("--artifact", default="", help="Path to lstm_forecaster.json.")
parser.add_argument("--symbols", default="", help="Comma-separated symbols. Defaults to configured fixed symbols.") parser.add_argument(
"--symbols",
default="",
help="Comma-separated symbols. Defaults to configured fixed symbols, then artifact symbols.",
)
parser.add_argument("--context-symbols", default="BTCUSDT,ETHUSDT", help="Cross-asset context symbols.") parser.add_argument("--context-symbols", default="BTCUSDT,ETHUSDT", help="Cross-asset context symbols.")
parser.add_argument("--limit", type=int, default=2000, help="Hourly candles per symbol.") parser.add_argument("--limit", type=int, default=2000, help="Hourly candles per symbol.")
parser.add_argument("--trend-limit", type=int, default=320, help="Daily candles per symbol.") parser.add_argument("--trend-limit", type=int, default=320, help="Daily candles per symbol.")
@@ -235,6 +314,8 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--min-oos-folds-with-trades", type=int, default=2, help="Minimum walk-forward folds that must produce trades.") parser.add_argument("--min-oos-folds-with-trades", type=int, default=2, help="Minimum walk-forward folds that must produce trades.")
parser.add_argument("--min-oos-profit-factor", type=float, default=1.10, help="Minimum out-of-sample profit factor.") parser.add_argument("--min-oos-profit-factor", type=float, default=1.10, help="Minimum out-of-sample profit factor.")
parser.add_argument("--min-benchmark-edge-percent", type=float, default=0.0, help="Required total-net percent advantage over the benchmark.") parser.add_argument("--min-benchmark-edge-percent", type=float, default=0.0, help="Required total-net percent advantage over the benchmark.")
parser.add_argument("--orderbook-db", default="", help="SQLite cache used by an artifact with L1 features.")
parser.add_argument("--orderbook-min-samples-per-bucket", type=int, default=20)
return parser.parse_args() return parser.parse_args()
@@ -244,6 +325,32 @@ def _symbols(raw: str, fallback: tuple[str, ...] | list[str]) -> list[str]:
return [str(item).upper() for item in fallback] return [str(item).upper() for item in fallback]
def _calibration_symbols(
raw: str,
configured: tuple[str, ...] | list[str],
artifact: dict[str, Any],
) -> list[str]:
explicit = _symbols(raw, ())
if explicit:
return explicit
fixed = _symbols("", configured)
if fixed:
return fixed
artifact_symbols = artifact.get("symbols")
if not isinstance(artifact_symbols, dict):
return []
return [str(symbol).strip().upper() for symbol in artifact_symbols if str(symbol).strip()]
def _calibration_horizon(entry: dict[str, Any], requested: int, *, explicit: bool) -> int:
horizons = _entry_target_horizons(entry)
if explicit and requested > 0:
if horizons:
return min(horizons, key=lambda value: abs(value - requested))
return requested
return _entry_horizon(entry, requested)
def _forecast_records( def _forecast_records(
*, *,
symbol: str, symbol: str,
@@ -252,10 +359,12 @@ def _forecast_records(
trend_candles: list[Candle], trend_candles: list[Candle],
artifact: dict[str, Any], artifact: dict[str, Any],
horizon: int, horizon: int,
horizon_is_explicit: bool,
round_trip_cost: float, round_trip_cost: float,
min_candles: int, min_candles: int,
calibration_window: int, calibration_window: int,
batch_size: int, batch_size: int,
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
) -> list[ForecastRecord]: ) -> list[ForecastRecord]:
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)
@@ -268,13 +377,31 @@ def _forecast_records(
symbol=symbol, symbol=symbol,
market_candles=market_candles, market_candles=market_candles,
trend_candles=trend_candles, trend_candles=trend_candles,
orderbook_features=orderbook_features,
) )
closes = [float(candle.close) for candle in candles] closes = [float(candle.close) for candle in candles]
decision_horizon = _entry_horizon(entry, horizon) decision_horizon = _calibration_horizon(entry, horizon, explicit=horizon_is_explicit)
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)
lookback = max(1, int(float(entry.get("lookback", 64))))
requires_orderbook = any(name in ORDERBOOK_FEATURES for name in feature_names)
symbol_orderbook = (orderbook_features or {}).get(symbol.upper(), {})
valid_indices = {
index
for index in range(start, max(start, end))
if not requires_orderbook
or all(
candles[position].timestamp in symbol_orderbook
for position in range(index - lookback + 1, index + 1)
)
}
batched_records = _batch_forecast_records( batched_records = _batch_forecast_records(
symbol=symbol, symbol=symbol,
candles=candles, candles=candles,
@@ -288,13 +415,18 @@ def _forecast_records(
start=start, start=start,
end=end, end=end,
batch_size=batch_size, batch_size=batch_size,
valid_indices=valid_indices,
) )
if batched_records is not None: if batched_records is not None:
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)):
if index not in valid_indices:
continue
prediction = _torch_recurrent_predict( prediction = _torch_recurrent_predict(
_log_returns(closes[: index + 1]), _log_returns(closes[: index + 1]),
symbol, symbol,
@@ -313,7 +445,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 +471,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 +485,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
@@ -350,14 +505,15 @@ def _batch_forecast_records(
start: int, start: int,
end: int, end: int,
batch_size: int, batch_size: int,
valid_indices: set[int] | None = None,
) -> list[ForecastRecord] | None: ) -> list[ForecastRecord] | None:
if torch is None or RecurrentReturnModel is None: if torch is None or RecurrentReturnModel is None:
return None return None
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))
@@ -368,14 +524,17 @@ def _batch_forecast_records(
indices = [ indices = [
index index
for index in range(start, max(start, end)) for index in range(start, max(start, end))
if index - lookback + 1 >= 0 and index + decision_horizon < len(closes) if index - lookback + 1 >= 0
and index + decision_horizon < len(closes)
and (valid_indices is None or index in valid_indices)
] ]
if not indices: if not indices:
return [] return []
records: list[ForecastRecord] = [] records: list[ForecastRecord] = []
skill = float(entry.get("skill", 0.0) or 0.0) skill = _entry_validation_skill(entry)
model.eval() for model in models:
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)):
batch_indices = indices[offset : offset + max(1, batch_size)] batch_indices = indices[offset : offset + max(1, batch_size)]
@@ -390,17 +549,25 @@ 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, [
entry=entry, decoded
candles=candles, for outputs in outputs_by_model
closes=closes, if (
index=index, decoded := _decode_selected_output(
horizon=decision_horizon, outputs[batch_offset],
clip=clip, entry=entry,
round_trip_cost=round_trip_cost, candles=candles,
closes=closes,
index=index,
horizon=decision_horizon,
clip=clip,
round_trip_cost=round_trip_cost,
)
) is not None
]
) )
if selected is None: if selected is None:
continue continue
@@ -409,7 +576,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 +602,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 +616,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 +655,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,12 +668,26 @@ 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)
} }
head_weight = entry.get("head_weight") if bool(entry.get("multitask_head")):
head_bias = entry.get("head_bias") for artifact_name, state_name in (
if not isinstance(head_weight, list) or not isinstance(head_bias, list): ("head_hidden_weight", "head_hidden.weight"),
return None ("head_hidden_bias", "head_hidden.bias"),
state["head.weight"] = torch.tensor(head_weight, dtype=torch.float32) ("return_head_weight", "return_head.weight"),
state["head.bias"] = torch.tensor(head_bias, dtype=torch.float32) ("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_bias = entry.get("head_bias")
if not isinstance(head_weight, list) or not isinstance(head_bias, list):
return None
state["head.weight"] = torch.tensor(head_weight, dtype=torch.float32)
state["head.bias"] = torch.tensor(head_bias, dtype=torch.float32)
if bool(entry.get("attention_pooling")): if bool(entry.get("attention_pooling")):
attention_weight = entry.get("attention_weight") attention_weight = entry.get("attention_weight")
if not isinstance(attention_weight, list): if not isinstance(attention_weight, list):
@@ -486,6 +708,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 +755,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 +804,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 +829,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 +876,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 +925,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 +941,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 +981,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 +1034,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 +1089,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 +1185,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 +1230,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 +1401,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 +1514,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 +1523,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 +1601,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 +1625,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 +1676,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()
+143
View File
@@ -0,0 +1,143 @@
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,
"market_observations": 100_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"])
parser.add_argument(
"--market-observations",
type=int,
default=DEFAULT_RECENT_ROWS["market_observations"],
)
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,
"market_observations": args.market_observations,
},
)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
if __name__ == "__main__":
main()
-94
View File
@@ -1,94 +0,0 @@
[CmdletBinding()]
param(
[string]$TaskName = "TradeBot PyTorch Forecaster Retrainer",
[int]$EveryHours = 6,
[string]$Symbols = "",
[int]$Limit = 3000,
[int]$Horizon = 0,
[string]$Horizons = "",
[string]$Features = "",
[string]$ContextSymbols = "",
[int]$FirstRunMinutes = 0,
[switch]$DeployToPi,
[string]$PiHost = "192.168.0.185",
[string]$PiUser = "sevenhill",
[string]$PiRoot = "/mnt/data/tradebot",
[string]$PiSshKeyPath = ""
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$Runner = Join-Path $RepoRoot "tools\run_torch_retrain.ps1"
if (-not (Test-Path $Runner)) {
throw "Runner not found: $Runner"
}
$LegacyTaskName = "TradeBot LSTM Retrainer"
if ($TaskName -ne $LegacyTaskName) {
$legacyTask = Get-ScheduledTask -TaskName $LegacyTaskName -ErrorAction SilentlyContinue
if ($legacyTask) {
Unregister-ScheduledTask -TaskName $LegacyTaskName -Confirm:$false
}
}
$actionArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$Runner`""
if ($Symbols) {
$actionArgs += " -Symbols `"$Symbols`""
}
if ($Limit -gt 0) {
$actionArgs += " -Limit $Limit"
}
if ($Horizon -gt 0) {
$actionArgs += " -Horizon $Horizon"
}
if ($Horizons) {
$actionArgs += " -Horizons `"$Horizons`""
}
if ($Features) {
$actionArgs += " -Features `"$Features`""
}
if ($ContextSymbols) {
$actionArgs += " -ContextSymbols `"$ContextSymbols`""
}
if ($DeployToPi) {
$actionArgs += " -DeployToPi"
}
if ($PiHost) {
$actionArgs += " -PiHost `"$PiHost`""
}
if ($PiUser) {
$actionArgs += " -PiUser `"$PiUser`""
}
if ($PiRoot) {
$actionArgs += " -PiRoot `"$PiRoot`""
}
if ($PiSshKeyPath) {
$actionArgs += " -PiSshKeyPath `"$PiSshKeyPath`""
}
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $actionArgs -WorkingDirectory $RepoRoot
$trigger = New-ScheduledTaskTrigger `
-Once `
-At (Get-Date).AddMinutes($(if ($FirstRunMinutes -gt 0) { $FirstRunMinutes } else { $EveryHours * 60 })) `
-RepetitionInterval (New-TimeSpan -Hours $EveryHours) `
-RepetitionDuration (New-TimeSpan -Days 3650)
$principal = New-ScheduledTaskPrincipal `
-UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) `
-LogonType Interactive `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-MultipleInstances IgnoreNew `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Description "Retrains TradeBot PyTorch recurrent forecast parameters every $EveryHours hours." `
-Force | Out-Null
Write-Host "Registered scheduled task '$TaskName' every $EveryHours hours."
+128 -79
View File
@@ -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,105 +16,153 @@ $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")) {
$legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue try {
if ($legacyTask) { $legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $legacyName -Confirm:$false if ($legacyTask) {
Write-Host "Removed legacy scheduled task '$legacyName'." Unregister-ScheduledTask -TaskName $legacyName -Confirm:$false
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"
New-ScheduledTaskTrigger -AtLogOn -User $currentUser Remove-ItemProperty -Path $runKey -Name "TradeBotWindowsTrainingAgent" -ErrorAction SilentlyContinue
New-ScheduledTaskTrigger -AtStartup
New-ScheduledTaskTrigger `
-Once `
-At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
-RepetitionDuration (New-TimeSpan -Days 3650)
)
$principal = New-ScheduledTaskPrincipal `
-UserId $currentUser `
-LogonType Interactive `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-MultipleInstances IgnoreNew `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-RestartCount 999 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit (New-TimeSpan -Days 30)
Register-ScheduledTask ` $installMode = "startup shortcut"
-TaskName $TaskName ` if ($isAdministrator) {
-Action $action ` if (Test-Path -LiteralPath $startupShortcut) {
-Trigger $trigger ` Remove-Item -LiteralPath $startupShortcut -Force
-Principal $principal ` }
-Settings $settings ` $action = New-ScheduledTaskAction -Execute $powershell -Argument $runnerArguments -WorkingDirectory $RepoRoot
-Description "Keeps the TradeBot Windows training agent online and polls the public bot API for retrain jobs." ` $trigger = @(
-Force | Out-Null New-ScheduledTaskTrigger -AtLogOn -User $currentUser
New-ScheduledTaskTrigger -AtStartup
New-ScheduledTaskTrigger `
-Once `
-At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
-RepetitionDuration (New-TimeSpan -Days 3650)
)
$taskPrincipal = New-ScheduledTaskPrincipal `
-UserId $currentUser `
-LogonType Interactive `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-MultipleInstances IgnoreNew `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-RestartCount 999 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit (New-TimeSpan -Days 30)
if ($StartNow) { Register-ScheduledTask `
Start-ScheduledTask -TaskName $TaskName -TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Principal $taskPrincipal `
-Settings $settings `
-Description "Keeps the TradeBot Windows training agent online and polls the bot API for retrain jobs." `
-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)"
}
$shell = New-Object -ComObject WScript.Shell
$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"
-152
View File
@@ -1,152 +0,0 @@
[CmdletBinding()]
param(
[int]$MinReplayTrades = 8,
[int]$MaxAttempts = 0,
[string]$Symbols = "",
[int]$Limit = 3000,
[switch]$DeployToPi,
[string]$PiHost = "192.168.0.185",
[string]$PiUser = "sevenhill",
[string]$PiRoot = "/mnt/data/tradebot",
[string]$PiSshKeyPath = "",
[int]$SeedStart = 0
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$RuntimeDir = Join-Path $RepoRoot "runtime"
$LoopLog = Join-Path $RuntimeDir "torch_retrain_until_replay8.log"
$GuardReport = Join-Path $RuntimeDir "torch_retrain_guard.json"
$ActiveCalibration = Join-Path $RuntimeDir "torch_threshold_calibration.json"
$Runner = Join-Path $RepoRoot "tools\run_torch_retrain.ps1"
New-Item -ItemType Directory -Force -Path $RuntimeDir | Out-Null
function Write-LoopLog {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ssK"
"[$timestamp] $Message" | Tee-Object -FilePath $LoopLog -Append
}
function ConvertTo-IntOrZero {
param($Value)
try {
if ($null -eq $Value) {
return 0
}
return [int]$Value
}
catch {
return 0
}
}
function Read-GuardSummary {
if (-not (Test-Path $GuardReport)) {
return [pscustomobject]@{
Accepted = $false
Reason = "guard_report_missing"
CandidateReplayTrades = 0
CurrentReplayTrades = 0
WalkForwardTrades = 0
}
}
try {
$payload = Get-Content -Raw -LiteralPath $GuardReport | ConvertFrom-Json
return [pscustomobject]@{
Accepted = [bool]$payload.accepted
Reason = [string]$payload.reason
CandidateReplayTrades = ConvertTo-IntOrZero $payload.candidate.full_replay.trades
CurrentReplayTrades = ConvertTo-IntOrZero $payload.current.full_replay.trades
WalkForwardTrades = ConvertTo-IntOrZero $payload.candidate.walk_forward_summary.trades
}
}
catch {
return [pscustomobject]@{
Accepted = $false
Reason = "guard_report_unreadable"
CandidateReplayTrades = 0
CurrentReplayTrades = 0
WalkForwardTrades = 0
}
}
}
function Read-ActiveReplayTrades {
if (-not (Test-Path $ActiveCalibration)) {
return 0
}
try {
$payload = Get-Content -Raw -LiteralPath $ActiveCalibration | ConvertFrom-Json
return ConvertTo-IntOrZero $payload.full_replay.trades
}
catch {
return 0
}
}
function Read-ActiveValidationPassed {
if (-not (Test-Path $ActiveCalibration)) {
return $false
}
try {
$payload = Get-Content -Raw -LiteralPath $ActiveCalibration | ConvertFrom-Json
return [bool]$payload.validation.passed
}
catch {
return $false
}
}
$attempt = 0
while ($true) {
$activeReplayTrades = Read-ActiveReplayTrades
if (Read-ActiveValidationPassed) {
Write-LoopLog "Stop condition reached: active calibration passed honest validation with full_replay.trades=$activeReplayTrades."
exit 0
}
$attempt += 1
if ($SeedStart -gt 0) {
$attemptSeed = $SeedStart + $attempt - 1
}
else {
$attemptSeed = Get-Random -Minimum 1 -Maximum 2147483647
}
Write-LoopLog "Attempt $attempt started; seed=$attemptSeed; target full_replay.trades >= $MinReplayTrades."
$runnerArgs = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", $Runner,
"-Limit", $Limit.ToString(),
"-Seed", $attemptSeed.ToString()
)
if ($Symbols) {
$runnerArgs += @("-Symbols", $Symbols)
}
if ($DeployToPi) {
$runnerArgs += "-DeployToPi"
if ($PiHost) { $runnerArgs += @("-PiHost", $PiHost) }
if ($PiUser) { $runnerArgs += @("-PiUser", $PiUser) }
if ($PiRoot) { $runnerArgs += @("-PiRoot", $PiRoot) }
if ($PiSshKeyPath) { $runnerArgs += @("-PiSshKeyPath", $PiSshKeyPath) }
}
& powershell.exe @runnerArgs 2>&1 | Tee-Object -FilePath $LoopLog -Append
$runnerExit = $LASTEXITCODE
$summary = Read-GuardSummary
Write-LoopLog "Attempt $attempt finished; runner_exit=$runnerExit accepted=$($summary.Accepted) reason=$($summary.Reason) candidate_full_replay.trades=$($summary.CandidateReplayTrades) current_full_replay.trades=$($summary.CurrentReplayTrades) walk_forward.trades=$($summary.WalkForwardTrades)."
if ($summary.Accepted -and (Read-ActiveValidationPassed)) {
Write-LoopLog "Stop condition reached: accepted candidate passed honest validation with full_replay.trades=$($summary.CandidateReplayTrades)."
exit 0
}
if ($MaxAttempts -gt 0 -and $attempt -ge $MaxAttempts) {
Write-LoopLog "MaxAttempts=$MaxAttempts reached before replay target."
exit 2
}
Start-Sleep -Seconds 10
}
+156 -83
View File
@@ -12,17 +12,23 @@ 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, [string]$OrderbookDb = "",
[string]$PiHost = "", [int]$OrderbookMinSamplesPerBucket = 0,
[string]$PiUser = "", [int]$OrderbookMinCoveredBuckets = 0,
[string]$PiRoot = "", [int]$OrderbookMinSymbols = 0,
[string]$PiSshKeyPath = "", [switch]$Pooled,
[switch]$NoPiRestart, [switch]$SkipGuard,
[switch]$SkipGuard [switch]$ResumeCandidate
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -38,6 +44,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) {
@@ -73,56 +103,35 @@ function Test-TorchArtifactFile {
} }
} }
function Sync-AcceptedArtifactsToPi {
if (-not ($DeployToPi -or $env:TORCH_RETRAIN_DEPLOY_TO_PI)) {
Write-RetrainLog "Pi artifact sync disabled."
return
}
$syncScript = Join-Path $RepoRoot "tools\sync_torch_artifacts_to_pi.ps1"
if (-not (Test-Path $syncScript)) {
throw "Pi sync script not found: $syncScript"
}
$syncArgs = @(
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", $syncScript,
"-RepoRoot", $RepoRoot
)
if ($PiHost) { $syncArgs += @("-RemoteHost", $PiHost) }
if ($PiUser) { $syncArgs += @("-RemoteUser", $PiUser) }
if ($PiRoot) { $syncArgs += @("-RemoteRoot", $PiRoot) }
if ($PiSshKeyPath) { $syncArgs += @("-SshKeyPath", $PiSshKeyPath) }
if ($NoPiRestart) { $syncArgs += "-NoRestart" }
Write-RetrainLog "Syncing accepted Torch artifacts to Raspberry Pi."
& powershell.exe @syncArgs 2>&1 | Tee-Object -FilePath $LogFile -Append
if ($LASTEXITCODE -ne 0) {
throw "Pi artifact sync failed with exit code $LASTEXITCODE."
}
Write-RetrainLog "Pi artifact sync completed."
}
if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS } if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS }
if ($Limit -le 0) { if ($Limit -le 0) {
$Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 3000 } $Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 4000 }
} }
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 { "64" } }
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" } }
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" } }
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 ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } } 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 { 50 } }
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" }
if (-not $OrderbookDb -and $env:TORCH_ORDERBOOK_DB) { $OrderbookDb = $env:TORCH_ORDERBOOK_DB }
if ($OrderbookMinSamplesPerBucket -le 0) { $OrderbookMinSamplesPerBucket = if ($env:TORCH_ORDERBOOK_MIN_SAMPLES_PER_BUCKET) { [int]$env:TORCH_ORDERBOOK_MIN_SAMPLES_PER_BUCKET } else { 20 } }
if ($OrderbookMinCoveredBuckets -le 0) { $OrderbookMinCoveredBuckets = if ($env:TORCH_ORDERBOOK_MIN_COVERED_BUCKETS) { [int]$env:TORCH_ORDERBOOK_MIN_COVERED_BUCKETS } else { 240 } }
if ($OrderbookMinSymbols -le 0) { $OrderbookMinSymbols = if ($env:TORCH_ORDERBOOK_MIN_SYMBOLS) { [int]$env:TORCH_ORDERBOOK_MIN_SYMBOLS } else { 2 } }
$ModelFile = if ($env:TIME_SERIES_LSTM_MODEL_PATH) { $env:TIME_SERIES_LSTM_MODEL_PATH } else { Join-Path $RuntimeDir "lstm_forecaster.json" } $ModelFile = if ($env:TIME_SERIES_LSTM_MODEL_PATH) { $env:TIME_SERIES_LSTM_MODEL_PATH } else { Join-Path $RuntimeDir "lstm_forecaster.json" }
if (-not [System.IO.Path]::IsPathRooted($ModelFile)) { $ModelFile = Join-Path $RepoRoot $ModelFile } if (-not [System.IO.Path]::IsPathRooted($ModelFile)) { $ModelFile = Join-Path $RepoRoot $ModelFile }
@@ -130,6 +139,10 @@ $CandidateFile = Join-Path $RuntimeDir "lstm_forecaster.candidate.json"
$CurrentCalibration = Join-Path $RuntimeDir "torch_guard_current.json" $CurrentCalibration = Join-Path $RuntimeDir "torch_guard_current.json"
$CandidateCalibration = Join-Path $RuntimeDir "torch_guard_candidate.json" $CandidateCalibration = Join-Path $RuntimeDir "torch_guard_candidate.json"
$GuardReport = Join-Path $RuntimeDir "torch_retrain_guard.json" $GuardReport = Join-Path $RuntimeDir "torch_retrain_guard.json"
$ShadowModelFile = Join-Path $RuntimeDir "lstm_forecaster.shadow.json"
$ShadowCalibration = Join-Path $RuntimeDir "torch_shadow_calibration.json"
$ShadowGuard = Join-Path $RuntimeDir "torch_shadow_guard.json"
$ShadowMode = -not [string]::IsNullOrWhiteSpace($OrderbookDb)
$mutex = New-Object System.Threading.Mutex($false, "TradeBotTorchRecurrentRetrainer") $mutex = New-Object System.Threading.Mutex($false, "TradeBotTorchRecurrentRetrainer")
$hasLock = $false $hasLock = $false
@@ -154,8 +167,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) }
@@ -164,73 +189,121 @@ try {
if ($Features) { $trainerArgs += @("--features", $Features) } if ($Features) { $trainerArgs += @("--features", $Features) }
if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) } if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) }
if ($Seed -gt 0) { $trainerArgs += @("--seed", $Seed.ToString()) } if ($Seed -gt 0) { $trainerArgs += @("--seed", $Seed.ToString()) }
if ($OrderbookDb) {
$trainerArgs += @(
"--orderbook-db", $OrderbookDb,
"--orderbook-min-samples-per-bucket", $OrderbookMinSamplesPerBucket.ToString(),
"--orderbook-min-covered-buckets", $OrderbookMinCoveredBuckets.ToString(),
"--orderbook-min-symbols", $OrderbookMinSymbols.ToString()
)
}
Push-Location $RepoRoot Push-Location $RepoRoot
$pushedLocation = $true $pushedLocation = $true
Write-RetrainLog "Starting PyTorch recurrent retrain: $python $($trainerArgs -join ' ')" if ($ResumeCandidate) {
& $python @trainerArgs 2>&1 | Tee-Object -FilePath $LogFile -Append if (-not (Test-TorchArtifactFile $CandidateFile)) {
$trainerExitCode = $LASTEXITCODE throw "ResumeCandidate requested, but no valid candidate artifact exists: $CandidateFile"
if ($trainerExitCode -ne 0) {
if (Test-TorchArtifactFile $CandidateFile) {
Write-RetrainLog "WARNING: Trainer exited with code $trainerExitCode after writing a valid candidate artifact; continuing to guard."
}
else {
throw "Trainer failed with exit code $trainerExitCode."
} }
Write-RetrainLog "Resuming guard from existing candidate artifact: $CandidateFile"
}
else {
Write-RetrainLog "Starting PyTorch recurrent retrain: $python $($trainerArgs -join ' ')"
$trainerExitCode = Invoke-LoggedNativeCommand -FilePath $python -ArgumentList $trainerArgs -LogPath $LogFile
if ($trainerExitCode -ne 0) {
if (Test-TorchArtifactFile $CandidateFile) {
Write-RetrainLog "WARNING: Trainer exited with code $trainerExitCode after writing a valid candidate artifact; continuing to guard."
}
else {
throw "Trainer failed with exit code $trainerExitCode."
}
}
Write-RetrainLog "Finished PyTorch recurrent retrain 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 ($OrderbookDb) {
$calibrationBaseArgs += @(
"--orderbook-db", $OrderbookDb,
"--orderbook-min-samples-per-bucket", $OrderbookMinSamplesPerBucket.ToString()
)
}
Write-RetrainLog "Calibrating current artifact for guard." if (Test-Path $ModelFile) {
& $python @($calibrationBaseArgs + @("--artifact", $ModelFile, "--output", $CurrentCalibration)) 2>&1 | Tee-Object -FilePath $LogFile -Append Write-RetrainLog "Calibrating current artifact for guard."
if ($LASTEXITCODE -ne 0) { $currentCalibrationExitCode = Invoke-LoggedNativeCommand `
throw "Current artifact calibration failed with exit code $LASTEXITCODE." -FilePath $python `
-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" ` $GuardTarget = if ($ShadowMode) { $ShadowModelFile } else { $ModelFile }
--current-report $CurrentCalibration ` $guardArgs = @(
--candidate-report $CandidateCalibration ` "-u",
--candidate-artifact $CandidateFile ` "tools\accept_torch_candidate.py",
--target-artifact $ModelFile ` "--current-report", $CurrentCalibration,
--report $GuardReport 2>&1 | Tee-Object -FilePath $LogFile -Append "--candidate-report", $CandidateCalibration,
if ($LASTEXITCODE -eq 2) { "--candidate-artifact", $CandidateFile,
"--target-artifact", $GuardTarget,
"--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") if ($ShadowMode) {
Write-RetrainLog "Updated active threshold calibration: $(Join-Path $RuntimeDir "torch_threshold_calibration.json")" Copy-Item -Force -LiteralPath $CandidateCalibration -Destination $ShadowCalibration
Copy-Item -Force -LiteralPath $GuardReport -Destination $ShadowGuard
Write-RetrainLog "Candidate passed offline gate and was staged for shadow only: $ShadowModelFile"
}
else {
Copy-Item -Force -LiteralPath $CandidateCalibration -Destination (Join-Path $RuntimeDir "torch_threshold_calibration.json")
Write-RetrainLog "Updated active threshold calibration: $(Join-Path $RuntimeDir "torch_threshold_calibration.json")"
}
}
if ($ShadowMode) {
Write-RetrainLog "Candidate accepted by offline guard. Active artifact was not changed: $ModelFile"
}
else {
Write-RetrainLog "Candidate accepted by guard. Active artifact: $ModelFile"
} }
Write-RetrainLog "Candidate accepted by guard. Active artifact: $ModelFile"
Sync-AcceptedArtifactsToPi
} }
catch { catch {
Write-RetrainLog "ERROR: $($_.Exception.Message)" Write-RetrainLog "ERROR: $($_.Exception.Message)"
+82
View File
@@ -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()
}
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
import argparse
import base64
import json
import os
import sqlite3
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
def sync_orderbook_observations(
*,
api_base_url: str,
token: str,
database_path: str | Path,
timeout: int = 60,
page_limit: int = 5000,
) -> dict[str, Any]:
path = Path(database_path)
path.parent.mkdir(parents=True, exist_ok=True)
_init_schema(path)
manifest = _get_json(
api_base_url,
"/api/training/market-observations/manifest",
token=token,
timeout=timeout,
)
rows = manifest.get("items") if isinstance(manifest.get("items"), list) else []
downloaded = 0
symbol_results: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
symbol = str(row.get("symbol") or "").strip().upper()
remote_max_id = int(row.get("max_id", 0) or 0)
if not symbol or remote_max_id <= 0:
continue
after_id = _local_max_id(path, symbol)
symbol_downloaded = 0
while after_id < remote_max_id:
query = urlencode(
{
"symbol": symbol,
"after_id": after_id,
"limit": max(1, min(5000, int(page_limit))),
}
)
payload = _get_json(
api_base_url,
f"/api/training/market-observations?{query}",
token=token,
timeout=timeout,
)
items = payload.get("items") if isinstance(payload.get("items"), list) else []
if not items:
break
inserted = _insert_rows(path, items)
symbol_downloaded += inserted
downloaded += inserted
next_after_id = int(payload.get("next_after_id", after_id) or after_id)
if next_after_id <= after_id:
break
after_id = next_after_id
symbol_results.append(
{
"symbol": symbol,
"downloaded": symbol_downloaded,
"local_max_id": _local_max_id(path, symbol),
"remote_max_id": remote_max_id,
}
)
return {
"database_path": str(path.resolve()),
"downloaded": downloaded,
"symbols": symbol_results,
"local_samples": _local_count(path),
}
def _init_schema(path: Path) -> None:
with sqlite3.connect(path) as connection:
connection.executescript(
"""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS market_observations (
id INTEGER PRIMARY KEY,
symbol TEXT NOT NULL,
bid_price REAL NOT NULL,
bid_size REAL NOT NULL,
ask_price REAL NOT NULL,
ask_size REAL NOT NULL,
mid_price REAL NOT NULL,
microprice REAL NOT NULL,
spread_bps REAL NOT NULL,
imbalance REAL NOT NULL,
last_price REAL NOT NULL,
source_timestamp_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_local_market_observations_symbol_id
ON market_observations(symbol, id);
"""
)
def _insert_rows(path: Path, rows: list[Any]) -> int:
values = []
for row in rows:
if not isinstance(row, dict):
continue
values.append(
(
int(row.get("id", 0) or 0),
str(row.get("symbol") or "").upper(),
float(row.get("bid_price", 0.0) or 0.0),
float(row.get("bid_size", 0.0) or 0.0),
float(row.get("ask_price", 0.0) or 0.0),
float(row.get("ask_size", 0.0) or 0.0),
float(row.get("mid_price", 0.0) or 0.0),
float(row.get("microprice", 0.0) or 0.0),
float(row.get("spread_bps", 0.0) or 0.0),
float(row.get("imbalance", 0.0) or 0.0),
float(row.get("last_price", 0.0) or 0.0),
int(row.get("source_timestamp_ms", 0) or 0),
str(row.get("created_at") or ""),
)
)
if not values:
return 0
with sqlite3.connect(path) as connection:
before = connection.total_changes
connection.executemany(
"""
INSERT OR IGNORE INTO market_observations (
id, symbol, bid_price, bid_size, ask_price, ask_size,
mid_price, microprice, spread_bps, imbalance, last_price,
source_timestamp_ms, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
values,
)
return connection.total_changes - before
def _local_max_id(path: Path, symbol: str) -> int:
with sqlite3.connect(path) as connection:
row = connection.execute(
"SELECT MAX(id) FROM market_observations WHERE symbol = ?",
(symbol,),
).fetchone()
return int(row[0] or 0) if row else 0
def _local_count(path: Path) -> int:
with sqlite3.connect(path) as connection:
row = connection.execute("SELECT COUNT(*) FROM market_observations").fetchone()
return int(row[0] or 0) if row else 0
def _get_json(api_base_url: str, path: str, *, token: str, timeout: int) -> dict[str, Any]:
headers = {"Accept": "application/json"}
headers.update(_auth_headers(token))
request = Request(api_base_url.rstrip("/") + path, headers=headers, method="GET")
try:
with urlopen(request, timeout=timeout) as response:
text = response.read().decode("utf-8")
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} {path}: {detail[:300]}") from exc
except URLError as exc:
raise RuntimeError(f"network error {path}: {exc.reason}") from exc
data = json.loads(text) if text.strip() else {}
return data if isinstance(data, dict) else {}
def _auth_headers(token: str) -> dict[str, str]:
value = token.strip()
if not value:
return {}
headers = {"X-TradeBot-Token": value}
if value.lower().startswith(("basic ", "bearer ")):
headers["Authorization"] = value
elif ":" in value:
encoded = base64.b64encode(value.encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {encoded}"
else:
headers["Authorization"] = f"Bearer {value}"
return headers
def main() -> None:
parser = argparse.ArgumentParser(description="Synchronize TradeBot L1 observations to a local SQLite cache.")
parser.add_argument("--api-base-url", default=os.environ.get("TRADEBOT_API_BASE_URL", "https://tb.kusoft.xyz"))
parser.add_argument("--api-auth", default=os.environ.get("TRADEBOT_API_AUTH", ""))
parser.add_argument("--database", default="runtime/orderbook_observations.sqlite3")
args = parser.parse_args()
result = sync_orderbook_observations(
api_base_url=args.api_base_url,
token=args.api_auth,
database_path=args.database,
)
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
-95
View File
@@ -1,95 +0,0 @@
[CmdletBinding()]
param(
[string]$RepoRoot = "",
[string]$RemoteHost = "",
[string]$RemoteUser = "",
[string]$RemoteRoot = "",
[string]$SshKeyPath = "",
[string]$ServiceName = "tradebot",
[switch]$NoRestart,
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
if (-not $RepoRoot) { $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path }
if (-not $RemoteHost -and $env:TORCH_DEPLOY_PI_HOST) { $RemoteHost = $env:TORCH_DEPLOY_PI_HOST }
if (-not $RemoteUser -and $env:TORCH_DEPLOY_PI_USER) { $RemoteUser = $env:TORCH_DEPLOY_PI_USER }
if (-not $RemoteRoot -and $env:TORCH_DEPLOY_PI_ROOT) { $RemoteRoot = $env:TORCH_DEPLOY_PI_ROOT }
if (-not $SshKeyPath -and $env:TORCH_DEPLOY_PI_SSH_KEY) { $SshKeyPath = $env:TORCH_DEPLOY_PI_SSH_KEY }
if (-not $RemoteHost) { $RemoteHost = "192.168.0.185" }
if (-not $RemoteUser) { $RemoteUser = "sevenhill" }
if (-not $RemoteRoot) { $RemoteRoot = "/mnt/data/tradebot" }
$RuntimeDir = Join-Path $RepoRoot "runtime"
$artifactNames = @(
"lstm_forecaster.json",
"torch_retrain_guard.json",
"torch_threshold_calibration.json"
)
$localFiles = @()
foreach ($name in $artifactNames) {
$path = Join-Path $RuntimeDir $name
if (Test-Path $path) {
$localFiles += (Resolve-Path $path).Path
}
}
if ($localFiles.Count -eq 0) {
throw "No Torch artifacts found in $RuntimeDir."
}
function ConvertTo-RemoteSingleQuoted {
param([string]$Value)
return "'" + ($Value -replace "'", "'\''") + "'"
}
function Invoke-LoggedCommand {
param(
[string]$Exe,
[string[]]$Arguments
)
$rendered = @($Exe) + $Arguments
Write-Host ($rendered -join " ")
if ($DryRun) {
return
}
& $Exe @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Exe failed with exit code $LASTEXITCODE."
}
}
$ssh = (Get-Command "ssh.exe" -ErrorAction SilentlyContinue)
if (-not $ssh) { $ssh = Get-Command "ssh" -ErrorAction Stop }
$scp = (Get-Command "scp.exe" -ErrorAction SilentlyContinue)
if (-not $scp) { $scp = Get-Command "scp" -ErrorAction Stop }
$commonSshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=15")
if ($SshKeyPath) {
$expandedKey = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($SshKeyPath)
$commonSshArgs += @("-i", $expandedKey)
}
$remote = "${RemoteUser}@${RemoteHost}"
$remoteRuntime = "$RemoteRoot/runtime"
$remoteIncoming = "$remoteRuntime/.incoming-torch"
$mkdirCommand = "mkdir -p $(ConvertTo-RemoteSingleQuoted $remoteIncoming) $(ConvertTo-RemoteSingleQuoted $remoteRuntime)"
Invoke-LoggedCommand $ssh.Source (@($commonSshArgs + @($remote, $mkdirCommand)))
$destination = "${remote}:$remoteIncoming/"
Invoke-LoggedCommand $scp.Source (@($commonSshArgs + $localFiles + @($destination)))
$moveParts = @()
foreach ($path in $localFiles) {
$name = Split-Path $path -Leaf
$moveParts += "mv -f $(ConvertTo-RemoteSingleQuoted "$remoteIncoming/$name") $(ConvertTo-RemoteSingleQuoted "$remoteRuntime/$name")"
}
$moveCommand = $moveParts -join " && "
Invoke-LoggedCommand $ssh.Source (@($commonSshArgs + @($remote, $moveCommand)))
if (-not $NoRestart) {
$restartCommand = "cd $(ConvertTo-RemoteSingleQuoted $RemoteRoot) && docker compose restart $(ConvertTo-RemoteSingleQuoted $ServiceName)"
Invoke-LoggedCommand $ssh.Source (@($commonSshArgs + @($remote, $restartCommand)))
}
Write-Host "Synced Torch artifacts to ${remote}:$remoteRuntime"
File diff suppressed because it is too large Load Diff
+359 -19
View File
@@ -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
@@ -19,12 +20,25 @@ from urllib.error import URLError
from urllib.request import Request from urllib.request import Request
from urllib.request import urlopen from urllib.request import urlopen
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.orderbook_features import load_orderbook_feature_map
from tools.sync_orderbook_observations import sync_orderbook_observations
ARTIFACT_NAMES = ( ARTIFACT_NAMES = (
"lstm_forecaster.json", "lstm_forecaster.json",
"torch_retrain_guard.json", "torch_retrain_guard.json",
"torch_threshold_calibration.json", "torch_threshold_calibration.json",
) )
SHADOW_ARTIFACT_NAMES = (
"lstm_forecaster.shadow.json",
"torch_shadow_guard.json",
"torch_shadow_calibration.json",
)
_LAST_ORDERBOOK_AUTO_CHECK = 0.0
def main() -> None: def main() -> None:
@@ -50,36 +64,103 @@ def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_
api_json(args, "/api/training/heartbeat", worker) api_json(args, "/api/training/heartbeat", worker)
claim = api_json(args, "/api/training/claim", worker) claim = api_json(args, "/api/training/claim", worker)
if not claim.get("claimed"): if not claim.get("claimed"):
maybe_auto_queue_orderbook(args, repo_root, runtime_dir, log_path)
return return
job = claim.get("job") if isinstance(claim.get("job"), dict) else {} job = claim.get("job") if isinstance(claim.get("job"), dict) else {}
job_id = str(job.get("id") or "") job_id = str(job.get("id") or "")
lease_token = str(claim.get("lease_token") or "")
if not job_id: if not job_id:
return return
if not lease_token:
raise RuntimeError("training server did not issue a job lease")
log(log_path, f"Claimed retrain job {job_id}") log(log_path, f"Claimed retrain job {job_id}")
report_progress(args, job_id, "running", "claimed", 2, "Задание получено Windows-agent") report_progress(args, job_id, lease_token, "running", "claimed", 2, "Задание получено Windows-agent")
success = False success = False
message = "" message = ""
summary: dict[str, Any] = {} summary: dict[str, Any] = {}
try: try:
run_retrain(args, job_id, job, repo_root, log_path) parameters = job.get("parameters") if isinstance(job.get("parameters"), dict) else {}
use_orderbook = parameters.get("use_orderbook", True) is not False
orderbook_status: dict[str, Any] = {}
if use_orderbook:
report_progress(
args,
job_id,
lease_token,
"running",
"orderbook_sync",
4,
"Синхронизирую forward-наблюдения стакана",
)
orderbook_status = prepare_orderbook_data(args, repo_root, parameters, log_path)
if orderbook_status["state"] != "ready":
summary = orderbook_status
message = "forward orderbook coverage is still accumulating"
success = True
log(log_path, f"Job {job_id} remains in collecting state: {orderbook_status}")
return
run_retrain(
args,
job_id,
lease_token,
job,
repo_root,
log_path,
orderbook_db=(repo_root / "runtime" / "orderbook_observations.sqlite3") if use_orderbook else None,
)
summary = read_json(runtime_dir / "torch_retrain_guard.json") summary = read_json(runtime_dir / "torch_retrain_guard.json")
report_progress(args, job_id, "running", "uploading", 72, "Обучение завершено, загружаю артефакты") accepted = summary.get("accepted") is True
for name in ARTIFACT_NAMES: if accepted:
path = runtime_dir / name report_progress(
if path.is_file(): args,
upload_artifact(args, job_id, path, log_path) job_id,
lease_token,
"running",
"uploading",
72,
"Обучение завершено, загружаю артефакты",
)
artifact_names = SHADOW_ARTIFACT_NAMES if use_orderbook else ARTIFACT_NAMES
if use_orderbook:
summary["deployment"] = "shadow"
summary["orderbook"] = orderbook_status
for name in artifact_names:
path = runtime_dir / name
if path.is_file():
upload_artifact(args, job_id, lease_token, path, log_path)
message = (
"training completed; candidate staged in shadow"
if use_orderbook
else "training completed; candidate accepted"
)
log(log_path, f"Completed retrain job {job_id}; {message}")
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}")
finally: finally:
payload = {"success": success, "message": message, "summary": summary} payload = {
"success": success,
"message": message,
"summary": summary,
"lease_token": lease_token,
}
api_json(args, f"/api/training/jobs/{job_id}/complete", payload) api_json(args, f"/api/training/jobs/{job_id}/complete", payload)
def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo_root: Path, log_path: Path) -> None: def run_retrain(
args: argparse.Namespace,
job_id: str,
lease_token: str,
job: dict[str, Any],
repo_root: Path,
log_path: Path,
orderbook_db: Path | None = None,
) -> None:
script = repo_root / "tools" / "run_torch_retrain.ps1" script = repo_root / "tools" / "run_torch_retrain.ps1"
if not script.is_file(): if not script.is_file():
raise RuntimeError(f"retrain script not found: {script}") raise RuntimeError(f"retrain script not found: {script}")
@@ -101,13 +182,46 @@ 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", True) is True:
cmd.append("-Pooled")
if parameters.get("resume_candidate") is True:
cmd.append("-ResumeCandidate")
if orderbook_db is not None:
cmd.extend(["-OrderbookDb", str(orderbook_db)])
for key, ps_arg, default in (
("orderbook_min_samples_per_bucket", "-OrderbookMinSamplesPerBucket", 20),
("orderbook_min_covered_buckets", "-OrderbookMinCoveredBuckets", 240),
("orderbook_min_symbols", "-OrderbookMinSymbols", 2),
):
cmd.extend([ps_arg, str(int(parameters.get(key, default) or default))])
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,
lease_token,
"running",
"training",
8,
"PyTorch retrain запущен",
)
line_count = 0 line_count = 0
output_queue: queue.Queue[str] = queue.Queue() output_queue: queue.Queue[str] = queue.Queue()
@@ -124,6 +238,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 +255,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
@@ -150,7 +265,16 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
report_message = last_message report_message = last_message
if not got_line: if not got_line:
report_message = training_heartbeat_message(now, started_at, last_output_at, last_message) report_message = training_heartbeat_message(now, started_at, last_output_at, last_message)
safe_report_progress(args, job_id, "running", "training", progress, report_message, log_path) safe_report_progress(
args,
job_id,
lease_token,
"running",
"training",
progress,
report_message,
log_path,
)
last_report_at = now last_report_at = now
if process.poll() is not None and output_queue.empty(): if process.poll() is not None and output_queue.empty():
@@ -160,7 +284,185 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
code = process.wait() code = process.wait()
if code != 0: if code != 0:
raise RuntimeError(f"retrain failed with exit code {code}") raise RuntimeError(f"retrain failed with exit code {code}")
report_progress(args, job_id, "running", "guard", 70, "Guard завершён, подготавливаю артефакты") report_progress(
args,
job_id,
lease_token,
"running",
"guard",
70,
"Guard завершён, подготавливаю артефакты",
)
def prepare_orderbook_data(
args: argparse.Namespace,
repo_root: Path,
parameters: dict[str, Any],
log_path: Path,
) -> dict[str, Any]:
database_path = repo_root / "runtime" / "orderbook_observations.sqlite3"
token = args.api_auth or os.environ.get("TRADEBOT_API_AUTH", "")
sync_result = sync_orderbook_observations(
api_base_url=args.api_base_url,
token=token,
database_path=database_path,
)
interval = str(parameters.get("interval") or os.environ.get("TORCH_RETRAIN_INTERVAL") or "60")
minimum_samples = int(parameters.get("orderbook_min_samples_per_bucket", 20) or 20)
minimum_buckets = int(parameters.get("orderbook_min_covered_buckets", 240) or 240)
minimum_symbols = int(parameters.get("orderbook_min_symbols", 2) or 2)
requested_symbols = {
item.strip().upper()
for item in str(parameters.get("symbols") or "").split(",")
if item.strip()
}
_features, manifest = load_orderbook_feature_map(
database_path,
interval=interval,
symbols=sorted(requested_symbols) if requested_symbols else None,
min_samples_per_bucket=minimum_samples,
)
eligible = sorted(
symbol
for symbol, row in manifest.items()
if int(row.get("covered_buckets", 0) or 0) >= minimum_buckets
)
state = "ready" if len(eligible) >= minimum_symbols else "collecting_orderbook"
coverage = {
symbol: int(row.get("covered_buckets", 0) or 0)
for symbol, row in sorted(manifest.items())
}
result = {
"accepted": False,
"state": state,
"reason": (
"orderbook coverage ready for training"
if state == "ready"
else "forward orderbook coverage is below the configured minimum"
),
"eligible_symbols": eligible,
"eligible_symbol_count": len(eligible),
"minimum_symbols": minimum_symbols,
"minimum_covered_buckets": minimum_buckets,
"minimum_samples_per_bucket": minimum_samples,
"covered_buckets_by_symbol": coverage,
"local_samples": int(sync_result.get("local_samples", 0) or 0),
"downloaded_samples": int(sync_result.get("downloaded", 0) or 0),
}
log(log_path, "Orderbook preparation: " + json.dumps(result, ensure_ascii=False, sort_keys=True))
return result
def maybe_auto_queue_orderbook(
args: argparse.Namespace,
repo_root: Path,
runtime_dir: Path,
log_path: Path,
) -> None:
global _LAST_ORDERBOOK_AUTO_CHECK
try:
interval_seconds = max(
300,
int(os.environ.get("TORCH_ORDERBOOK_AUTO_CHECK_SECONDS", "3600") or 3600),
)
except ValueError:
interval_seconds = 3600
now = time.monotonic()
if _LAST_ORDERBOOK_AUTO_CHECK and now - _LAST_ORDERBOOK_AUTO_CHECK < interval_seconds:
return
_LAST_ORDERBOOK_AUTO_CHECK = now
marker_path = runtime_dir / "orderbook_auto_queue.json"
if marker_path.is_file() or (runtime_dir / "lstm_forecaster.shadow.json").is_file():
return
status = prepare_orderbook_data(args, repo_root, {}, log_path)
if status.get("state") != "ready":
return
response = api_json(args, "/api/training/retrain/auto", {})
if not response.get("queued"):
log(log_path, f"Automatic orderbook retrain was not queued: {response.get('reason', 'unknown')}")
return
marker = {
"queued_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"job_id": (response.get("job") or {}).get("id"),
"coverage": status,
}
marker_tmp = marker_path.with_suffix(".tmp")
marker_tmp.write_text(json.dumps(marker, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
marker_tmp.replace(marker_path)
log(log_path, f"Automatically queued orderbook retrain job {marker['job_id']}")
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:
@@ -185,7 +487,13 @@ def format_duration(seconds: float) -> str:
return f"{seconds_part}с" return f"{seconds_part}с"
def upload_artifact(args: argparse.Namespace, job_id: str, path: Path, log_path: Path) -> None: def upload_artifact(
args: argparse.Namespace,
job_id: str,
lease_token: str,
path: Path,
log_path: Path,
) -> None:
digest = hashlib.sha256(path.read_bytes()).hexdigest() digest = hashlib.sha256(path.read_bytes()).hexdigest()
size = path.stat().st_size size = path.stat().st_size
chunk_size = max(64 * 1024, args.chunk_size) chunk_size = max(64 * 1024, args.chunk_size)
@@ -200,16 +508,26 @@ def upload_artifact(args: argparse.Namespace, job_id: str, path: Path, log_path:
"total": total, "total": total,
"sha256": digest, "sha256": digest,
"data_base64": base64.b64encode(data).decode("ascii"), "data_base64": base64.b64encode(data).decode("ascii"),
"lease_token": lease_token,
} }
api_json(args, f"/api/training/jobs/{job_id}/artifacts/chunk", payload, timeout=120) api_json(args, f"/api/training/jobs/{job_id}/artifacts/chunk", payload, timeout=120)
if index == 0 or index == total - 1 or index % 10 == 0: if index == 0 or index == total - 1 or index % 10 == 0:
progress = 72 + int(((index + 1) / total) * 23) progress = 72 + int(((index + 1) / total) * 23)
report_progress(args, job_id, "running", "uploading", progress, f"Загружаю {path.name}: {index + 1}/{total}") report_progress(
args,
job_id,
lease_token,
"running",
"uploading",
progress,
f"Загружаю {path.name}: {index + 1}/{total}",
)
def report_progress( def report_progress(
args: argparse.Namespace, args: argparse.Namespace,
job_id: str, job_id: str,
lease_token: str,
status: str, status: str,
phase: str, phase: str,
progress_percent: int, progress_percent: int,
@@ -224,6 +542,7 @@ def report_progress(
"progress_percent": progress_percent, "progress_percent": progress_percent,
"message": message, "message": message,
"worker": worker_payload(args, Path(args.repo_root).resolve()), "worker": worker_payload(args, Path(args.repo_root).resolve()),
"lease_token": lease_token,
}, },
) )
@@ -231,6 +550,7 @@ def report_progress(
def safe_report_progress( def safe_report_progress(
args: argparse.Namespace, args: argparse.Namespace,
job_id: str, job_id: str,
lease_token: str,
status: str, status: str,
phase: str, phase: str,
progress_percent: int, progress_percent: int,
@@ -240,7 +560,15 @@ def safe_report_progress(
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(1, 4): for attempt in range(1, 4):
try: try:
report_progress(args, job_id, status, phase, progress_percent, message) report_progress(
args,
job_id,
lease_token,
status,
phase,
progress_percent,
message,
)
return return
except Exception as exc: # noqa: BLE001 - keep the local training process alive. except Exception as exc: # noqa: BLE001 - keep the local training process alive.
last_error = exc last_error = exc
@@ -288,7 +616,7 @@ def worker_payload(args: argparse.Namespace, repo_root: Path) -> dict[str, Any]:
"worker_id": args.worker_id or f"{name}:{repo_root}", "worker_id": args.worker_id or f"{name}:{repo_root}",
"name": name, "name": name,
"path": str(repo_root), "path": str(repo_root),
"version": "1", "version": "3",
} }
@@ -313,6 +641,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