Compare commits
27
Commits
f0c70e2e1f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ce92b6428 | ||
|
|
393454c9e0 | ||
|
|
0ec64645b6 | ||
|
|
be5c9d482a | ||
|
|
991b77351c | ||
|
|
5082be2e5a | ||
|
|
0992da0ece | ||
|
|
5d8ad1437e | ||
|
|
f7a625586e | ||
|
|
2967cd607c | ||
|
|
d0869b5d29 | ||
|
|
1f2fb011a7 | ||
|
|
e1a42a9011 | ||
|
|
51a7833896 | ||
|
|
1c7701c38e | ||
|
|
4c347ed425 | ||
|
|
5c4aecfe5f | ||
|
|
7186acb9a1 | ||
|
|
668e606ee2 | ||
|
|
da53483164 | ||
|
|
18936cf8b1 | ||
|
|
069d75d2f2 | ||
|
|
6fb79ee2a9 | ||
|
|
77a063d263 | ||
|
|
e21a96640d | ||
|
|
7f1ac694e4 | ||
|
|
33c3831bf2 |
@@ -1,93 +0,0 @@
|
||||
TRADING_MODE=paper
|
||||
HOST=127.0.0.1
|
||||
PORT=8787
|
||||
|
||||
BYBIT_TESTNET=false
|
||||
BYBIT_API_KEY=
|
||||
BYBIT_API_SECRET=
|
||||
|
||||
STARTING_BALANCE_USDT=100
|
||||
AUTO_SELECT_SYMBOLS=false
|
||||
TOP_SYMBOLS_COUNT=12
|
||||
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
|
||||
|
||||
STRATEGY_MODE=torch_forecast
|
||||
BASE_INTERVAL=60
|
||||
KLINE_LIMIT=240
|
||||
TREND_INTERVAL=D
|
||||
TREND_KLINE_LIMIT=260
|
||||
LOOP_INTERVAL_SECONDS=5
|
||||
FAST_TRADING_ENABLED=false
|
||||
FAST_LOOP_INTERVAL_SECONDS=1
|
||||
FAST_ENTRY_COOLDOWN_SECONDS=20
|
||||
MAX_ENTRIES_PER_MINUTE=12
|
||||
WEBSOCKET_ENABLED=true
|
||||
MIN_SIGNAL_CONFIDENCE=0.64
|
||||
MAX_SPREAD_PERCENT=0.18
|
||||
MIN_24H_TURNOVER_USDT=1000000
|
||||
PATTERN_ANALYSIS_ENABLED=true
|
||||
PATTERN_SCORE_WEIGHT=0.18
|
||||
LEARNING_ENABLED=true
|
||||
LEARNING_LOOKBACK_TRADES=120
|
||||
LEARNING_MIN_SAMPLES=3
|
||||
LEARNING_MAX_ADJUSTMENT=0.12
|
||||
LEARNING_MAX_POSITION_MULTIPLIER=1.6
|
||||
MIN_POSITION_USDT=1
|
||||
MAX_POSITION_USDT=8
|
||||
MAX_SYMBOL_EXPOSURE_USDT=25
|
||||
MAX_TOTAL_EXPOSURE_USDT=100
|
||||
MAX_OPEN_POSITIONS=24
|
||||
MAX_POSITIONS_PER_SYMBOL=6
|
||||
GRID_TRADING_ENABLED=false
|
||||
GRID_ENTRY_CONFIDENCE=0.58
|
||||
GRID_BUY_ZONE=0.45
|
||||
GRID_MAX_POSITION_USDT=8
|
||||
REBOUND_TRADING_ENABLED=true
|
||||
REBOUND_ENTRY_CONFIDENCE=0.55
|
||||
REBOUND_MIN_PROBABILITY=0.55
|
||||
REBOUND_MAX_POSITION_USDT=6
|
||||
KELLY_SIZING_ENABLED=true
|
||||
KELLY_FRACTION=0.25
|
||||
KELLY_MAX_FRACTION=0.20
|
||||
RISK_PER_TRADE_PERCENT=0.01
|
||||
RISK_GUARD_ENABLED=true
|
||||
RISK_SYMBOL_GUARD_ENABLED=false
|
||||
RISK_RECENT_TRADE_WINDOW=20
|
||||
RISK_MAX_CONSECUTIVE_LOSSES=4
|
||||
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
|
||||
RISK_REDUCE_MULTIPLIER=1.0
|
||||
ATR_TRAILING_MULTIPLIER=2.2
|
||||
TREND_RSI_MIN=45
|
||||
TREND_RSI_MAX=65
|
||||
TIME_SERIES_FORECAST_ENABLED=true
|
||||
TIME_SERIES_MIN_CANDLES=120
|
||||
TIME_SERIES_FORECAST_HORIZON=3
|
||||
TIME_SERIES_MIN_EDGE_PERCENT=0.10
|
||||
TIME_SERIES_MIN_PROBABILITY_UP=0.47
|
||||
TIME_SERIES_MIN_CONFIDENCE=0.4
|
||||
TIME_SERIES_MAX_ADJUSTMENT=0.08
|
||||
TIME_SERIES_LSTM_ENABLED=true
|
||||
TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json
|
||||
TIME_SERIES_PROBE_ENABLED=true
|
||||
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
|
||||
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
||||
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
|
||||
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
|
||||
STOP_LOSS_PERCENT=0.04
|
||||
STOP_LOSS_EXIT_ENABLED=false
|
||||
TAKE_PROFIT_PERCENT=0.035
|
||||
TRAILING_STOP_PERCENT=0.015
|
||||
MIN_HOLD_SECONDS=180
|
||||
ENTRY_COOLDOWN_SECONDS=180
|
||||
MAX_DAILY_DRAWDOWN_USDT=6
|
||||
MIN_CASH_RESERVE_USDT=5
|
||||
TAKER_FEE_RATE=0.001
|
||||
SLIPPAGE_RATE=0.0003
|
||||
|
||||
# Real trading is locked unless all three values are set explicitly.
|
||||
ENABLE_LIVE_TRADING=false
|
||||
LIVE_TRADING_CONFIRM=
|
||||
LIVE_ORDER_MAX_USDT=10
|
||||
|
||||
DATABASE_PATH=runtime/tradebot.sqlite3
|
||||
LOG_PATH=runtime/tradebot.log
|
||||
+58
-3
@@ -1,15 +1,23 @@
|
||||
TRADING_MODE=paper
|
||||
HOST=127.0.0.1
|
||||
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
|
||||
# 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_SECRET=
|
||||
|
||||
STARTING_BALANCE_USDT=100
|
||||
AUTO_SELECT_SYMBOLS=false
|
||||
AUTO_SELECT_SYMBOLS=true
|
||||
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
|
||||
BASE_INTERVAL=60
|
||||
@@ -22,6 +30,8 @@ FAST_LOOP_INTERVAL_SECONDS=1
|
||||
FAST_ENTRY_COOLDOWN_SECONDS=20
|
||||
MAX_ENTRIES_PER_MINUTE=12
|
||||
WEBSOCKET_ENABLED=true
|
||||
MARKET_OBSERVATION_ENABLED=true
|
||||
MARKET_OBSERVATION_SAMPLE_SECONDS=30
|
||||
MIN_SIGNAL_CONFIDENCE=0.64
|
||||
MAX_SPREAD_PERCENT=0.18
|
||||
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_PROBABILITY_UP=0.55
|
||||
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_EXIT_ENABLED=false
|
||||
TAKE_PROFIT_PERCENT=0.035
|
||||
TRAILING_STOP_PERCENT=0.015
|
||||
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
|
||||
MAX_DAILY_DRAWDOWN_USDT=6
|
||||
MIN_CASH_RESERVE_USDT=5
|
||||
@@ -86,6 +111,36 @@ SLIPPAGE_RATE=0.0003
|
||||
ENABLE_LIVE_TRADING=false
|
||||
LIVE_TRADING_CONFIRM=
|
||||
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
|
||||
LOG_PATH=runtime/tradebot.log
|
||||
|
||||
+12
-5
@@ -1,13 +1,20 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir -r /app/requirements.txt
|
||||
RUN pip install --no-cache-dir --disable-pip-version-check --upgrade pip \
|
||||
&& 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 README.md /app/README.md
|
||||
RUN mkdir -p /app/runtime
|
||||
COPY --chown=1000:1000 crypto_spot_bot /app/crypto_spot_bot
|
||||
COPY --chown=1000:1000 README.md /app/README.md
|
||||
RUN mkdir -p /app/runtime && chown -R 1000:1000 /app/runtime
|
||||
|
||||
EXPOSE 8787
|
||||
USER 1000:1000
|
||||
STOPSIGNAL SIGTERM
|
||||
CMD ["python", "-m", "crypto_spot_bot.main"]
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
# Crypto Spot TradeBot
|
||||
|
||||
Веб-панель управления доступна на корневом адресе сервиса: локально
|
||||
`http://127.0.0.1:8787/`, в production — `https://tb.kusoft.xyz/`. Панель показывает
|
||||
готовность торгового контура, капитал, позиции, рынки, сигналы, сделки, события и
|
||||
состояние модели; из неё можно запускать и останавливать цикл и переключать быстрый
|
||||
режим. Приватные данные и управляющие действия используют ту же авторизацию, что и API.
|
||||
|
||||
Spot-бот для демо-торговли криптовалютой на реальных данных Bybit. По умолчанию работает только в `paper`-режиме со стартовым балансом `100 USDT`; live-режим заблокирован до явного включения через env-переменные.
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- Реальные 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.
|
||||
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
|
||||
- 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`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры.
|
||||
- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`.
|
||||
@@ -17,12 +25,14 @@ Spot-бот для демо-торговли криптовалютой на р
|
||||
- DCA/мартингейл отключены: в режиме `trend_macd` брокер не разрешает вторую позицию по той же паре.
|
||||
- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`.
|
||||
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
|
||||
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
|
||||
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для просмотра 12 пар, свечей, Torch/Kelly параметров, расписания удалённого retrain и live-чеклиста.
|
||||
- Защищённый JSON API: equity, cash, PnL, позиции, сделки, сигналы, события, свечи, управление paper-циклом и состоянием обучения.
|
||||
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для динамического списка Bybit-пар, свечей, Torch/Kelly параметров, WorkManager-расписания удалённого retrain и live-чеклиста.
|
||||
- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`.
|
||||
- Health endpoint `/api/health` и Prometheus-compatible `/metrics`.
|
||||
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост.
|
||||
- Liveness `/api/health`, readiness `/api/ready`, объединенный mobile snapshot `/api/mobile/snapshot` и Prometheus-compatible `/metrics`.
|
||||
- Все приватные 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-ключей.
|
||||
- Внешний 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>.
|
||||
|
||||
Для текущего 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>.
|
||||
|
||||
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 отдельно указывает, что 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/>.
|
||||
- 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
|
||||
```
|
||||
|
||||
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
|
||||
.\.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 `
|
||||
--limit 3000 `
|
||||
--architectures lstm,gru `
|
||||
--architectures lstm `
|
||||
--lookbacks 64 `
|
||||
--hidden-sizes 64,96 `
|
||||
--layers 2 `
|
||||
@@ -78,34 +90,33 @@ Dashboard: <http://127.0.0.1:8787/>
|
||||
--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 прогнозы удалены.
|
||||
|
||||
Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
|
||||
Локальный retrain на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
|
||||
|
||||
```powershell
|
||||
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-машина сама подключается к интернету, забирает задания, обучает модель и загружает артефакты обратно:
|
||||
|
||||
```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
|
||||
powershell -ExecutionPolicy Bypass -File tools\sync_torch_artifacts_to_pi.ps1 -RemoteHost 192.168.0.185 -RemoteUser sevenhill -RemoteRoot /mnt/data/tradebot
|
||||
```
|
||||
Основной 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` остаётся только в финальном отчёте и никогда не участвует в фильтрации входов или подборе порогов.
|
||||
|
||||
Внутри 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
|
||||
|
||||
@@ -115,18 +126,21 @@ docker compose up -d --build
|
||||
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
|
||||
TRADING_MODE=paper
|
||||
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
|
||||
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
|
||||
SYMBOLS=
|
||||
STRATEGY_MODE=torch_forecast
|
||||
BASE_INTERVAL=60
|
||||
TREND_INTERVAL=D
|
||||
@@ -137,6 +151,8 @@ FAST_LOOP_INTERVAL_SECONDS=1
|
||||
FAST_ENTRY_COOLDOWN_SECONDS=20
|
||||
MAX_ENTRIES_PER_MINUTE=12
|
||||
WEBSOCKET_ENABLED=true
|
||||
MARKET_OBSERVATION_ENABLED=true
|
||||
MARKET_OBSERVATION_SAMPLE_SECONDS=30
|
||||
MIN_SIGNAL_CONFIDENCE=0.64
|
||||
PATTERN_ANALYSIS_ENABLED=true
|
||||
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_PROBABILITY_UP=0.55
|
||||
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_EXIT_ENABLED=false
|
||||
TAKE_PROFIT_PERCENT=0.035
|
||||
TRAILING_STOP_PERCENT=0.015
|
||||
MIN_HOLD_SECONDS=180
|
||||
PROFIT_ONLY_EXIT_ENABLED=true
|
||||
MIN_EXIT_NET_PERCENT=0.31
|
||||
ENTRY_COOLDOWN_SECONDS=180
|
||||
MAX_DAILY_DRAWDOWN_USDT=6
|
||||
TAKER_FEE_RATE=0.001
|
||||
@@ -202,6 +227,12 @@ SLIPPAGE_RATE=0.0003
|
||||
|
||||
Для быстрого режима рекомендуется оставлять `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-режим специально заблокирован. Для включения нужны все значения:
|
||||
@@ -213,15 +244,29 @@ LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK
|
||||
BYBIT_API_KEY=...
|
||||
BYBIT_API_SECRET=...
|
||||
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
|
||||
|
||||
- `GET /api/health` — healthcheck.
|
||||
- `GET /api/status` — статус бота, account snapshot, позиции.
|
||||
- `GET /api/dashboard/snapshot` — компактный защищённый snapshot для веб-панели; внешний UI использует эквивалентный `/web-api/dashboard/snapshot`, чтобы не смешивать Basic-auth браузера с токеном мобильного API.
|
||||
- `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/signals` — последние сигналы стратегии.
|
||||
- `GET /api/events` — события.
|
||||
@@ -234,5 +279,6 @@ LIVE_ORDER_MAX_USDT=10
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements-dev.txt
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
- Русский интерфейс без bubble/pill-оформления.
|
||||
- Современная биржевая компоновка: список пар, один выбранный график, компактные параметры ниже.
|
||||
- Свечной график 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.
|
||||
- Обзор equity/cash/exposure/PnL и последних решений.
|
||||
- Удалённый запуск retrain через очередь заданий на боте и закреплённый Windows-компьютер обучения.
|
||||
- Расписание retrain на телефоне: Android отправляет команду по расписанию, но обучение идёт на Windows-машине.
|
||||
- Расписание retrain через Android WorkManager: команда отправляется только при наличии сети, а обучение идёт на Windows-машине.
|
||||
- Настройки API, токена команд, тёмной/светлой темы.
|
||||
- Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой.
|
||||
|
||||
@@ -38,15 +38,11 @@ https://tb.kusoft.xyz
|
||||
|
||||
Этот адрес установлен в приложении по умолчанию. Если в настройках ввести просто `tb.kusoft.xyz`, приложение само добавит `https://`.
|
||||
|
||||
Если домен защищён авторизацией, в поле `API auth` можно указать:
|
||||
|
||||
- `login:password` — приложение отправит HTTP Basic;
|
||||
- `Basic ...` — готовый Basic header;
|
||||
- `Bearer ...` или просто токен — приложение отправит Bearer.
|
||||
В поле `API-токен` указывается отдельный токен Android-клиента (`TRADEBOT_API_TOKEN` на сервере). Логин и пароль reverse proxy приложению не нужны. Токен отправляется как Bearer/X-TradeBot-Token и хранится зашифрованным ключом Android Keystore.
|
||||
|
||||
## Переобучение
|
||||
|
||||
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `DESKTOP-TMFDL0H` сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети.
|
||||
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на этой машине сам выходит в интернет, забирает задание, обучает модель и отправляет проверенный bundle обратно боту. Имя и путь активного worker приложение получает от сервера, без прошитого имени компьютера.
|
||||
|
||||
## Live-торговля
|
||||
|
||||
|
||||
@@ -4,13 +4,17 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "xyz.kusoft.tradebotmonitor"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "xyz.kusoft.tradebotmonitor"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 12
|
||||
versionName = "0.2.9"
|
||||
targetSdk = 37
|
||||
versionCode = 24
|
||||
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">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="TradeBot AI"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@drawable/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -22,16 +23,5 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".RetrainAlarmReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package xyz.kusoft.tradebotmonitor
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -20,8 +30,23 @@ class AppPrefs(context: Context) {
|
||||
set(value) = prefs.edit().putString("api_base_url", normalizeBaseUrl(value)).apply()
|
||||
|
||||
var commandToken: String
|
||||
get() = prefs.getString("command_token", "") ?: ""
|
||||
set(value) = prefs.edit().putString("command_token", value.trim()).apply()
|
||||
get() {
|
||||
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
|
||||
get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT"
|
||||
@@ -59,17 +84,62 @@ class AppPrefs(context: Context) {
|
||||
private fun normalizeBaseUrl(value: String): String {
|
||||
val trimmed = value.trim().trimEnd('/')
|
||||
if (trimmed.isBlank()) return DEFAULT_API_BASE_URL
|
||||
return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||
trimmed
|
||||
} else {
|
||||
"https://$trimmed"
|
||||
return when {
|
||||
trimmed.startsWith("https://") -> trimmed
|
||||
trimmed.startsWith("http://") -> "https://${trimmed.removePrefix("http://")}"
|
||||
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 {
|
||||
const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz"
|
||||
const val LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787"
|
||||
const val DEFAULT_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
|
||||
const val DEFAULT_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
|
||||
const val DEFAULT_TRAINING_COMPUTER_NAME = "Ожидание Windows-agent"
|
||||
const val DEFAULT_TRAINING_COMPUTER_PATH = "Имя и путь поступят от сервера"
|
||||
const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1"
|
||||
}
|
||||
}
|
||||
|
||||
+681
-106
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,8 @@ data class ForecastData(
|
||||
val model: String,
|
||||
val expectedReturnPercent: Double,
|
||||
val probabilityUp: Double,
|
||||
val probabilityTakeProfitFirst: Double?,
|
||||
val targetTransform: String,
|
||||
val skill: Double,
|
||||
val volatilityPercent: Double,
|
||||
val horizon: Int,
|
||||
@@ -73,10 +75,17 @@ data class SignalData(
|
||||
?: 0.0
|
||||
|
||||
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")
|
||||
?: 0.0
|
||||
|
||||
val targetTransform: String
|
||||
get() = diagnostics.optString("target_transform").ifBlank {
|
||||
diagnostics.optJSONObject("forecast")?.optString("target_transform").orEmpty()
|
||||
}
|
||||
|
||||
val positionNotionalUsdt: Double
|
||||
get() = diagnostics.optDoubleOrNull("position_notional_usdt")
|
||||
?: diagnostics.optJSONObject("position_sizing")?.optDoubleOrNull("notional_usdt")
|
||||
@@ -84,6 +93,7 @@ data class SignalData(
|
||||
}
|
||||
|
||||
data class PositionData(
|
||||
val id: Long?,
|
||||
val symbol: String,
|
||||
val qty: Double,
|
||||
val entryPrice: Double,
|
||||
@@ -97,6 +107,7 @@ data class PositionData(
|
||||
val highestPrice: Double?,
|
||||
val trailingStop: Double?,
|
||||
val atrTrailingStop: Double?,
|
||||
val openedAt: String,
|
||||
val exitAction: String,
|
||||
val exitReason: String,
|
||||
val stopLossExitEnabled: Boolean,
|
||||
@@ -134,6 +145,8 @@ data class ClosedTradesSummary(
|
||||
data class BotSnapshot(
|
||||
val ok: Boolean,
|
||||
val running: Boolean,
|
||||
val ready: Boolean,
|
||||
val readinessReasons: List<String>,
|
||||
val mode: String,
|
||||
val account: AccountData,
|
||||
val positions: List<PositionData>,
|
||||
@@ -153,5 +166,8 @@ fun JSONObject.optStringClean(name: String): String =
|
||||
fun JSONObject.optDoubleOrNull(name: String): Double? =
|
||||
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? =
|
||||
if (has(name) && !isNull(name)) optBoolean(name) else null
|
||||
|
||||
+37
-45
@@ -1,63 +1,55 @@
|
||||
package xyz.kusoft.tradebotmonitor
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import java.util.concurrent.Executors
|
||||
import androidx.work.Constraints
|
||||
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 {
|
||||
private const val ACTION_RETRAIN = "xyz.kusoft.tradebotmonitor.RETRAIN"
|
||||
private const val REQUEST_CODE = 6406
|
||||
private const val UNIQUE_WORK_NAME = "tradebot-periodic-retrain"
|
||||
|
||||
fun schedule(context: Context, hours: Int) {
|
||||
val interval = hours.coerceAtLeast(1) * 60L * 60L * 1000L
|
||||
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
manager.setInexactRepeating(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
System.currentTimeMillis() + interval,
|
||||
interval,
|
||||
pendingIntent(context),
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
val request = PeriodicWorkRequest.Builder(
|
||||
RetrainWorker::class.java,
|
||||
hours.coerceAtLeast(1).toLong(),
|
||||
TimeUnit.HOURS,
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
manager.cancel(pendingIntent(context))
|
||||
WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
}
|
||||
|
||||
private fun pendingIntent(context: Context): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
REQUEST_CODE,
|
||||
Intent(context, RetrainAlarmReceiver::class.java).setAction(ACTION_RETRAIN),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
class RetrainAlarmReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val pending = goAsync()
|
||||
Executors.newSingleThreadExecutor().execute {
|
||||
try {
|
||||
val prefs = AppPrefs(context)
|
||||
if (prefs.retrainScheduleEnabled) {
|
||||
class RetrainWorker(
|
||||
context: Context,
|
||||
parameters: WorkerParameters,
|
||||
) : Worker(context, parameters) {
|
||||
override fun doWork(): Result {
|
||||
val prefs = AppPrefs(applicationContext)
|
||||
if (!prefs.retrainScheduleEnabled || prefs.commandToken.isBlank()) {
|
||||
return Result.success()
|
||||
}
|
||||
return try {
|
||||
TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
|
||||
}
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
val prefs = AppPrefs(context)
|
||||
if (prefs.retrainScheduleEnabled) {
|
||||
RetrainScheduler.schedule(context, prefs.retrainIntervalHours)
|
||||
Result.success()
|
||||
} catch (_: Exception) {
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-22
@@ -1,6 +1,5 @@
|
||||
package xyz.kusoft.tradebotmonitor
|
||||
|
||||
import android.util.Base64
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
@@ -14,16 +13,19 @@ class TradeBotApi(
|
||||
private val token: String,
|
||||
) {
|
||||
fun fetchSnapshot(): BotSnapshot {
|
||||
val health = getJson("/api/health")
|
||||
val status = getJson("/api/status")
|
||||
val markets = getJson("/api/markets")
|
||||
val signals = getJson("/api/signals?limit=220")
|
||||
val config = getJson("/api/config")
|
||||
val trades = getJson("/api/trades?limit=10")
|
||||
val retrain = getJson("/api/retrain")
|
||||
val backtest = getJson("/api/backtest")
|
||||
val snapshot = getJson("/api/mobile/snapshot")
|
||||
val health = snapshot.optJSONObject("health") ?: JSONObject()
|
||||
val status = snapshot.optJSONObject("status") ?: JSONObject()
|
||||
val markets = snapshot.optJSONObject("markets") ?: JSONObject()
|
||||
val signals = snapshot.optJSONObject("signals") ?: JSONObject()
|
||||
val config = snapshot.optJSONObject("config") ?: JSONObject()
|
||||
val trades = snapshot.optJSONObject("trades") ?: JSONObject()
|
||||
val retrain = snapshot.optJSONObject("retrain") ?: JSONObject()
|
||||
val backtest = snapshot.optJSONObject("backtest") ?: JSONObject()
|
||||
|
||||
val accountJson = status.optJSONObject("account") ?: JSONObject()
|
||||
val readiness = status.optJSONObject("readiness") ?: JSONObject()
|
||||
val readinessReasons = readiness.optJSONArray("reasons") ?: JSONArray()
|
||||
val account = AccountData(
|
||||
equity = accountJson.optDouble("equity", 0.0),
|
||||
cash = accountJson.optDouble("cash", 0.0),
|
||||
@@ -33,6 +35,10 @@ class TradeBotApi(
|
||||
return BotSnapshot(
|
||||
ok = health.optBoolean("ok", 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"),
|
||||
account = account,
|
||||
positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()),
|
||||
@@ -91,7 +97,7 @@ class TradeBotApi(
|
||||
connection.disconnect()
|
||||
if (code !in 200..299) {
|
||||
if (code == HttpURLConnection.HTTP_UNAUTHORIZED) {
|
||||
throw IllegalStateException("HTTP 401: сервер требует логин и пароль")
|
||||
throw IllegalStateException("HTTP 401: API-токен отсутствует или неверен")
|
||||
}
|
||||
throw IllegalStateException("HTTP $code: ${text.take(240)}")
|
||||
}
|
||||
@@ -104,6 +110,7 @@ class TradeBotApi(
|
||||
val row = items.optJSONObject(index) ?: continue
|
||||
val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject()
|
||||
output += PositionData(
|
||||
id = row.optLongOrNull("id"),
|
||||
symbol = row.optStringClean("symbol"),
|
||||
qty = row.optDouble("qty", 0.0),
|
||||
entryPrice = row.optDouble("entry_price", 0.0),
|
||||
@@ -117,6 +124,7 @@ class TradeBotApi(
|
||||
highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"),
|
||||
trailingStop = exitPlan.optDoubleOrNull("trailing_stop"),
|
||||
atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"),
|
||||
openedAt = row.optStringClean("opened_at"),
|
||||
exitAction = exitPlan.optStringClean("action"),
|
||||
exitReason = exitPlan.optStringClean("reason"),
|
||||
stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true,
|
||||
@@ -175,7 +183,7 @@ class TradeBotApi(
|
||||
qualityScore = quality.optDouble("score", 0.0),
|
||||
)
|
||||
}
|
||||
return output.sortedBy { it.symbol }
|
||||
return output
|
||||
}
|
||||
|
||||
private fun parseTicker(row: JSONObject): TickerData =
|
||||
@@ -234,7 +242,10 @@ class TradeBotApi(
|
||||
return ForecastData(
|
||||
model = row.optStringClean("model"),
|
||||
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),
|
||||
volatilityPercent = row.optDouble("volatility_percent", 0.0),
|
||||
horizon = row.optInt("horizon", 0),
|
||||
@@ -273,15 +284,8 @@ class TradeBotApi(
|
||||
private fun applyAuthHeaders(connection: HttpURLConnection, token: String) {
|
||||
val value = token.trim()
|
||||
if (value.isBlank()) return
|
||||
connection.setRequestProperty("X-TradeBot-Token", value)
|
||||
val authorization = when {
|
||||
value.startsWith("Basic ", ignoreCase = true) -> value
|
||||
value.startsWith("Bearer ", ignoreCase = true) -> value
|
||||
":" in value -> {
|
||||
val encoded = Base64.encodeToString(value.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP)
|
||||
"Basic $encoded"
|
||||
}
|
||||
else -> "Bearer $value"
|
||||
}
|
||||
val rawToken = value.removePrefix("Bearer ").removePrefix("bearer ").trim()
|
||||
connection.setRequestProperty("X-TradeBot-Token", rawToken)
|
||||
val authorization = "Bearer $rawToken"
|
||||
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"/>
|
||||
<text x="338" y="48" class="text small">91%</text>
|
||||
<text x="34" y="88" class="text h2">Рынки</text>
|
||||
<text x="34" y="114" class="muted small">12 фиксированных spot-пар</text>
|
||||
<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"/>
|
||||
<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
|
||||
Vendored
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Crypto spot trading bot package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "1.1.2"
|
||||
|
||||
@@ -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]]:
|
||||
symbols = {symbol.upper() for symbol in settings.symbols}
|
||||
if not symbols:
|
||||
return trades
|
||||
return [trade for trade in trades if str(trade.get("symbol", "")).upper() in symbols]
|
||||
return [
|
||||
trade
|
||||
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]]:
|
||||
|
||||
@@ -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 ""
|
||||
+305
-18
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
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.models import BotStatus, Signal, Ticker, utc_now
|
||||
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.time_series import TimeSeriesForecaster
|
||||
from crypto_spot_bot.time_series import TimeSeriesForecaster, _barrier_outcome
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CryptoSpotBot:
|
||||
@@ -26,6 +36,7 @@ class CryptoSpotBot:
|
||||
pattern_analyzer: PatternAnalyzer,
|
||||
learner: TradeLearner,
|
||||
forecaster: TimeSeriesForecaster | None = None,
|
||||
shadow_forecaster: TimeSeriesForecaster | None = None,
|
||||
llm_advisor=None,
|
||||
):
|
||||
self.settings = settings
|
||||
@@ -36,6 +47,7 @@ class CryptoSpotBot:
|
||||
self.pattern_analyzer = pattern_analyzer
|
||||
self.learner = learner
|
||||
self.forecaster = forecaster
|
||||
self.shadow_forecaster = shadow_forecaster
|
||||
self.llm_advisor = llm_advisor
|
||||
self.running = False
|
||||
self.started_at: datetime | None = None
|
||||
@@ -44,6 +56,11 @@ class CryptoSpotBot:
|
||||
self._entry_cooldown_until: dict[str, datetime] = {}
|
||||
self._loop_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:
|
||||
if self.running:
|
||||
@@ -51,6 +68,17 @@ class CryptoSpotBot:
|
||||
self.market.reset_stop()
|
||||
if not self.market.symbols:
|
||||
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._update_patterns()
|
||||
self._update_forecasts()
|
||||
@@ -58,7 +86,10 @@ class CryptoSpotBot:
|
||||
self.running = True
|
||||
self.started_at = utc_now()
|
||||
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:
|
||||
self._ws_task = asyncio.create_task(self.market.websocket_loop())
|
||||
self._loop_task = asyncio.create_task(self._run_loop())
|
||||
@@ -72,7 +103,13 @@ class CryptoSpotBot:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
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:
|
||||
while self.running:
|
||||
@@ -80,6 +117,7 @@ class CryptoSpotBot:
|
||||
rest_refresh_seconds = self._rest_refresh_seconds()
|
||||
if self._needs_rest_refresh(rest_refresh_seconds):
|
||||
await asyncio.to_thread(self.market.refresh_rest)
|
||||
await self._maintain_runtime()
|
||||
self.broker.update_highs(self.market.tickers)
|
||||
self._update_patterns()
|
||||
self._update_forecasts()
|
||||
@@ -88,9 +126,11 @@ class CryptoSpotBot:
|
||||
await self._process_entries()
|
||||
self.broker.mark_equity(self.market.prices())
|
||||
self.last_loop_at = utc_now()
|
||||
self._consecutive_loop_errors = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._consecutive_loop_errors += 1
|
||||
self.message = f"ошибка цикла: {exc}"
|
||||
self.storage.event(self.message, "ERROR")
|
||||
await asyncio.sleep(self.settings.effective_loop_interval_seconds)
|
||||
@@ -110,6 +150,18 @@ class CryptoSpotBot:
|
||||
prices = self.market.prices()
|
||||
reduction_candidate_id = self._reduction_candidate_id(prices)
|
||||
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)
|
||||
candles = self.market.candles.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
|
||||
learning = {"adaptive_rules": adaptive_rules}
|
||||
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:
|
||||
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()
|
||||
|
||||
async def _process_entries(self) -> None:
|
||||
prices = self.market.prices()
|
||||
risk_guard = risk_guard_snapshot(
|
||||
self.settings,
|
||||
self.storage.closed_trades(self.settings.learning_lookback_trades),
|
||||
self.storage.latest_equity(),
|
||||
self.storage.closed_trades(
|
||||
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:
|
||||
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)
|
||||
if cooldown_since:
|
||||
age = (utc_now() - cooldown_since).total_seconds()
|
||||
cooldown_seconds = self.settings.effective_entry_cooldown_seconds
|
||||
if age < cooldown_seconds:
|
||||
self.storage.insert_signal(
|
||||
self._record_signal(
|
||||
Signal(
|
||||
symbol,
|
||||
"HOLD",
|
||||
@@ -162,7 +231,7 @@ class CryptoSpotBot:
|
||||
account["open_positions_for_symbol"] = open_count
|
||||
account["exchange_min_entry_usdt"] = self.broker.minimum_entry_budget(instrument, ticker)
|
||||
if risk_guard.get("block_new_entries"):
|
||||
self.storage.insert_signal(
|
||||
self._record_signal(
|
||||
Signal(
|
||||
symbol,
|
||||
"HOLD",
|
||||
@@ -178,7 +247,7 @@ class CryptoSpotBot:
|
||||
continue
|
||||
symbol_guard = self._risk_guard_for_symbol(risk_guard, symbol)
|
||||
if symbol_guard.get("block_new_entries"):
|
||||
self.storage.insert_signal(
|
||||
self._record_signal(
|
||||
Signal(
|
||||
symbol,
|
||||
"HOLD",
|
||||
@@ -224,9 +293,10 @@ class CryptoSpotBot:
|
||||
account,
|
||||
trend_candles,
|
||||
)
|
||||
self.storage.insert_signal(signal)
|
||||
self._record_signal(signal)
|
||||
if signal.action == "BUY" and ticker is not None:
|
||||
position = self.broker.buy(
|
||||
position = await asyncio.to_thread(
|
||||
self.broker.buy,
|
||||
signal,
|
||||
ticker,
|
||||
instrument,
|
||||
@@ -235,6 +305,41 @@ class CryptoSpotBot:
|
||||
if position is not None:
|
||||
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
|
||||
def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict:
|
||||
rows = risk_guard.get("symbols")
|
||||
@@ -272,14 +377,28 @@ class CryptoSpotBot:
|
||||
volume_24h=0.0,
|
||||
change_24h=0.0,
|
||||
)
|
||||
self.broker.sell(
|
||||
position,
|
||||
synthetic_ticker,
|
||||
f"{self.settings.strategy_mode}: закрыта старая paper-позиция вне списка разрешенных пар",
|
||||
candidate = Signal(
|
||||
position.symbol,
|
||||
"SELL",
|
||||
0.5,
|
||||
f"{self.settings.strategy_mode}: старая paper-позиция вне списка разрешенных пар",
|
||||
{
|
||||
"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:
|
||||
rules = self._with_exposure_context(self.learner.state.adaptive_rules or {})
|
||||
@@ -303,6 +422,11 @@ class CryptoSpotBot:
|
||||
self.settings.pattern_analysis_enabled
|
||||
or self.settings.grid_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:
|
||||
self.market.patterns = {}
|
||||
@@ -317,6 +441,25 @@ class CryptoSpotBot:
|
||||
self.market.patterns = patterns
|
||||
|
||||
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 (
|
||||
self.forecaster is None
|
||||
or not self.settings.time_series_forecast_enabled
|
||||
@@ -330,20 +473,164 @@ class CryptoSpotBot:
|
||||
symbol=symbol,
|
||||
market_candles=self.market.candles,
|
||||
trend_candles=self.market.trend_candles.get(symbol, []),
|
||||
orderbook_features=orderbook_features,
|
||||
).as_dict()
|
||||
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:
|
||||
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(
|
||||
running=self.running,
|
||||
mode=self.settings.trading_mode,
|
||||
live_trading_ready=self.settings.live_ready,
|
||||
live_trading_ready=live_ready,
|
||||
symbols=self.market.symbols,
|
||||
started_at=self.started_at,
|
||||
last_loop_at=self.last_loop_at,
|
||||
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]:
|
||||
prices = self.market.prices()
|
||||
state = self.broker.account_state(prices)
|
||||
|
||||
+188
-5
@@ -9,6 +9,8 @@ from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from crypto_spot_bot.config import Settings
|
||||
from crypto_spot_bot.models import Candle, Ticker
|
||||
@@ -40,14 +42,47 @@ class Instrument:
|
||||
class BybitClient:
|
||||
def __init__(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]:
|
||||
response = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
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()
|
||||
return self._unwrap(response.json())
|
||||
|
||||
@@ -185,6 +220,10 @@ class BybitClient:
|
||||
return candles
|
||||
|
||||
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(
|
||||
"/v5/market/orderbook",
|
||||
{"category": "spot", "symbol": symbol, "limit": 1},
|
||||
@@ -192,8 +231,10 @@ class BybitClient:
|
||||
bids = result.get("b") or []
|
||||
asks = result.get("a") or []
|
||||
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
|
||||
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(
|
||||
self,
|
||||
@@ -208,27 +249,165 @@ class BybitClient:
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"orderType": "Market",
|
||||
"qty": f"{qty:.8f}".rstrip("0").rstrip("."),
|
||||
"qty": _decimal_text(qty),
|
||||
"timeInForce": "IOC",
|
||||
"isLeverage": 0,
|
||||
"orderFilter": "Order",
|
||||
"marketUnit": market_unit,
|
||||
"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)
|
||||
|
||||
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]:
|
||||
return self.private_get(
|
||||
"/v5/account/wallet-balance",
|
||||
{"accountType": account_type, "coin": coin},
|
||||
)
|
||||
|
||||
def realtime_orders(self, *, category: str = "spot", open_only: int = 0, limit: int = 50) -> dict[str, Any]:
|
||||
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(
|
||||
"/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:
|
||||
args: list[str] = []
|
||||
@@ -254,3 +433,7 @@ def _looks_like_stablecoin(base_coin: str) -> bool:
|
||||
"PYUSD",
|
||||
"USD1",
|
||||
}
|
||||
|
||||
|
||||
def _decimal_text(value: float) -> str:
|
||||
return f"{value:.12f}".rstrip("0").rstrip(".")
|
||||
|
||||
+102
-4
@@ -137,11 +137,13 @@ class Settings:
|
||||
time_series_probe_min_probability_up: float
|
||||
time_series_probe_size_multiplier: float
|
||||
time_series_rebound_fallback_enabled: bool
|
||||
time_series_trend_fallback_enabled: bool
|
||||
stop_loss_percent: float
|
||||
stop_loss_exit_enabled: bool
|
||||
take_profit_percent: float
|
||||
trailing_stop_percent: float
|
||||
min_hold_seconds: int
|
||||
min_exit_net_percent: float
|
||||
entry_cooldown_seconds: int
|
||||
max_daily_drawdown_usdt: float
|
||||
min_cash_reserve_usdt: float
|
||||
@@ -153,17 +155,41 @@ class Settings:
|
||||
database_path: Path
|
||||
log_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
|
||||
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
|
||||
def websocket_url(self) -> str:
|
||||
if self.bybit_websocket_url_override:
|
||||
return self.bybit_websocket_url_override
|
||||
return (
|
||||
"wss://stream-testnet.bybit.com/v5/public/spot"
|
||||
if self.bybit_testnet
|
||||
else "wss://stream.bybit.com/v5/public/spot"
|
||||
else "wss://stream.bybit.kz/v5/public/spot"
|
||||
)
|
||||
|
||||
@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()
|
||||
if strategy_mode not in STRATEGY_MODES:
|
||||
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))
|
||||
requested_symbols = _symbols_env("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_probability_up=_float_env("TIME_SERIES_PROBE_MIN_PROBABILITY_UP", 0.55),
|
||||
time_series_probe_size_multiplier=_float_env("TIME_SERIES_PROBE_SIZE_MULTIPLIER", 0.40),
|
||||
time_series_rebound_fallback_enabled=_bool_env("TIME_SERIES_REBOUND_FALLBACK_ENABLED", True),
|
||||
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_exit_enabled=_bool_env("STOP_LOSS_EXIT_ENABLED", True),
|
||||
take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035),
|
||||
trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015),
|
||||
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),
|
||||
max_daily_drawdown_usdt=_float_env("MAX_DAILY_DRAWDOWN_USDT", 6.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")),
|
||||
log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")),
|
||||
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:
|
||||
raise ValueError(
|
||||
"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
|
||||
|
||||
|
||||
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:
|
||||
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
||||
output: list[str] = []
|
||||
|
||||
+312
-37
@@ -1,14 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Response
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
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 import __version__
|
||||
from crypto_spot_bot.bybit import BybitClient
|
||||
from crypto_spot_bot.config import Settings, load_settings, update_env_value
|
||||
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.patterns import PatternAnalyzer
|
||||
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.strategy import SpotStrategy
|
||||
from crypto_spot_bot.time_series import TimeSeriesForecaster
|
||||
from crypto_spot_bot.training_coordination import TrainingCoordinator
|
||||
|
||||
|
||||
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
|
||||
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:
|
||||
@@ -42,8 +52,25 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
pattern_analyzer = PatternAnalyzer()
|
||||
learner = TradeLearner(settings, storage)
|
||||
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)
|
||||
authorizer = ApiAuthorizer(settings)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
@@ -53,63 +80,93 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
finally:
|
||||
await bot.stop()
|
||||
|
||||
app = FastAPI(title="Крипто спот-бот", lifespan=lifespan)
|
||||
app = FastAPI(title="Крипто спот-бот", version=__version__, lifespan=lifespan)
|
||||
app.state.settings = settings
|
||||
app.state.storage = storage
|
||||
app.state.bot = bot
|
||||
app.state.market = market
|
||||
app.state.training = training
|
||||
app.mount("/assets", StaticFiles(directory=WEB_ROOT), name="dashboard-assets")
|
||||
|
||||
@app.get("/", response_class=PlainTextResponse, status_code=410)
|
||||
async def index() -> str:
|
||||
return WEB_UI_REMOVED_MESSAGE
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next) -> Response:
|
||||
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")
|
||||
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")
|
||||
async def status() -> dict[str, Any]:
|
||||
async def status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
return {
|
||||
"status": bot.status().as_dict(),
|
||||
"account": bot.account_snapshot(),
|
||||
"positions": bot.positions_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")
|
||||
async def markets() -> dict[str, Any]:
|
||||
async def markets(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
return market.snapshot()
|
||||
|
||||
@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)
|
||||
return {
|
||||
"items": storage.recent_trades(row_limit),
|
||||
"closed_items": storage.closed_trades(row_limit),
|
||||
"closed_summary": storage.closed_trade_summary(),
|
||||
"items": storage.recent_trades(row_limit, mode=settings.trading_mode),
|
||||
"closed_items": storage.closed_trades(row_limit, mode=settings.trading_mode),
|
||||
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
|
||||
}
|
||||
|
||||
@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))}
|
||||
|
||||
@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))}
|
||||
|
||||
@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)
|
||||
|
||||
@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", {})
|
||||
|
||||
@app.get("/api/reconciliation")
|
||||
async def reconciliation() -> dict[str, Any]:
|
||||
return reconciliation_snapshot(
|
||||
async def reconciliation(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(
|
||||
reconciliation_snapshot,
|
||||
settings=settings,
|
||||
storage=storage,
|
||||
client=client,
|
||||
@@ -117,71 +174,230 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
)
|
||||
|
||||
@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")
|
||||
|
||||
@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["coordination"] = training.status()
|
||||
data["shadow"] = shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256())
|
||||
return data
|
||||
|
||||
@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()
|
||||
|
||||
@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")
|
||||
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)
|
||||
|
||||
@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")
|
||||
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)
|
||||
|
||||
@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)
|
||||
|
||||
@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:
|
||||
return training.save_artifact_chunk(job_id, payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/training/jobs/{job_id}/progress")
|
||||
async def training_progress(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
async def training_progress(
|
||||
job_id: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
_: None = Depends(authorizer.require_training),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return training.progress(job_id, payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/training/jobs/{job_id}/complete")
|
||||
async def training_complete(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
async def training_complete(
|
||||
job_id: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
_: None = Depends(authorizer.require_training),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return training.complete(job_id, payload)
|
||||
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")
|
||||
async def config() -> dict[str, Any]:
|
||||
async def config(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
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")
|
||||
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)
|
||||
env_persisted = _apply_fast_trading(settings, storage, enabled)
|
||||
response = _safe_config(settings)
|
||||
response["env_persisted"] = env_persisted
|
||||
return response
|
||||
|
||||
@app.post("/web-api/control/start", include_in_schema=False)
|
||||
@app.post("/api/control/start")
|
||||
async def start() -> dict[str, Any]:
|
||||
async def start(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
await bot.start()
|
||||
return bot.status().as_dict()
|
||||
|
||||
@app.post("/web-api/control/stop", include_in_schema=False)
|
||||
@app.post("/api/control/stop")
|
||||
async def stop() -> dict[str, Any]:
|
||||
async def stop(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||
await bot.stop()
|
||||
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.",
|
||||
"# TYPE tradebot_loop_interval_seconds gauge",
|
||||
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")
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def error_handler(_, exc: Exception) -> JSONResponse:
|
||||
try:
|
||||
storage.event(f"API error: {exc}", "ERROR")
|
||||
return JSONResponse({"error": str(exc)}, status_code=500)
|
||||
except Exception:
|
||||
logger.exception("Could not persist API error event")
|
||||
return JSONResponse({"error": "internal server error"}, status_code=500)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
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:
|
||||
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_size_multiplier": settings.time_series_probe_size_multiplier,
|
||||
"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),
|
||||
"stop_loss_percent": settings.stop_loss_percent,
|
||||
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
|
||||
"take_profit_percent": settings.take_profit_percent,
|
||||
"trailing_stop_percent": settings.trailing_stop_percent,
|
||||
"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,
|
||||
"max_daily_drawdown_usdt": settings.max_daily_drawdown_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,
|
||||
"live_ready": settings.live_ready,
|
||||
"live_order_max_usdt": settings.live_order_max_usdt,
|
||||
"live_order_fill_timeout_seconds": settings.live_order_fill_timeout_seconds,
|
||||
"live_reconciliation_interval_seconds": settings.live_reconciliation_interval_seconds,
|
||||
"live_protective_stop_enabled": settings.live_protective_stop_enabled,
|
||||
"api_auth_configured": bool(
|
||||
settings.api_auth_token
|
||||
or settings.training_worker_token
|
||||
or settings.trusted_proxy_user_header
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+493
-13
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections import deque
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||
from typing import Iterable
|
||||
from typing import Any, Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
from crypto_spot_bot.bybit import BybitClient, Instrument
|
||||
@@ -38,9 +38,19 @@ class PaperBroker:
|
||||
def __init__(self, settings: Settings, storage: Storage):
|
||||
self.settings = settings
|
||||
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.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()
|
||||
|
||||
def open_positions(self) -> list[Position]:
|
||||
@@ -64,11 +74,24 @@ class PaperBroker:
|
||||
def mark_equity(self, prices: dict[str, float]) -> dict[str, float]:
|
||||
state = self.account_state(prices)
|
||||
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.lifetime_peak_equity = max(self.lifetime_peak_equity, equity)
|
||||
state["drawdown"] = max(0.0, self.peak_equity - equity)
|
||||
self.storage.set_runtime("paper_cash", self.cash)
|
||||
self.storage.set_runtime("peak_equity", self.peak_equity)
|
||||
self.storage.insert_equity(equity, self.cash, self.exposure(), state["drawdown"])
|
||||
self.storage.set_runtime("paper_peak_equity_day", self.peak_equity_day)
|
||||
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
|
||||
|
||||
def account_state(self, prices: dict[str, float]) -> dict[str, float]:
|
||||
@@ -181,6 +204,7 @@ class PaperBroker:
|
||||
entry_confidence=signal.confidence,
|
||||
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
|
||||
entry_diagnostics=signal.diagnostics,
|
||||
mode=self.settings.trading_mode,
|
||||
)
|
||||
position.id = self.storage.insert_position(position)
|
||||
self.positions.append(position)
|
||||
@@ -201,6 +225,7 @@ class PaperBroker:
|
||||
entry_confidence=position.entry_confidence,
|
||||
entry_diagnostics=position.entry_diagnostics,
|
||||
opened_at=position.opened_at,
|
||||
mode=self.settings.trading_mode,
|
||||
)
|
||||
)
|
||||
self.storage.event(
|
||||
@@ -244,6 +269,7 @@ class PaperBroker:
|
||||
entry_diagnostics=position.entry_diagnostics,
|
||||
opened_at=position.opened_at,
|
||||
closed_at=utc_now(),
|
||||
mode=self.settings.trading_mode,
|
||||
)
|
||||
trade.id = self.storage.insert_trade(trade)
|
||||
self.storage.event(
|
||||
@@ -368,11 +394,139 @@ class PaperBroker:
|
||||
|
||||
|
||||
class LiveBroker(PaperBroker):
|
||||
TERMINAL_ORDER_STATUSES = {
|
||||
"Filled",
|
||||
"Cancelled",
|
||||
"Rejected",
|
||||
"PartiallyFilledCanceled",
|
||||
"PartillyFilledCancelled",
|
||||
"Deactivated",
|
||||
}
|
||||
|
||||
def __init__(self, settings: Settings, storage: Storage, client: BybitClient):
|
||||
super().__init__(settings, storage)
|
||||
if not settings.live_ready:
|
||||
raise BrokerError("Live mode is not unlocked by settings")
|
||||
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(
|
||||
self,
|
||||
@@ -400,30 +554,356 @@ class LiveBroker(PaperBroker):
|
||||
if budget < max(self.settings.min_position_usdt, minimum_budget):
|
||||
self.storage.event(f"{ticker.symbol}: live BUY skipped, adjusted budget below minimum", "WARN")
|
||||
return None
|
||||
|
||||
signal.diagnostics["position_notional_usdt"] = budget
|
||||
notional = budget / (1 + self.settings.taker_fee_rate)
|
||||
requested_quote = budget / (1 + self.settings.taker_fee_rate)
|
||||
client_order_id = f"tb-buy-{uuid4().hex[:18]}"
|
||||
self.storage.upsert_order(
|
||||
client_order_id=client_order_id,
|
||||
symbol=ticker.symbol,
|
||||
side="Buy",
|
||||
order_kind="MARKET",
|
||||
status="PENDING_SUBMIT",
|
||||
requested_notional=requested_quote,
|
||||
raw={"signal": signal.as_dict()},
|
||||
)
|
||||
try:
|
||||
response = self.client.place_spot_market_order(
|
||||
symbol=ticker.symbol,
|
||||
side="Buy",
|
||||
qty=notional,
|
||||
qty=requested_quote,
|
||||
market_unit="quoteCoin",
|
||||
order_link_id=f"tb-buy-{uuid4().hex[:18]}",
|
||||
order_link_id=client_order_id,
|
||||
)
|
||||
self.storage.event(f"{ticker.symbol}: реальная покупка отправлена orderId={response.get('orderId')}")
|
||||
return self._record_buy(signal, ticker, instrument, "реальная покупка, локальная запись")
|
||||
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:
|
||||
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(
|
||||
symbol=position.symbol,
|
||||
side="Sell",
|
||||
qty=position.qty,
|
||||
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(
|
||||
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]:
|
||||
|
||||
@@ -60,7 +60,10 @@ class TradeLearner:
|
||||
self.storage.set_runtime("learning_state", self.state.as_dict())
|
||||
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)
|
||||
wins = sum(1 for trade in trades if float(trade.get("net_pnl") or 0.0) > 0)
|
||||
symbol_stats = _group_stats(trades, "symbol")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
import uvicorn
|
||||
|
||||
@@ -15,7 +16,12 @@ def main() -> None:
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(settings.log_path, encoding="utf-8"),
|
||||
RotatingFileHandler(
|
||||
settings.log_path,
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
|
||||
+174
-17
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -50,12 +52,22 @@ class MarketData:
|
||||
self.candles: dict[str, list[Candle]] = {}
|
||||
self.trend_candles: dict[str, list[Candle]] = {}
|
||||
self.orderbook_top: dict[str, tuple[float, float]] = {}
|
||||
self.orderbook_metrics: dict[str, dict[str, Any]] = {}
|
||||
self.patterns: 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_ws_message_at: datetime | None = None
|
||||
self.ws_connected = False
|
||||
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:
|
||||
self.instruments = await asyncio.to_thread(self.client.instruments)
|
||||
@@ -76,15 +88,19 @@ class MarketData:
|
||||
if symbol in self.instruments
|
||||
]
|
||||
self.storage.event("Торговые пары: " + ", ".join(self.symbols))
|
||||
await asyncio.to_thread(self.refresh_rest)
|
||||
await asyncio.to_thread(self.refresh_rest, True)
|
||||
|
||||
def refresh_rest(self) -> None:
|
||||
def refresh_rest(self, force_candles: bool = False) -> None:
|
||||
if not self._refresh_lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
ticker_map = {ticker.symbol: ticker for ticker in self.client.spot_tickers()}
|
||||
for symbol in self.symbols:
|
||||
ticker = ticker_map.get(symbol)
|
||||
if ticker:
|
||||
self.tickers[symbol] = ticker
|
||||
try:
|
||||
if force_candles or _candles_due(self.candles.get(symbol, []), self.settings.base_interval):
|
||||
candles = self.client.klines(
|
||||
symbol=symbol,
|
||||
interval=self.settings.base_interval,
|
||||
@@ -93,6 +109,9 @@ class MarketData:
|
||||
candles = _closed_candles(candles, self.settings.base_interval)
|
||||
add_indicators(candles)
|
||||
self.candles[symbol] = candles
|
||||
if force_candles or _candles_due(
|
||||
self.trend_candles.get(symbol, []), self.settings.trend_interval
|
||||
):
|
||||
trend_candles = self.client.klines(
|
||||
symbol=symbol,
|
||||
interval=self.settings.trend_interval,
|
||||
@@ -101,22 +120,17 @@ class MarketData:
|
||||
trend_candles = _closed_candles(trend_candles, self.settings.trend_interval)
|
||||
add_indicators(trend_candles)
|
||||
self.trend_candles[symbol] = trend_candles
|
||||
bid, ask = self.client.orderbook_top(symbol)
|
||||
self.orderbook_top[symbol] = (bid, ask)
|
||||
if symbol in self.tickers:
|
||||
current = self.tickers[symbol]
|
||||
self.tickers[symbol] = Ticker(
|
||||
symbol=current.symbol,
|
||||
last_price=current.last_price,
|
||||
bid=bid or current.bid,
|
||||
ask=ask or current.ask,
|
||||
turnover_24h=current.turnover_24h,
|
||||
volume_24h=current.volume_24h,
|
||||
change_24h=current.change_24h,
|
||||
)
|
||||
bid, bid_size, ask, ask_size = self.client.orderbook_level_one(symbol)
|
||||
self._update_orderbook(symbol, bid, bid_size, ask, ask_size)
|
||||
except Exception as exc:
|
||||
self.rest_error_count += 1
|
||||
self.last_rest_error = str(exc)
|
||||
self.storage.event(f"{symbol}: ошибка обновления REST данных: {exc}", "ERROR")
|
||||
self.last_rest_refresh_at = utc_now()
|
||||
if ticker_map:
|
||||
self.last_rest_error = ""
|
||||
finally:
|
||||
self._refresh_lock.release()
|
||||
|
||||
async def websocket_loop(self) -> None:
|
||||
if not self.settings.websocket_enabled:
|
||||
@@ -163,7 +177,11 @@ class MarketData:
|
||||
elif topic.startswith("orderbook.") and isinstance(data, dict):
|
||||
parts = topic.split(".")
|
||||
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:
|
||||
current = self.tickers.get(symbol)
|
||||
@@ -205,14 +223,66 @@ class MarketData:
|
||||
add_indicators(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 []
|
||||
asks = data.get("a") or []
|
||||
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_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:
|
||||
self.orderbook_top[symbol] = (bid, ask)
|
||||
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:
|
||||
self.tickers[symbol] = Ticker(
|
||||
symbol=symbol,
|
||||
@@ -223,14 +293,78 @@ class MarketData:
|
||||
volume_24h=current.volume_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]:
|
||||
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]:
|
||||
return {
|
||||
"symbols": self.symbols,
|
||||
"ws_connected": self.ws_connected,
|
||||
"rest_error_count": self.rest_error_count,
|
||||
"last_rest_error": self.last_rest_error,
|
||||
"quality": market_quality_snapshot(
|
||||
symbols=self.symbols,
|
||||
candles_by_symbol=self.candles,
|
||||
@@ -243,6 +377,16 @@ class MarketData:
|
||||
"last_ws_message_at": self.last_ws_message_at.isoformat()
|
||||
if self.last_ws_message_at
|
||||
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": [
|
||||
{
|
||||
"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:]],
|
||||
"pattern": self.patterns.get(symbol),
|
||||
"forecast": self.forecasts.get(symbol),
|
||||
"shadow_forecast": self.shadow_forecasts.get(symbol),
|
||||
"orderbook": self.orderbook_metrics.get(symbol),
|
||||
"quality": analyze_symbol_quality(
|
||||
symbol=symbol,
|
||||
candles=self.candles.get(symbol, []),
|
||||
@@ -291,3 +437,14 @@ def _interval_ms(interval: str) -> int:
|
||||
if normalized.isdigit():
|
||||
return int(normalized) * 60 * 1000
|
||||
return 0
|
||||
|
||||
|
||||
def _candles_due(candles: list[Candle], interval: str, now_ms: int | None = None) -> bool:
|
||||
if not candles:
|
||||
return True
|
||||
interval_ms = _interval_ms(interval)
|
||||
if interval_ms <= 0:
|
||||
return True
|
||||
now_ms = now_ms if now_ms is not None else int(utc_now().timestamp() * 1000)
|
||||
expected_latest_start = (now_ms // interval_ms - 1) * interval_ms
|
||||
return candles[-1].timestamp < expected_latest_start
|
||||
|
||||
@@ -88,6 +88,9 @@ class Position:
|
||||
entry_confidence: float = 0.0
|
||||
entry_pattern: str = ""
|
||||
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:
|
||||
return self.qty * price
|
||||
@@ -131,6 +134,7 @@ class Trade:
|
||||
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
|
||||
opened_at: datetime | None = None
|
||||
closed_at: datetime | None = None
|
||||
mode: str = "paper"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
|
||||
@@ -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
|
||||
@@ -15,7 +15,7 @@ def reconciliation_snapshot(
|
||||
client: BybitClient,
|
||||
instruments: dict[str, Instrument],
|
||||
) -> dict[str, Any]:
|
||||
local_positions = storage.open_positions()
|
||||
local_positions = storage.open_positions(settings.trading_mode)
|
||||
local = [
|
||||
{
|
||||
"id": position.id,
|
||||
@@ -23,6 +23,7 @@ def reconciliation_snapshot(
|
||||
"qty": position.qty,
|
||||
"entry_price": position.entry_price,
|
||||
"notional_usdt": position.notional_usdt,
|
||||
"protective_order_id": position.protective_order_id,
|
||||
}
|
||||
for position in local_positions
|
||||
]
|
||||
|
||||
@@ -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
|
||||
+636
-27
@@ -2,23 +2,70 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
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:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._last_hold_signal: dict[tuple[str, str], float] = {}
|
||||
self.init_schema()
|
||||
|
||||
@contextmanager
|
||||
def connect(self) -> Iterator[sqlite3.Connection]:
|
||||
conn = sqlite3.connect(self.path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
@@ -27,6 +74,10 @@ class Storage:
|
||||
|
||||
def init_schema(self) -> None:
|
||||
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(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS positions (
|
||||
@@ -44,6 +95,9 @@ class Storage:
|
||||
entry_confidence REAL NOT NULL DEFAULT 0,
|
||||
entry_pattern 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'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
@@ -61,7 +115,8 @@ class Storage:
|
||||
entry_confidence REAL NOT NULL DEFAULT 0,
|
||||
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
|
||||
opened_at TEXT,
|
||||
closed_at TEXT
|
||||
closed_at TEXT,
|
||||
mode TEXT NOT NULL DEFAULT 'paper'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -78,7 +133,8 @@ class Storage:
|
||||
cash REAL NOT NULL,
|
||||
exposure 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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -101,6 +157,74 @@ class Storage:
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
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 = {
|
||||
@@ -116,6 +240,9 @@ class Storage:
|
||||
"entry_confidence": "REAL NOT NULL DEFAULT 0",
|
||||
"entry_pattern": "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():
|
||||
if column not in columns:
|
||||
conn.execute(f"ALTER TABLE positions ADD COLUMN {column} {definition}")
|
||||
@@ -127,9 +254,19 @@ class Storage:
|
||||
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
|
||||
"entry_confidence": "REAL NOT NULL DEFAULT 0",
|
||||
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
|
||||
"mode": "TEXT NOT NULL DEFAULT 'paper'",
|
||||
}.items():
|
||||
if column not in trade_columns:
|
||||
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:
|
||||
with self.connect() as conn:
|
||||
@@ -138,8 +275,9 @@ class Storage:
|
||||
INSERT INTO positions (
|
||||
symbol, qty, entry_price, notional_usdt, entry_fee_usdt, stop_loss,
|
||||
take_profit, highest_price, opened_at, entry_reason,
|
||||
entry_confidence, entry_pattern, entry_diagnostics_json, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
|
||||
entry_confidence, entry_pattern, entry_diagnostics_json,
|
||||
protective_order_id, protective_order_link_id, mode, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
|
||||
""",
|
||||
(
|
||||
position.symbol,
|
||||
@@ -155,6 +293,9 @@ class Storage:
|
||||
position.entry_confidence,
|
||||
position.entry_pattern,
|
||||
json.dumps(position.entry_diagnostics, ensure_ascii=False),
|
||||
position.protective_order_id,
|
||||
position.protective_order_link_id,
|
||||
position.mode,
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
@@ -170,8 +311,49 @@ class Storage:
|
||||
(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:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE positions
|
||||
SET protective_order_id=?, protective_order_link_id=?
|
||||
WHERE id=? AND status='OPEN'
|
||||
""",
|
||||
(order_id, order_link_id, position_id),
|
||||
)
|
||||
|
||||
def update_position_after_partial_sell(
|
||||
self,
|
||||
position_id: int,
|
||||
*,
|
||||
qty: float,
|
||||
notional_usdt: float,
|
||||
entry_fee_usdt: float,
|
||||
) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE positions
|
||||
SET qty=?, notional_usdt=?, entry_fee_usdt=?,
|
||||
protective_order_id='', protective_order_link_id=''
|
||||
WHERE id=? AND status='OPEN'
|
||||
""",
|
||||
(qty, notional_usdt, entry_fee_usdt, position_id),
|
||||
)
|
||||
|
||||
def open_positions(self, mode: str | None = None) -> list[Position]:
|
||||
with self.connect() as conn:
|
||||
if mode:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM positions WHERE status='OPEN' AND mode=? ORDER BY opened_at",
|
||||
(mode,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM positions WHERE status='OPEN' ORDER BY opened_at"
|
||||
).fetchall()
|
||||
@@ -191,6 +373,9 @@ class Storage:
|
||||
entry_confidence=float(row["entry_confidence"]),
|
||||
entry_pattern=row["entry_pattern"],
|
||||
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
|
||||
]
|
||||
@@ -203,7 +388,8 @@ class Storage:
|
||||
symbol, side, qty, entry_price, exit_price, gross_pnl,
|
||||
fee_usdt, net_pnl, reason, entry_pattern, entry_confidence,
|
||||
entry_diagnostics_json, opened_at, closed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
, mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
trade.symbol,
|
||||
@@ -220,32 +406,41 @@ class Storage:
|
||||
json.dumps(trade.entry_diagnostics, ensure_ascii=False),
|
||||
trade.opened_at.isoformat() if trade.opened_at else None,
|
||||
trade.closed_at.isoformat() if trade.closed_at else None,
|
||||
trade.mode,
|
||||
),
|
||||
)
|
||||
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:
|
||||
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]
|
||||
|
||||
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:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
query = """
|
||||
SELECT * FROM trades
|
||||
WHERE side='SELL' AND closed_at IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
"""
|
||||
params: tuple[Any, ...]
|
||||
if mode:
|
||||
query += " AND mode=?"
|
||||
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]
|
||||
|
||||
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:
|
||||
row = conn.execute(
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) AS trades,
|
||||
COALESCE(SUM(net_pnl), 0) AS net_pnl,
|
||||
@@ -256,7 +451,11 @@ class Storage:
|
||||
FROM trades
|
||||
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)
|
||||
wins = int(row["wins"] 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,
|
||||
}
|
||||
|
||||
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:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -282,25 +489,256 @@ class Storage:
|
||||
signal.action,
|
||||
signal.confidence,
|
||||
signal.reason,
|
||||
json.dumps(signal.diagnostics, ensure_ascii=False),
|
||||
_signal_diagnostics_json(signal.diagnostics),
|
||||
signal.created_at.isoformat(),
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
def recent_signals(self, limit: int = 80) -> list[dict[str, Any]]:
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||
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:
|
||||
conn.execute(
|
||||
"INSERT INTO equity (equity, cash, exposure, drawdown, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(equity, cash, exposure, drawdown, utc_now().isoformat()),
|
||||
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 latest_equity(self) -> dict[str, Any] | None:
|
||||
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:
|
||||
conn.execute(
|
||||
"INSERT INTO equity (equity, cash, exposure, drawdown, created_at, mode) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(equity, cash, exposure, drawdown, utc_now().isoformat(), mode),
|
||||
)
|
||||
|
||||
def latest_equity(self, mode: str | None = None) -> dict[str, Any] | None:
|
||||
with self.connect() as conn:
|
||||
if mode:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM equity WHERE mode=? ORDER BY id DESC LIMIT 1",
|
||||
(mode,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute("SELECT * FROM equity ORDER BY id DESC LIMIT 1").fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@@ -376,12 +814,183 @@ class Storage:
|
||||
except json.JSONDecodeError:
|
||||
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:
|
||||
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}")
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
return json.loads(value)
|
||||
|
||||
+315
-18
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from crypto_spot_bot.config import Settings
|
||||
from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now
|
||||
|
||||
@@ -25,6 +27,59 @@ class SpotStrategy:
|
||||
trend_candles: list[Candle] | None = None,
|
||||
) -> Signal:
|
||||
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(
|
||||
settings=self.settings,
|
||||
symbol=symbol,
|
||||
@@ -316,6 +371,7 @@ class SpotStrategy:
|
||||
latest = candles[-1]
|
||||
previous = candles[-2] if len(candles) >= 2 else latest
|
||||
price = ticker.last_price
|
||||
adaptive = _adaptive_rules(learning or {})
|
||||
trailing = position.trailing_stop(self.settings.trailing_stop_percent)
|
||||
diagnostics = {
|
||||
"price": price,
|
||||
@@ -328,8 +384,11 @@ class SpotStrategy:
|
||||
"rsi_14": latest.rsi_14,
|
||||
"ema_20": latest.ema_20,
|
||||
"ema_50": latest.ema_50,
|
||||
"adaptive_rules": adaptive,
|
||||
}
|
||||
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)
|
||||
if price >= position.take_profit:
|
||||
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
|
||||
@@ -368,6 +427,53 @@ class SpotStrategy:
|
||||
forecast: dict | None = None,
|
||||
) -> Signal:
|
||||
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 {})
|
||||
if self.settings.strategy_mode == "trend_macd":
|
||||
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)
|
||||
trailing = position.trailing_stop(trailing_percent)
|
||||
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings)
|
||||
min_exit_net_percent = _min_exit_net_percent(self.settings)
|
||||
diagnostics = {
|
||||
"price": price,
|
||||
"entry_price": position.entry_price,
|
||||
@@ -408,9 +515,12 @@ class SpotStrategy:
|
||||
"adaptive_rules": adaptive,
|
||||
"forecast": forecast,
|
||||
"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),
|
||||
}
|
||||
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)
|
||||
if price >= effective_take_profit:
|
||||
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
|
||||
@@ -438,6 +548,7 @@ class SpotStrategy:
|
||||
estimated_exit_net_percent=estimated_exit_net_percent,
|
||||
stop_loss_percent=stop_loss_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:
|
||||
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(
|
||||
*,
|
||||
settings: Settings,
|
||||
@@ -605,6 +722,8 @@ def _trend_macd_exit_signal(
|
||||
"close_below_ema50": close_below_ema50,
|
||||
}
|
||||
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)
|
||||
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)
|
||||
@@ -639,10 +758,14 @@ def _torch_forecast_entry_signal(
|
||||
sizing = _torch_forecast_position_sizing(settings, account_context, stop_loss_percent, forecast, symbol)
|
||||
position_notional = float(sizing["notional_usdt"])
|
||||
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)
|
||||
min_edge = max(0.0, settings.time_series_min_edge_percent)
|
||||
min_probability = _torch_min_probability(settings)
|
||||
min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent))
|
||||
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_probability = round(
|
||||
_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
|
||||
liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
|
||||
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(
|
||||
settings=settings,
|
||||
candles=candles or [],
|
||||
@@ -694,20 +830,22 @@ def _torch_forecast_entry_signal(
|
||||
rebound.get("active")
|
||||
and model_ok
|
||||
and quality_gate_ok
|
||||
and model_fresh_ok
|
||||
and bool(forecast.get("usable", False))
|
||||
and not bool(forecast.get("block_entry", False))
|
||||
and expected_return >= 0.0
|
||||
and probability_up >= rebound_model_probability_min
|
||||
and skill > 0.0
|
||||
and confidence >= settings.time_series_min_confidence
|
||||
and confidence >= _safe_float(forecast.get("calibrated_min_confidence"), settings.time_series_min_confidence)
|
||||
)
|
||||
fallback_rebound_entry_ok = bool(
|
||||
settings.time_series_rebound_fallback_enabled
|
||||
and rebound.get("active")
|
||||
and missing_torch_model
|
||||
and quality_gate_ok
|
||||
and model_fresh_ok
|
||||
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
|
||||
if rebound_entry_ok and position_notional > 0:
|
||||
@@ -730,6 +868,7 @@ def _torch_forecast_entry_signal(
|
||||
checks = {
|
||||
"torch_model_ok": model_ok,
|
||||
"quality_gate_ok": quality_gate_ok,
|
||||
"model_fresh_ok": model_fresh_ok,
|
||||
"forecast_usable": bool(forecast.get("usable", False)),
|
||||
"forecast_not_blocked": not bool(forecast.get("block_entry", False)),
|
||||
"expected_edge_ok": full_edge_ok or probe_edge_ok,
|
||||
@@ -767,6 +906,10 @@ def _torch_forecast_entry_signal(
|
||||
"skill": skill,
|
||||
"quality_gate": forecast.get("quality_gate", {}),
|
||||
"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),
|
||||
"turnover_24h": ticker.turnover_24h,
|
||||
"checks": checks,
|
||||
@@ -871,11 +1014,16 @@ def _torch_forecast_exit_signal(
|
||||
)
|
||||
|
||||
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)
|
||||
min_edge = max(0.0, settings.time_series_min_edge_percent)
|
||||
min_probability = _torch_min_probability(settings)
|
||||
min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent))
|
||||
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)
|
||||
min_exit_net_percent = _min_exit_net_percent(settings)
|
||||
entry_path = str(position.entry_diagnostics.get("entry_path", ""))
|
||||
entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", ""))
|
||||
rebound_fallback_position = entry_path == "rebound_fallback" or entry_edge_mode == "rebound_fallback"
|
||||
@@ -899,26 +1047,53 @@ def _torch_forecast_exit_signal(
|
||||
"min_probability_up": min_probability,
|
||||
"skill": skill,
|
||||
"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,
|
||||
}
|
||||
hold_seconds = (utc_now() - position.opened_at).total_seconds()
|
||||
diagnostics["hold_seconds"] = hold_seconds
|
||||
diagnostics["min_hold_seconds"] = settings.min_hold_seconds
|
||||
if effective_stop_loss is not None and price <= effective_stop_loss:
|
||||
diagnostics["emergency_exit"] = True
|
||||
diagnostics["emergency_exit_type"] = "configured_stop_loss"
|
||||
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics)
|
||||
if price >= position.take_profit:
|
||||
return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics)
|
||||
if atr_trailing_stop is not None and price <= atr_trailing_stop:
|
||||
if estimated_exit_net_percent < min_exit_net_percent:
|
||||
diagnostics["atr_exit_blocked_by_min_profit"] = True
|
||||
if estimated_exit_net_percent < 0:
|
||||
diagnostics["atr_exit_blocked_by_cost"] = True
|
||||
return Signal(
|
||||
position.symbol,
|
||||
"HOLD",
|
||||
0.45,
|
||||
"torch_forecast: ATR trailing touched, but exit is not worth fees",
|
||||
"torch_forecast: ATR trailing touched, but exit profit is below minimum",
|
||||
diagnostics,
|
||||
)
|
||||
return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics)
|
||||
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 rebound_fallback_position:
|
||||
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,
|
||||
stop_loss_percent=stop_loss_percent,
|
||||
min_edge_percent=min_edge,
|
||||
min_exit_net_percent=min_exit_net_percent,
|
||||
)
|
||||
if forecast_exit is not None:
|
||||
action, confidence, reason = forecast_exit
|
||||
return Signal(position.symbol, action, confidence, reason, diagnostics)
|
||||
diagnostics["forecast_exit_blocked_by_min_profit"] = True
|
||||
if estimated_exit_net_percent < 0:
|
||||
diagnostics["forecast_exit_blocked_by_cost"] = True
|
||||
return Signal(
|
||||
position.symbol,
|
||||
"HOLD",
|
||||
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}%"
|
||||
),
|
||||
diagnostics,
|
||||
)
|
||||
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(
|
||||
position.symbol,
|
||||
"SELL",
|
||||
@@ -983,6 +1161,8 @@ def _torch_forecast_exit_signal(
|
||||
),
|
||||
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)
|
||||
|
||||
|
||||
@@ -991,6 +1171,21 @@ def _is_torch_forecast(forecast: dict) -> bool:
|
||||
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:
|
||||
model = str(forecast.get("model", "")).strip().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:
|
||||
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))
|
||||
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)
|
||||
@@ -1044,7 +1239,7 @@ def _torch_forecast_position_sizing(
|
||||
symbol=symbol,
|
||||
)
|
||||
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
|
||||
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
|
||||
probability_up = _forecast_probability(forecast)
|
||||
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
|
||||
min_edge = max(0.01, settings.time_series_min_edge_percent)
|
||||
edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15)
|
||||
@@ -1199,7 +1394,7 @@ def _position_risk_multiplier(forecast: dict | None, adaptive: dict | None) -> f
|
||||
multiplier = 1.0
|
||||
forecast = forecast or {}
|
||||
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)
|
||||
if probability_up < 0.52:
|
||||
multiplier *= 0.75
|
||||
@@ -1236,7 +1431,7 @@ def _kelly_position(
|
||||
probability_source = "confidence"
|
||||
probability = confidence_probability
|
||||
if forecast.get("usable"):
|
||||
probability = _safe_float(forecast.get("probability_up"), confidence_probability)
|
||||
probability = _forecast_probability(forecast, confidence_probability)
|
||||
probability_source = "forecast"
|
||||
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:
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
mode = str(adaptive.get(mode_key, "normal")).lower()
|
||||
if mode != "profit_only":
|
||||
@@ -1649,18 +1945,19 @@ def _forecast_exit_signal(
|
||||
estimated_exit_net_percent: float,
|
||||
stop_loss_percent: float,
|
||||
min_edge_percent: float,
|
||||
min_exit_net_percent: float,
|
||||
) -> tuple[str, float, str] | None:
|
||||
if not forecast.get("usable"):
|
||||
return None
|
||||
skill = _safe_float(forecast.get("skill"), 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)
|
||||
strong_negative = skill > 0.02 and expected_return <= -max(min_edge, 0.03) and probability_up <= 0.44
|
||||
if not strong_negative:
|
||||
return None
|
||||
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}; фиксируем результат"
|
||||
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)
|
||||
|
||||
+328
-15
@@ -2,12 +2,16 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import hashlib
|
||||
from bisect import bisect_right
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from crypto_spot_bot.config import Settings
|
||||
from crypto_spot_bot.models import Candle
|
||||
from crypto_spot_bot.orderbook_features import ORDERBOOK_FEATURES
|
||||
|
||||
|
||||
DEFAULT_TORCH_FEATURES = (
|
||||
@@ -156,14 +160,31 @@ class TimeSeriesForecast:
|
||||
candidates: list[dict[str, Any]] = field(default_factory=list)
|
||||
quality_gate_passed: bool | None = None
|
||||
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]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
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.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: dict[str, Any] = {}
|
||||
self._calibration_mtime: float | None = None
|
||||
@@ -176,6 +197,7 @@ class TimeSeriesForecaster:
|
||||
*,
|
||||
market_candles: dict[str, list[Candle]] | None = None,
|
||||
trend_candles: list[Candle] | None = None,
|
||||
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
|
||||
) -> TimeSeriesForecast:
|
||||
if not self.settings.time_series_forecast_enabled:
|
||||
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")
|
||||
|
||||
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)
|
||||
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)
|
||||
model = _torch_recurrent_model_name(symbol, artifact)
|
||||
clip = _clamp(_float_entry(entry or {}, "clip", 8.0), 1.0, 50.0)
|
||||
@@ -200,6 +239,7 @@ class TimeSeriesForecaster:
|
||||
symbol=symbol,
|
||||
market_candles=market_candles,
|
||||
trend_candles=trend_candles,
|
||||
orderbook_features=orderbook_features,
|
||||
)
|
||||
if entry
|
||||
else []
|
||||
@@ -230,6 +270,7 @@ class TimeSeriesForecaster:
|
||||
expected_gross_return = float(selected.get("expected_gross_return", expected_return))
|
||||
expected_price = closes[-1] * math.exp(expected_gross_return)
|
||||
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)
|
||||
baseline_mae = max(float(selected.get("baseline_mae", model_mae)), model_mae)
|
||||
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
|
||||
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)))
|
||||
min_edge = max(0.0, self.settings.time_series_min_edge_percent)
|
||||
min_edge = calibrated["edge"]
|
||||
confidence_adjustment = _confidence_adjustment(
|
||||
expected_return_percent=expected_return_percent,
|
||||
probability_up=probability_up,
|
||||
@@ -251,21 +292,32 @@ class TimeSeriesForecaster:
|
||||
)
|
||||
conservative_return_percent = min(expected_return_percent, q50_percent)
|
||||
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)
|
||||
)
|
||||
reason = _reason(
|
||||
reason = (
|
||||
_barrier_reason(model, expected_return_percent, probability_up, skill, block_entry)
|
||||
if target_transform == "barrier_net_return"
|
||||
else _reason(
|
||||
model=model,
|
||||
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(
|
||||
enabled=True,
|
||||
usable=True,
|
||||
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_price=round(expected_price, 8),
|
||||
volatility_percent=round(volatility_percent, 4),
|
||||
@@ -282,12 +334,23 @@ class TimeSeriesForecaster:
|
||||
quantile_50_percent=round(q50_percent, 4),
|
||||
quantile_90_percent=round(q90_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,
|
||||
horizon_forecasts=_public_horizon_forecasts(prediction),
|
||||
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
|
||||
quality_gate_passed=quality_gate_passed,
|
||||
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)
|
||||
@@ -307,7 +370,7 @@ class TimeSeriesForecaster:
|
||||
expected_return_percent = (math.exp(expected_return) - 1) * 100
|
||||
probability_up = _normal_cdf(expected_return / max(uncertainty, 1e-9))
|
||||
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(
|
||||
expected_return_percent=expected_return_percent,
|
||||
probability_up=probability_up,
|
||||
@@ -315,7 +378,9 @@ class TimeSeriesForecaster:
|
||||
min_edge=min_edge,
|
||||
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(
|
||||
model=model,
|
||||
expected_return_percent=expected_return_percent,
|
||||
@@ -323,6 +388,8 @@ class TimeSeriesForecaster:
|
||||
skill=skill,
|
||||
block_entry=block_entry,
|
||||
)
|
||||
if not symbol_eligible:
|
||||
reason = "symbol excluded by train-only calibration"
|
||||
return TimeSeriesForecast(
|
||||
enabled=True,
|
||||
usable=True,
|
||||
@@ -350,12 +417,18 @@ class TimeSeriesForecaster:
|
||||
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
|
||||
quality_gate_passed=quality_gate_passed,
|
||||
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]:
|
||||
if not self.settings.time_series_lstm_enabled:
|
||||
return {}
|
||||
path = self.settings.time_series_lstm_model_path
|
||||
path = self.model_path
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
@@ -373,7 +446,7 @@ class TimeSeriesForecaster:
|
||||
return self._lstm_artifact
|
||||
|
||||
def _load_quality_gate(self) -> dict[str, Any]:
|
||||
path = self.settings.time_series_lstm_model_path.parent / "torch_threshold_calibration.json"
|
||||
path = self.calibration_path
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
@@ -386,11 +459,16 @@ class TimeSeriesForecaster:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
validation = data.get("validation") if isinstance(data, dict) else {}
|
||||
self._quality_gate = validation if isinstance(validation, dict) else {}
|
||||
self._quality_gate = data if isinstance(data, dict) else {}
|
||||
self._calibration_mtime = stat.st_mtime
|
||||
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:
|
||||
return TimeSeriesForecast(
|
||||
@@ -420,12 +498,18 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
|
||||
candidates=[],
|
||||
quality_gate_passed=None,
|
||||
quality_gate={},
|
||||
model_created_at="",
|
||||
model_age_hours=None,
|
||||
model_fresh=False,
|
||||
)
|
||||
|
||||
|
||||
def _quality_gate_passed(quality_gate: dict[str, Any]) -> bool | None:
|
||||
if not quality_gate:
|
||||
return None
|
||||
validation = quality_gate.get("validation")
|
||||
if isinstance(validation, dict):
|
||||
return _quality_gate_passed(validation)
|
||||
if "passed" in quality_gate:
|
||||
return bool(quality_gate.get("passed"))
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
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,
|
||||
market_candles: dict[str, list[Candle]] | None = None,
|
||||
trend_candles: list[Candle] | None = None,
|
||||
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
|
||||
) -> list[list[float]]:
|
||||
names = list(feature_names or DEFAULT_TORCH_FEATURES)
|
||||
context = _feature_context(
|
||||
@@ -454,6 +583,7 @@ def _feature_matrix(
|
||||
symbol=symbol,
|
||||
market_candles=market_candles,
|
||||
trend_candles=trend_candles,
|
||||
orderbook_features=orderbook_features,
|
||||
)
|
||||
rows: list[list[float]] = []
|
||||
for index, candle in enumerate(candles):
|
||||
@@ -467,6 +597,7 @@ def _feature_context(
|
||||
symbol: str | None,
|
||||
market_candles: dict[str, list[Candle]] | None,
|
||||
trend_candles: list[Candle] | None,
|
||||
orderbook_features: dict[str, dict[int, dict[str, float]]] | None,
|
||||
) -> dict[str, Any]:
|
||||
market_candles = market_candles or {}
|
||||
normalized_market = {key.upper(): value for key, value in market_candles.items()}
|
||||
@@ -488,12 +619,23 @@ def _feature_context(
|
||||
"context_indexes": context_indexes,
|
||||
"trend_candles": trend_rows,
|
||||
"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:
|
||||
close = max(float(candle.close), 1e-12)
|
||||
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":
|
||||
return _log_change(candle.close, previous.close)
|
||||
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
|
||||
if not isinstance(entry, dict):
|
||||
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 entry
|
||||
|
||||
@@ -959,6 +1106,31 @@ def _torch_recurrent_predict(
|
||||
model_name = _torch_recurrent_model_name(symbol, artifact)
|
||||
if not entry or not model_name:
|
||||
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))
|
||||
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))
|
||||
@@ -1019,8 +1191,76 @@ def _torch_recurrent_predict(
|
||||
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]:
|
||||
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")
|
||||
if isinstance(raw_weight, list) and raw_weight and isinstance(raw_weight[0], list):
|
||||
matrix = _float_matrix(raw_weight)
|
||||
@@ -1091,8 +1331,18 @@ def _decode_multi_horizon_prediction(
|
||||
|
||||
expected = decode("mean")
|
||||
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)
|
||||
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)
|
||||
q10 = _clamp(q_values[0], -cap, cap)
|
||||
q50 = _clamp(q_values[1], -cap, cap)
|
||||
@@ -1116,6 +1366,11 @@ def _decode_multi_horizon_prediction(
|
||||
"q50": q50,
|
||||
"q90": q90,
|
||||
"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,
|
||||
"validation_mae": 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_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
|
||||
|
||||
|
||||
@@ -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))))
|
||||
|
||||
|
||||
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:
|
||||
recent = returns[-120:] if len(returns) > 120 else returns
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
if value >= 40:
|
||||
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}"
|
||||
|
||||
|
||||
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:
|
||||
return 0.5 * (1 + math.erf(value / math.sqrt(2)))
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
@@ -13,13 +17,26 @@ from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
|
||||
ALLOWED_TRAINING_ARTIFACTS = {
|
||||
ACTIVE_TRAINING_ARTIFACTS = {
|
||||
"lstm_forecaster.json",
|
||||
"torch_retrain_guard.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)
|
||||
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:
|
||||
@@ -36,6 +53,61 @@ class TrainingCoordinator:
|
||||
self._save_state(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]:
|
||||
payload = payload or {}
|
||||
with self._lock:
|
||||
@@ -44,7 +116,12 @@ class TrainingCoordinator:
|
||||
existing = self._active_job(state)
|
||||
if existing is not None:
|
||||
self._save_state(state)
|
||||
return {"queued": False, "reason": "active_job_exists", "job": existing, "status": self._public_status(state)}
|
||||
return {
|
||||
"queued": False,
|
||||
"reason": "active_job_exists",
|
||||
"job": self._public_job(existing),
|
||||
"status": self._public_status(state),
|
||||
}
|
||||
|
||||
now = _now()
|
||||
job = {
|
||||
@@ -55,11 +132,16 @@ class TrainingCoordinator:
|
||||
"parameters": _safe_parameters(payload.get("parameters")),
|
||||
"message": "",
|
||||
"artifacts": [],
|
||||
"attempts": 0,
|
||||
}
|
||||
state.setdefault("jobs", []).append(job)
|
||||
self._trim_jobs(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]:
|
||||
payload = payload or {}
|
||||
@@ -83,14 +165,24 @@ class TrainingCoordinator:
|
||||
return {"claimed": False, "job": None, "status": self._public_status(state)}
|
||||
|
||||
now = _now()
|
||||
lease_token = secrets.token_urlsafe(32)
|
||||
job["status"] = "running"
|
||||
job["claimed_at"] = now
|
||||
job["updated_at"] = now
|
||||
job["claimed_by"] = worker["id"]
|
||||
job["worker"] = worker
|
||||
job["lease_token"] = lease_token
|
||||
job["attempts"] = int(job.get("attempts", 0)) + 1
|
||||
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]:
|
||||
job_id = _valid_job_id(job_id)
|
||||
name = Path(str(payload.get("name") or "")).name
|
||||
if name not in ALLOWED_TRAINING_ARTIFACTS:
|
||||
raise ValueError(f"artifact is not allowed: {name}")
|
||||
@@ -99,73 +191,126 @@ class TrainingCoordinator:
|
||||
sha256 = str(payload.get("sha256") or "").strip().lower()
|
||||
if index < 0 or total <= 0 or index >= total:
|
||||
raise ValueError("invalid artifact chunk index")
|
||||
if not sha256:
|
||||
raise ValueError("artifact sha256 is required")
|
||||
if total > MAX_ARTIFACT_CHUNKS:
|
||||
raise ValueError("artifact has too many chunks")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", sha256):
|
||||
raise ValueError("artifact sha256 is invalid")
|
||||
try:
|
||||
chunk = base64.b64decode(str(payload.get("data_base64") or ""), validate=True)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("invalid artifact chunk payload") from exc
|
||||
if not chunk or len(chunk) > MAX_ARTIFACT_CHUNK_BYTES:
|
||||
raise ValueError("artifact chunk size is invalid")
|
||||
|
||||
chunk_dir = self.upload_root / job_id / name
|
||||
with self._lock:
|
||||
state = self._load_state()
|
||||
job = self._job_by_id(state, job_id)
|
||||
if job is None:
|
||||
raise ValueError(f"training job not found: {job_id}")
|
||||
if job.get("status") != "running" or not job.get("claimed_by"):
|
||||
raise ValueError("training job is not claimed and running")
|
||||
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)
|
||||
|
||||
if not all((chunk_dir / f"{part:06d}.part").is_file() for part in range(total)):
|
||||
return {"complete": False, "received": index + 1, "total": total}
|
||||
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}
|
||||
|
||||
target_tmp = self.runtime_dir / f".{name}.{job_id}.tmp"
|
||||
ready_dir = self.upload_root / job_id / "ready"
|
||||
ready_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_tmp = ready_dir / f".{name}.tmp"
|
||||
digest = hashlib.sha256()
|
||||
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")
|
||||
|
||||
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(target_tmp, self.runtime_dir / name)
|
||||
target = ready_dir / name
|
||||
os.replace(target_tmp, target)
|
||||
_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()})
|
||||
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, "name": name, "sha256": sha256}
|
||||
return {"complete": True, "staged": True, "name": name, "sha256": sha256}
|
||||
|
||||
def progress(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
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):
|
||||
state["worker"] = self._worker_from_payload(payload["worker"])
|
||||
job["status"] = str(payload.get("status") or job.get("status") or "running")
|
||||
job["phase"] = str(payload.get("phase") or job.get("phase") or "running")
|
||||
job["message"] = str(payload.get("message") or job.get("message") or "")
|
||||
job["status"] = "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 "")[:2000]
|
||||
job["progress_percent"] = _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
|
||||
job["updated_at"] = _now()
|
||||
if isinstance(payload.get("details"), dict):
|
||||
job["details"] = payload["details"]
|
||||
self._save_state(state)
|
||||
return {"ok": True, "job": job, "status": self._public_status(state)}
|
||||
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]:
|
||||
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)
|
||||
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["phase"] = "completed" if success else "failed"
|
||||
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 "")
|
||||
if isinstance(payload.get("summary"), dict):
|
||||
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)
|
||||
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]:
|
||||
try:
|
||||
@@ -193,10 +462,11 @@ class TrainingCoordinator:
|
||||
os.replace(tmp, self.state_path)
|
||||
|
||||
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 {
|
||||
"id": str(payload.get("worker_id") or payload.get("id") or "windows-training-host"),
|
||||
"name": str(payload.get("name") or "DESKTOP-TMFDL0H"),
|
||||
"path": str(payload.get("path") or "C:\\Repos\\TradeBot"),
|
||||
"id": worker_id,
|
||||
"name": str(payload.get("name") or worker_id).strip(),
|
||||
"path": str(payload.get("path") or "").strip(),
|
||||
"version": str(payload.get("version") or "1"),
|
||||
"last_seen_at": _now(),
|
||||
}
|
||||
@@ -219,11 +489,27 @@ class TrainingCoordinator:
|
||||
"agent_recently_seen": recently_seen,
|
||||
"agent_busy": agent_busy,
|
||||
"worker": worker,
|
||||
"active_job": active,
|
||||
"latest_job": latest,
|
||||
"active_job": self._public_job(active),
|
||||
"latest_job": self._public_job(latest),
|
||||
"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:
|
||||
for job in reversed(state.get("jobs", [])):
|
||||
if job.get("status") in {"pending", "running"}:
|
||||
@@ -247,11 +533,30 @@ class TrainingCoordinator:
|
||||
for job in state.get("jobs", []):
|
||||
if job.get("status") != "running":
|
||||
continue
|
||||
claimed_at = _parse_time(str(job.get("claimed_at") or ""))
|
||||
if claimed_at and now - claimed_at > RUNNING_TIMEOUT:
|
||||
lease_updated_at = _parse_time(
|
||||
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["phase"] = "failed"
|
||||
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:
|
||||
jobs = state.get("jobs", [])
|
||||
@@ -262,8 +567,163 @@ class TrainingCoordinator:
|
||||
def _safe_parameters(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
allowed = {"symbols", "limit", "lookbacks", "architectures", "hidden_sizes", "layers", "dropouts", "epochs"}
|
||||
return {key: value[key] for key in allowed if key in value}
|
||||
allowed = {
|
||||
"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:
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[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);
|
||||
}
|
||||
@@ -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
@@ -6,16 +6,31 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
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:
|
||||
- "127.0.0.1:8787:8787"
|
||||
- "${TRADEBOT_BIND_ADDRESS:-127.0.0.1}:8787:8787"
|
||||
volumes:
|
||||
- ./.env:/app/.env
|
||||
- ./.env:/app/.env:ro
|
||||
- ./runtime:/app/runtime
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=5).read()"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
stop_grace_period: 30s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==9.1.1
|
||||
+4
-5
@@ -1,5 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
requests==2.32.3
|
||||
websockets==14.1
|
||||
pytest==8.4.2
|
||||
fastapi==0.139.0
|
||||
uvicorn[standard]==0.51.0
|
||||
requests==2.34.2
|
||||
websockets==16.1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-1162397
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
BTCUSDT: loaded 2000 60 candles
|
||||
ETHUSDT: loaded 2000 60 candles
|
||||
LTCUSDT: loaded 2000 60 candles
|
||||
SOLUSDT: loaded 2000 60 candles
|
||||
BTCUSDT: replay records 720
|
||||
ETHUSDT: replay records 720
|
||||
SOLUSDT: replay records 720
|
||||
LTCUSDT: replay records 720
|
||||
|
||||
records_by_symbol {"BTCUSDT": 720, "ETHUSDT": 720, "LTCUSDT": 720, "SOLUSDT": 720}
|
||||
artifact {"created_at": "2026-06-23T19:07:54.434411+00:00", "feature_count": 55, "symbols": {"BTCUSDT": {"directional_accuracy": 0.725, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.15903346077183758}, "ETHUSDT": {"directional_accuracy": 0.6916666666666667, "hidden_size": 64, "lookback": 64, "model": "torch_gru", "skill": 0.09273757527902074}, "LTCUSDT": {"directional_accuracy": 0.6583333333333333, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.11954702418314447}, "SOLUSDT": {"directional_accuracy": 0.6416666666666667, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.03400498728351002}}, "target_horizon": 3, "target_horizons": [1, 3, 6, 12], "target_transform": "net_return_over_volatility", "version": 4}
|
||||
|
||||
TOP_RESULTS
|
||||
edge=0.1000 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.1000 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.1000 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.1000 prob=0.6200 conf=0.6000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.1000 prob=0.6200 conf=0.5600 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.1000 prob=0.6200 conf=0.5000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.0800 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.0800 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.0800 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.0800 prob=0.6200 conf=0.6000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.0800 prob=0.6200 conf=0.5600 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.0800 prob=0.6200 conf=0.5000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.0600 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.0600 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
edge=0.0600 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
|
||||
|
||||
RECOMMENDED
|
||||
edge=0.1000 prob=0.6000 conf=0.6800 trades=16 win=0.562 avg=0.4948% total=7.9171% dd=1.8130% pf=3.629 score=0.5817
|
||||
|
||||
FULL_REPLAY
|
||||
trades=5 win=1.000 avg=1.9149% total=9.5746% dd=0.0000% pf=999.000
|
||||
|
||||
WALK_FORWARD
|
||||
{"avg_net_percent": 0.4783, "max_drawdown_percent": 1.3024, "profit_factor": 3.471, "status": "ok", "total_net_percent": 7.1747, "trades": 15, "win_rate": 0.5333, "wins": 8}
|
||||
env TIME_SERIES_MIN_EDGE_PERCENT=0.1000 TIME_SERIES_MIN_PROBABILITY_UP=0.6000 TIME_SERIES_MIN_CONFIDENCE=0.6800
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,515 +0,0 @@
|
||||
{
|
||||
"artifact": {
|
||||
"version": 4,
|
||||
"created_at": "2026-06-23T19:07:54.434411+00:00",
|
||||
"feature_count": 55,
|
||||
"target_horizon": 3,
|
||||
"target_horizons": [
|
||||
1,
|
||||
3,
|
||||
6,
|
||||
12
|
||||
],
|
||||
"target_transform": "net_return_over_volatility",
|
||||
"symbols": {
|
||||
"BTCUSDT": {
|
||||
"model": "torch_gru",
|
||||
"lookback": 64,
|
||||
"hidden_size": 96,
|
||||
"skill": 0.15903346077183758,
|
||||
"directional_accuracy": 0.725
|
||||
},
|
||||
"ETHUSDT": {
|
||||
"model": "torch_gru",
|
||||
"lookback": 64,
|
||||
"hidden_size": 64,
|
||||
"skill": 0.09273757527902074,
|
||||
"directional_accuracy": 0.6916666666666667
|
||||
},
|
||||
"SOLUSDT": {
|
||||
"model": "torch_gru",
|
||||
"lookback": 64,
|
||||
"hidden_size": 96,
|
||||
"skill": 0.03400498728351002,
|
||||
"directional_accuracy": 0.6416666666666667
|
||||
},
|
||||
"LTCUSDT": {
|
||||
"model": "torch_gru",
|
||||
"lookback": 64,
|
||||
"hidden_size": 96,
|
||||
"skill": 0.11954702418314447,
|
||||
"directional_accuracy": 0.6583333333333333
|
||||
}
|
||||
}
|
||||
},
|
||||
"records_by_symbol": {
|
||||
"BTCUSDT": 720,
|
||||
"ETHUSDT": 720,
|
||||
"SOLUSDT": 720,
|
||||
"LTCUSDT": 720
|
||||
},
|
||||
"recommended": {
|
||||
"edge": 0.1,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.72,
|
||||
"trades": 30,
|
||||
"wins": 17,
|
||||
"win_rate": 0.5666666666666667,
|
||||
"total_net_percent": 12.82679871911413,
|
||||
"average_net_percent": 0.42755995730380436,
|
||||
"max_drawdown_percent": 1.812991648733242,
|
||||
"profit_factor": 3.2963631842987433,
|
||||
"score": 0.5882388552951857
|
||||
},
|
||||
"full_replay": {
|
||||
"trades": 8,
|
||||
"wins": 8,
|
||||
"win_rate": 1.0,
|
||||
"total_net_percent": 21.0484,
|
||||
"avg_net_percent": 2.631,
|
||||
"max_drawdown_percent": 0.0,
|
||||
"profit_factor": 999.0,
|
||||
"trades_detail": [
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"entry_timestamp": 1779832800000,
|
||||
"exit_timestamp": 1779868800000,
|
||||
"net_percent": 0.5281,
|
||||
"reason": "forecast_weak_profit_lock",
|
||||
"held_bars": 10,
|
||||
"entry_probability": 0.5747,
|
||||
"entry_expected_percent": 0.3453
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"entry_timestamp": 1779940800000,
|
||||
"exit_timestamp": 1779984000000,
|
||||
"net_percent": 1.3185,
|
||||
"reason": "forecast_weak_profit_lock",
|
||||
"held_bars": 12,
|
||||
"entry_probability": 0.6018,
|
||||
"entry_expected_percent": 0.3155
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"entry_timestamp": 1780300800000,
|
||||
"exit_timestamp": 1780347600000,
|
||||
"net_percent": 0.2591,
|
||||
"reason": "forecast_weak_profit_lock",
|
||||
"held_bars": 13,
|
||||
"entry_probability": 0.6164,
|
||||
"entry_expected_percent": 0.3215
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"entry_timestamp": 1780768800000,
|
||||
"exit_timestamp": 1780855200000,
|
||||
"net_percent": 4.4581,
|
||||
"reason": "max_hold",
|
||||
"held_bars": 24,
|
||||
"entry_probability": 0.5632,
|
||||
"entry_expected_percent": 0.5685
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"entry_timestamp": 1780862400000,
|
||||
"exit_timestamp": 1780869600000,
|
||||
"net_percent": 2.4609,
|
||||
"reason": "forecast_weak_profit_lock",
|
||||
"held_bars": 2,
|
||||
"entry_probability": 0.5368,
|
||||
"entry_expected_percent": 0.4055
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"entry_timestamp": 1781139600000,
|
||||
"exit_timestamp": 1781204400000,
|
||||
"net_percent": 2.0707,
|
||||
"reason": "forecast_weak_profit_lock",
|
||||
"held_bars": 18,
|
||||
"entry_probability": 0.5775,
|
||||
"entry_expected_percent": 0.3013
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"entry_timestamp": 1781445600000,
|
||||
"exit_timestamp": 1781532000000,
|
||||
"net_percent": 9.0305,
|
||||
"reason": "max_hold",
|
||||
"held_bars": 24,
|
||||
"entry_probability": 0.6014,
|
||||
"entry_expected_percent": 0.2946
|
||||
},
|
||||
{
|
||||
"symbol": "ETHUSDT",
|
||||
"entry_timestamp": 1781892000000,
|
||||
"exit_timestamp": 1781942400000,
|
||||
"net_percent": 0.9224,
|
||||
"reason": "forecast_weak_profit_lock",
|
||||
"held_bars": 14,
|
||||
"entry_probability": 0.5966,
|
||||
"entry_expected_percent": 0.2647
|
||||
}
|
||||
]
|
||||
},
|
||||
"walk_forward": {
|
||||
"summary": {
|
||||
"trades": 16,
|
||||
"wins": 8,
|
||||
"win_rate": 0.5,
|
||||
"total_net_percent": 6.8682,
|
||||
"avg_net_percent": 0.4293,
|
||||
"max_drawdown_percent": 1.3024,
|
||||
"profit_factor": 3.1396,
|
||||
"status": "warn"
|
||||
},
|
||||
"folds": [
|
||||
{
|
||||
"fold": 1,
|
||||
"train_records": 720,
|
||||
"test_records": 720,
|
||||
"thresholds": {
|
||||
"edge": 0.1,
|
||||
"probability": 0.6,
|
||||
"confidence": 0.72,
|
||||
"trades": 2,
|
||||
"wins": 1,
|
||||
"win_rate": 0.5,
|
||||
"total_net_percent": -0.10348384852443271,
|
||||
"average_net_percent": -0.051741924262216354,
|
||||
"max_drawdown_percent": 0.5106090484004788,
|
||||
"profit_factor": 0.7973325211360753,
|
||||
"score": -0.0037694524148430344
|
||||
},
|
||||
"test": {
|
||||
"trades": 4,
|
||||
"wins": 2,
|
||||
"win_rate": 0.5,
|
||||
"total_net_percent": 0.0557,
|
||||
"avg_net_percent": 0.0139,
|
||||
"max_drawdown_percent": 1.3024,
|
||||
"profit_factor": 1.0428
|
||||
}
|
||||
},
|
||||
{
|
||||
"fold": 2,
|
||||
"train_records": 1440,
|
||||
"test_records": 720,
|
||||
"thresholds": {
|
||||
"edge": 0.1,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.72,
|
||||
"trades": 18,
|
||||
"wins": 11,
|
||||
"win_rate": 0.6111111111111112,
|
||||
"total_net_percent": 6.01434645293809,
|
||||
"average_net_percent": 0.3341303584965606,
|
||||
"max_drawdown_percent": 1.812991648733242,
|
||||
"profit_factor": 2.6352078385564366,
|
||||
"score": 0.3944002502730791
|
||||
},
|
||||
"test": {
|
||||
"trades": 11,
|
||||
"wins": 6,
|
||||
"win_rate": 0.5455,
|
||||
"total_net_percent": 7.119,
|
||||
"avg_net_percent": 0.6472,
|
||||
"max_drawdown_percent": 1.0894,
|
||||
"profit_factor": 5.4462
|
||||
}
|
||||
},
|
||||
{
|
||||
"fold": 3,
|
||||
"train_records": 2160,
|
||||
"test_records": 720,
|
||||
"thresholds": {
|
||||
"edge": 0.1,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.72,
|
||||
"trades": 29,
|
||||
"wins": 17,
|
||||
"win_rate": 0.5862068965517241,
|
||||
"total_net_percent": 13.13332018590817,
|
||||
"average_net_percent": 0.4528731098589024,
|
||||
"max_drawdown_percent": 1.812991648733242,
|
||||
"profit_factor": 3.48775770371021,
|
||||
"score": 0.6189314390475966
|
||||
},
|
||||
"test": {
|
||||
"trades": 1,
|
||||
"wins": 0,
|
||||
"win_rate": 0.0,
|
||||
"total_net_percent": -0.3065,
|
||||
"avg_net_percent": -0.3065,
|
||||
"max_drawdown_percent": 0.3065,
|
||||
"profit_factor": 0.0
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"probability_calibration": {
|
||||
"samples": 2880,
|
||||
"buckets": [
|
||||
{
|
||||
"bucket": "0.30-0.35",
|
||||
"samples": 38,
|
||||
"avg_probability": 0.336,
|
||||
"actual_win_rate": 0.1053,
|
||||
"avg_future_net_percent": -0.6575
|
||||
},
|
||||
{
|
||||
"bucket": "0.35-0.40",
|
||||
"samples": 418,
|
||||
"avg_probability": 0.3839,
|
||||
"actual_win_rate": 0.2225,
|
||||
"avg_future_net_percent": -0.6359
|
||||
},
|
||||
{
|
||||
"bucket": "0.40-0.45",
|
||||
"samples": 1065,
|
||||
"avg_probability": 0.427,
|
||||
"actual_win_rate": 0.2873,
|
||||
"avg_future_net_percent": -0.4036
|
||||
},
|
||||
{
|
||||
"bucket": "0.45-0.50",
|
||||
"samples": 911,
|
||||
"avg_probability": 0.4746,
|
||||
"actual_win_rate": 0.3271,
|
||||
"avg_future_net_percent": -0.3061
|
||||
},
|
||||
{
|
||||
"bucket": "0.50-0.55",
|
||||
"samples": 290,
|
||||
"avg_probability": 0.5188,
|
||||
"actual_win_rate": 0.3966,
|
||||
"avg_future_net_percent": -0.0583
|
||||
},
|
||||
{
|
||||
"bucket": "0.55-0.60",
|
||||
"samples": 104,
|
||||
"avg_probability": 0.5758,
|
||||
"actual_win_rate": 0.4327,
|
||||
"avg_future_net_percent": 0.0173
|
||||
},
|
||||
{
|
||||
"bucket": "0.60-0.65",
|
||||
"samples": 42,
|
||||
"avg_probability": 0.6138,
|
||||
"actual_win_rate": 0.4762,
|
||||
"avg_future_net_percent": -0.0041
|
||||
},
|
||||
{
|
||||
"bucket": "0.65-0.70",
|
||||
"samples": 6,
|
||||
"avg_probability": 0.6679,
|
||||
"actual_win_rate": 0.3333,
|
||||
"avg_future_net_percent": 0.6587
|
||||
},
|
||||
{
|
||||
"bucket": "0.70-0.75",
|
||||
"samples": 6,
|
||||
"avg_probability": 0.7103,
|
||||
"actual_win_rate": 0.8333,
|
||||
"avg_future_net_percent": 2.1268
|
||||
}
|
||||
]
|
||||
},
|
||||
"top_results": [
|
||||
{
|
||||
"edge": 0.1,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.72,
|
||||
"trades": 30,
|
||||
"wins": 17,
|
||||
"win_rate": 0.5666666666666667,
|
||||
"total_net_percent": 12.82679871911413,
|
||||
"average_net_percent": 0.42755995730380436,
|
||||
"max_drawdown_percent": 1.812991648733242,
|
||||
"profit_factor": 3.2963631842987433,
|
||||
"score": 0.5882388552951857
|
||||
},
|
||||
{
|
||||
"edge": 0.1,
|
||||
"probability": 0.5,
|
||||
"confidence": 0.72,
|
||||
"trades": 30,
|
||||
"wins": 17,
|
||||
"win_rate": 0.5666666666666667,
|
||||
"total_net_percent": 12.82679871911413,
|
||||
"average_net_percent": 0.42755995730380436,
|
||||
"max_drawdown_percent": 1.812991648733242,
|
||||
"profit_factor": 3.2963631842987433,
|
||||
"score": 0.5882388552951857
|
||||
},
|
||||
{
|
||||
"edge": 0.1,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.68,
|
||||
"trades": 38,
|
||||
"wins": 19,
|
||||
"win_rate": 0.5,
|
||||
"total_net_percent": 13.314209250896504,
|
||||
"average_net_percent": 0.35037392765517117,
|
||||
"max_drawdown_percent": 2.2311766078638495,
|
||||
"profit_factor": 2.618335500149353,
|
||||
"score": 0.5031517681827032
|
||||
},
|
||||
{
|
||||
"edge": 0.1,
|
||||
"probability": 0.5,
|
||||
"confidence": 0.68,
|
||||
"trades": 38,
|
||||
"wins": 19,
|
||||
"win_rate": 0.5,
|
||||
"total_net_percent": 13.314209250896504,
|
||||
"average_net_percent": 0.35037392765517117,
|
||||
"max_drawdown_percent": 2.2311766078638495,
|
||||
"profit_factor": 2.618335500149353,
|
||||
"score": 0.5031517681827032
|
||||
},
|
||||
{
|
||||
"edge": 0.08,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.64,
|
||||
"trades": 56,
|
||||
"wins": 28,
|
||||
"win_rate": 0.5,
|
||||
"total_net_percent": 17.433321755380405,
|
||||
"average_net_percent": 0.31130931706036435,
|
||||
"max_drawdown_percent": 3.6509791332801522,
|
||||
"profit_factor": 2.1428339375966368,
|
||||
"score": 0.4832797693926659
|
||||
},
|
||||
{
|
||||
"edge": 0.06,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.68,
|
||||
"trades": 56,
|
||||
"wins": 28,
|
||||
"win_rate": 0.5,
|
||||
"total_net_percent": 17.433321755380405,
|
||||
"average_net_percent": 0.31130931706036435,
|
||||
"max_drawdown_percent": 3.6509791332801522,
|
||||
"profit_factor": 2.1428339375966368,
|
||||
"score": 0.4832797693926659
|
||||
},
|
||||
{
|
||||
"edge": 0.05,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.68,
|
||||
"trades": 60,
|
||||
"wins": 29,
|
||||
"win_rate": 0.48333333333333334,
|
||||
"total_net_percent": 16.9130381260749,
|
||||
"average_net_percent": 0.281883968767915,
|
||||
"max_drawdown_percent": 3.7288680658046136,
|
||||
"profit_factor": 1.9655257883521706,
|
||||
"score": 0.44304683201823336
|
||||
},
|
||||
{
|
||||
"edge": 0.08,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.72,
|
||||
"trades": 38,
|
||||
"wins": 16,
|
||||
"win_rate": 0.42105263157894735,
|
||||
"total_net_percent": 12.356704347142244,
|
||||
"average_net_percent": 0.3251764301879538,
|
||||
"max_drawdown_percent": 2.826650409116027,
|
||||
"profit_factor": 2.4329654894966497,
|
||||
"score": 0.44256958838476457
|
||||
},
|
||||
{
|
||||
"edge": 0.08,
|
||||
"probability": 0.5,
|
||||
"confidence": 0.72,
|
||||
"trades": 38,
|
||||
"wins": 16,
|
||||
"win_rate": 0.42105263157894735,
|
||||
"total_net_percent": 12.356704347142244,
|
||||
"average_net_percent": 0.3251764301879538,
|
||||
"max_drawdown_percent": 2.826650409116027,
|
||||
"profit_factor": 2.4329654894966497,
|
||||
"score": 0.44256958838476457
|
||||
},
|
||||
{
|
||||
"edge": 0.1,
|
||||
"probability": 0.52,
|
||||
"confidence": 0.6,
|
||||
"trades": 59,
|
||||
"wins": 28,
|
||||
"win_rate": 0.4745762711864407,
|
||||
"total_net_percent": 16.704033568694644,
|
||||
"average_net_percent": 0.2831192130287228,
|
||||
"max_drawdown_percent": 3.9030543543860263,
|
||||
"profit_factor": 2.053895519143829,
|
||||
"score": 0.4355711367750193
|
||||
},
|
||||
{
|
||||
"edge": 0.1,
|
||||
"probability": 0.54,
|
||||
"confidence": 0.72,
|
||||
"trades": 29,
|
||||
"wins": 16,
|
||||
"win_rate": 0.5517241379310345,
|
||||
"total_net_percent": 9.266858120167019,
|
||||
"average_net_percent": 0.3195468317298972,
|
||||
"max_drawdown_percent": 1.812991648733242,
|
||||
"profit_factor": 2.659032178431275,
|
||||
"score": 0.41557735852998334
|
||||
},
|
||||
{
|
||||
"edge": 0.1,
|
||||
"probability": 0.55,
|
||||
"confidence": 0.72,
|
||||
"trades": 25,
|
||||
"wins": 14,
|
||||
"win_rate": 0.56,
|
||||
"total_net_percent": 9.16979950649479,
|
||||
"average_net_percent": 0.3667919802597916,
|
||||
"max_drawdown_percent": 1.812991648733242,
|
||||
"profit_factor": 3.155095090386423,
|
||||
"score": 0.41121722668525085
|
||||
},
|
||||
{
|
||||
"edge": 0.08,
|
||||
"probability": 0.5,
|
||||
"confidence": 0.64,
|
||||
"trades": 57,
|
||||
"wins": 28,
|
||||
"win_rate": 0.49122807017543857,
|
||||
"total_net_percent": 15.383105036720245,
|
||||
"average_net_percent": 0.2698790357319341,
|
||||
"max_drawdown_percent": 3.6509791332801522,
|
||||
"profit_factor": 1.8889561886320847,
|
||||
"score": 0.4107453600913507
|
||||
},
|
||||
{
|
||||
"edge": 0.06,
|
||||
"probability": 0.5,
|
||||
"confidence": 0.68,
|
||||
"trades": 57,
|
||||
"wins": 28,
|
||||
"win_rate": 0.49122807017543857,
|
||||
"total_net_percent": 15.383105036720245,
|
||||
"average_net_percent": 0.2698790357319341,
|
||||
"max_drawdown_percent": 3.6509791332801522,
|
||||
"profit_factor": 1.8889561886320847,
|
||||
"score": 0.4107453600913507
|
||||
},
|
||||
{
|
||||
"edge": 0.1,
|
||||
"probability": 0.55,
|
||||
"confidence": 0.68,
|
||||
"trades": 32,
|
||||
"wins": 16,
|
||||
"win_rate": 0.5,
|
||||
"total_net_percent": 9.83610967545454,
|
||||
"average_net_percent": 0.3073784273579544,
|
||||
"max_drawdown_percent": 2.2311766078638495,
|
||||
"profit_factor": 2.5019571623752666,
|
||||
"score": 0.40798477425385704
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
BTCUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.47001% baseline=0.55889% skill=0.1590 dir=0.725 p_brier=0.2443
|
||||
ETHUSDT: model=torch_gru lookback=64 features=55 hidden=64 layers=2 horizons=1,3,6,12 mae=0.63328% baseline=0.69801% skill=0.0927 dir=0.692 p_brier=0.2239
|
||||
SOLUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.85491% baseline=0.88500% skill=0.0340 dir=0.642 p_brier=0.2308
|
||||
LTCUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.57185% baseline=0.64949% skill=0.1195 dir=0.658 p_brier=0.2369
|
||||
saved G:\Repos\TradeBot\runtime\lstm_forecaster.candidate.json
|
||||
Binary file not shown.
@@ -89,11 +89,13 @@ def make_settings():
|
||||
time_series_probe_min_probability_up=0.55,
|
||||
time_series_probe_size_multiplier=0.40,
|
||||
time_series_rebound_fallback_enabled=True,
|
||||
time_series_trend_fallback_enabled=False,
|
||||
stop_loss_percent=0.02,
|
||||
stop_loss_exit_enabled=True,
|
||||
take_profit_percent=0.035,
|
||||
trailing_stop_percent=0.015,
|
||||
min_hold_seconds=180,
|
||||
min_exit_net_percent=0.20,
|
||||
entry_cooldown_seconds=180,
|
||||
max_daily_drawdown_usdt=6.0,
|
||||
min_cash_reserve_usdt=5.0,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from crypto_spot_bot.auth import ApiAuthorizer
|
||||
|
||||
|
||||
def _request(**headers: str) -> Request:
|
||||
encoded = [(key.lower().encode(), value.encode()) for key, value in headers.items()]
|
||||
return Request({"type": "http", "method": "GET", "path": "/", "headers": encoded})
|
||||
|
||||
|
||||
def test_api_authorizer_accepts_direct_basic_token(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, api_auth_token="user:secret")
|
||||
auth = ApiAuthorizer(settings)
|
||||
basic = base64.b64encode(b"user:secret").decode("ascii")
|
||||
|
||||
asyncio.run(auth.require(_request(Authorization=f"Basic {basic}")))
|
||||
|
||||
|
||||
def test_api_authorizer_accepts_trusted_proxy_header(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, trusted_proxy_user_header="X-TradeBot-Proxy-User")
|
||||
auth = ApiAuthorizer(settings)
|
||||
|
||||
asyncio.run(auth.require(_request(**{"X-TradeBot-Proxy-User": "sevenhill"})))
|
||||
|
||||
|
||||
def test_api_authorizer_rejects_missing_credentials(make_settings, tmp_path) -> None:
|
||||
auth = ApiAuthorizer(make_settings(tmp_path))
|
||||
|
||||
with pytest.raises(HTTPException) as raised:
|
||||
asyncio.run(auth.require(_request()))
|
||||
|
||||
assert raised.value.status_code == 401
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
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:
|
||||
payload = websocket_subscribe_message(["BTCUSDT"], interval="60")
|
||||
|
||||
assert "kline.60.BTCUSDT" 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)
|
||||
|
||||
@@ -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
@@ -78,7 +78,7 @@ def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None:
|
||||
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("TOP_SYMBOLS_COUNT", 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)
|
||||
|
||||
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.symbols == FIXED_SPOT_SYMBOLS
|
||||
assert settings.symbols == ()
|
||||
assert settings.strategy_mode == "torch_forecast"
|
||||
assert settings.base_interval == "60"
|
||||
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.top_symbols_count == 12
|
||||
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
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
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 _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
|
||||
|
||||
|
||||
@@ -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_size_multiplier"] == 0.40
|
||||
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"] == {
|
||||
"available": True,
|
||||
"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:
|
||||
assert "Web UI removed" in WEB_UI_REMOVED_MESSAGE
|
||||
assert "/api/*" in WEB_UI_REMOVED_MESSAGE
|
||||
def test_web_ui_assets_are_available() -> None:
|
||||
html = WEB_INDEX.read_text(encoding="utf-8")
|
||||
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
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
||||
|
||||
from crypto_spot_bot.bybit import Instrument
|
||||
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.storage import Storage
|
||||
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["symbol"] == "HYPEUSDT"
|
||||
assert "trend_macd" in trade["reason"]
|
||||
|
||||
|
||||
def test_live_broker_records_exchange_fill_and_protective_stop(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
trading_mode="live",
|
||||
enable_live_trading=True,
|
||||
live_trading_confirm="I_ACCEPT_REAL_RISK",
|
||||
bybit_api_key="key",
|
||||
bybit_api_secret="secret",
|
||||
live_protective_stop_enabled=True,
|
||||
)
|
||||
storage = Storage(settings.database_path)
|
||||
|
||||
class Client:
|
||||
def place_spot_market_order(self, **kwargs):
|
||||
return {"orderId": "buy-1"}
|
||||
|
||||
def wait_for_spot_order(self, **kwargs):
|
||||
return {
|
||||
"order": {"orderStatus": "Filled"},
|
||||
"executions": [
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"execQty": "0.001",
|
||||
"execValue": "10",
|
||||
"execPrice": "10000",
|
||||
"execFee": "0.01",
|
||||
"feeCurrency": "USDT",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def place_spot_protective_stop(self, **kwargs):
|
||||
return {"orderId": "stop-1"}
|
||||
|
||||
broker = LiveBroker(settings, storage, Client())
|
||||
broker.reconciliation_state = {"status": "ok", "blocking": False, "discrepancies": []}
|
||||
ticker = Ticker("BTCUSDT", 10000, 9999, 10001, 10_000_000, 1000, 0)
|
||||
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 5)
|
||||
signal = Signal("BTCUSDT", "BUY", 0.8, "test", {"position_notional_usdt": 10})
|
||||
|
||||
position = broker.buy(signal, ticker, instrument, {"BTCUSDT": 10000})
|
||||
|
||||
assert position is not None
|
||||
assert position.qty == 0.001
|
||||
assert position.entry_price == 10000
|
||||
assert position.protective_order_id == "stop-1"
|
||||
assert storage.recent_orders()[0]["order_kind"] == "PROTECTIVE_STOP"
|
||||
|
||||
|
||||
def test_live_broker_sell_uses_confirmed_exchange_fill(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
trading_mode="live",
|
||||
enable_live_trading=True,
|
||||
live_trading_confirm="I_ACCEPT_REAL_RISK",
|
||||
bybit_api_key="key",
|
||||
bybit_api_secret="secret",
|
||||
live_protective_stop_enabled=False,
|
||||
)
|
||||
storage = Storage(settings.database_path)
|
||||
|
||||
class Client:
|
||||
def place_spot_market_order(self, **kwargs):
|
||||
return {"orderId": "buy-1" if kwargs["side"] == "Buy" else "sell-1"}
|
||||
|
||||
def wait_for_spot_order(self, **kwargs):
|
||||
buying = kwargs["order_id"] == "buy-1"
|
||||
return {
|
||||
"order": {"orderStatus": "Filled"},
|
||||
"executions": [
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"execQty": "0.001",
|
||||
"execValue": "10" if buying else "11",
|
||||
"execPrice": "10000" if buying else "11000",
|
||||
"execFee": "0.01",
|
||||
"feeCurrency": "USDT",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
broker = LiveBroker(settings, storage, Client())
|
||||
broker.reconciliation_state = {"status": "ok", "blocking": False, "discrepancies": []}
|
||||
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 5)
|
||||
entry_ticker = Ticker("BTCUSDT", 10000, 9999, 10001, 10_000_000, 1000, 0)
|
||||
position = broker.buy(
|
||||
Signal("BTCUSDT", "BUY", 0.8, "test", {"position_notional_usdt": 10}),
|
||||
entry_ticker,
|
||||
instrument,
|
||||
{"BTCUSDT": 10000},
|
||||
)
|
||||
assert position is not None
|
||||
|
||||
trade = broker.sell(
|
||||
position,
|
||||
Ticker("BTCUSDT", 11000, 10999, 11001, 10_000_000, 1000, 0),
|
||||
"test exit",
|
||||
)
|
||||
|
||||
assert trade.exit_price == 11000
|
||||
assert trade.qty == 0.001
|
||||
assert broker.open_positions() == []
|
||||
assert storage.recent_orders()[0]["status"] == "Filled"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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.storage import Storage
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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": "",
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_retrain_runner_passes_training_horizon_to_calibrator() -> None:
|
||||
runner = (
|
||||
Path(__file__).resolve().parents[1] / "tools" / "run_torch_retrain.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
calibration_start = runner.index("$calibrationBaseArgs = @(")
|
||||
calibration_end = runner.index("\n )", calibration_start)
|
||||
calibration_args = runner[calibration_start:calibration_end]
|
||||
|
||||
assert '"--horizon", $Horizon.ToString()' in calibration_args
|
||||
|
||||
|
||||
def test_retrain_runner_uses_a_regime_sized_validation_window() -> None:
|
||||
runner = (
|
||||
Path(__file__).resolve().parents[1] / "tools" / "run_torch_retrain.ps1"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "[int]$ValidationWindow = 0" in runner
|
||||
assert "else { 720 }" in runner
|
||||
assert '"--validation-window", $ValidationWindow.ToString()' in runner
|
||||
@@ -0,0 +1,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
@@ -2,9 +2,85 @@ from __future__ import annotations
|
||||
|
||||
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.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]:
|
||||
@@ -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
|
||||
|
||||
|
||||
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:
|
||||
settings = make_settings(
|
||||
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
|
||||
|
||||
|
||||
def test_torch_forecast_holds_atr_trailing_exit_below_min_profit(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20)
|
||||
strategy = SpotStrategy(settings)
|
||||
candles = _trend_entry_candles(close=100.35)
|
||||
candles[-1].atr_14 = 0.6
|
||||
position = Position(
|
||||
1,
|
||||
"MNTUSDT",
|
||||
1,
|
||||
100,
|
||||
100,
|
||||
0.1,
|
||||
96,
|
||||
120,
|
||||
102,
|
||||
opened_at=utc_now() - timedelta(seconds=600),
|
||||
)
|
||||
ticker = Ticker("MNTUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0)
|
||||
|
||||
signal = strategy.exit_signal(
|
||||
position,
|
||||
candles,
|
||||
ticker,
|
||||
forecast={
|
||||
"usable": True,
|
||||
"model": "torch_lstm",
|
||||
"expected_return_percent": 0.4,
|
||||
"probability_up": 0.58,
|
||||
"skill": 0.18,
|
||||
"block_entry": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert signal.action == "HOLD"
|
||||
assert signal.diagnostics["atr_exit_blocked_by_min_profit"] is True
|
||||
assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent
|
||||
|
||||
|
||||
def test_torch_forecast_holds_negative_forecast_exit_below_min_profit(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20)
|
||||
strategy = SpotStrategy(settings)
|
||||
position = Position(
|
||||
1,
|
||||
"BTCUSDT",
|
||||
1,
|
||||
100,
|
||||
100,
|
||||
0.1,
|
||||
96,
|
||||
120,
|
||||
100.5,
|
||||
opened_at=utc_now() - timedelta(seconds=600),
|
||||
)
|
||||
ticker = Ticker("BTCUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0)
|
||||
|
||||
signal = strategy.exit_signal(
|
||||
position,
|
||||
_trend_entry_candles(close=100.35),
|
||||
ticker,
|
||||
forecast={
|
||||
"usable": True,
|
||||
"model": "torch_lstm",
|
||||
"expected_return_percent": -0.2,
|
||||
"probability_up": 0.40,
|
||||
"skill": 0.18,
|
||||
"block_entry": False,
|
||||
"reason": "model turned down",
|
||||
},
|
||||
)
|
||||
|
||||
assert signal.action == "HOLD"
|
||||
assert signal.diagnostics["forecast_exit_blocked_by_min_profit"] is True
|
||||
assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent
|
||||
|
||||
|
||||
def test_torch_forecast_rebound_fallback_holds_without_model(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
|
||||
strategy = SpotStrategy(settings)
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from crypto_spot_bot.models import Candle
|
||||
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:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
@@ -304,6 +377,86 @@ def test_time_series_forecaster_attaches_quality_gate(make_settings, tmp_path) -
|
||||
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:
|
||||
artifact_path = tmp_path / "lstm_forecaster.json"
|
||||
_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]["raw_display"].endswith("%")
|
||||
assert "диапазон" in forecast.feature_snapshot[0]["interpretation"]
|
||||
|
||||
|
||||
def test_time_series_forecaster_reads_barrier_multitask_artifact(make_settings, tmp_path) -> None:
|
||||
artifact_path = tmp_path / "lstm_forecaster.json"
|
||||
_write_barrier_multitask_gru_artifact(artifact_path)
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
time_series_lstm_model_path=artifact_path,
|
||||
time_series_min_candles=80,
|
||||
time_series_forecast_horizon=3,
|
||||
)
|
||||
|
||||
forecast = TimeSeriesForecaster(settings).forecast(
|
||||
_candles_from_returns([0.0002] * 140), symbol="BTCUSDT"
|
||||
)
|
||||
|
||||
assert forecast.usable is True
|
||||
assert forecast.target_transform == "barrier_net_return"
|
||||
assert forecast.expected_return_percent == pytest.approx(1.005, abs=0.01)
|
||||
assert forecast.probability_take_profit_first == pytest.approx(0.8, abs=0.001)
|
||||
assert "P(TP before SL)" in forecast.reason
|
||||
|
||||
@@ -14,7 +14,11 @@ def _report(*, validation_passed: bool = True, trades: int = 30, total: float =
|
||||
"max_drawdown_percent": 1.0,
|
||||
},
|
||||
"walk_forward": {"summary": {"trades": trades, "avg_net_percent": 0.3}},
|
||||
"validation": {"passed": validation_passed, "status": "pass" if validation_passed else "fail"},
|
||||
"validation": {
|
||||
"passed": validation_passed,
|
||||
"status": "pass" if validation_passed else "fail",
|
||||
"protocol": "untouched_model_holdout_with_threshold_walk_forward",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -3,8 +3,11 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
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:
|
||||
@@ -14,6 +17,7 @@ def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
|
||||
job_id = requested["job"]["id"]
|
||||
heartbeat = coordinator.heartbeat({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
|
||||
claimed = coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
|
||||
lease_token = claimed["lease_token"]
|
||||
|
||||
assert requested["queued"] 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(
|
||||
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"]["progress_percent"] == 42
|
||||
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 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:
|
||||
coordinator = TrainingCoordinator(tmp_path)
|
||||
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'
|
||||
sha256 = hashlib.sha256(payload).hexdigest()
|
||||
first = payload[:20]
|
||||
@@ -52,6 +150,7 @@ def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
|
||||
"total": 2,
|
||||
"sha256": sha256,
|
||||
"data_base64": base64.b64encode(first).decode("ascii"),
|
||||
"lease_token": lease_token,
|
||||
},
|
||||
)
|
||||
part_2 = coordinator.save_artifact_chunk(
|
||||
@@ -62,15 +161,41 @@ def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
|
||||
"total": 2,
|
||||
"sha256": sha256,
|
||||
"data_base64": base64.b64encode(second).decode("ascii"),
|
||||
"lease_token": lease_token,
|
||||
},
|
||||
)
|
||||
|
||||
assert part_1["complete"] is False
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
coordinator = TrainingCoordinator(tmp_path)
|
||||
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_busy"] 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"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
@@ -11,6 +12,11 @@ def main() -> None:
|
||||
args = _parse_args()
|
||||
current = _read_json(args.current_report)
|
||||
candidate = _read_json(args.candidate_report)
|
||||
candidate_artifact = Path(args.candidate_artifact)
|
||||
candidate_sha256 = _sha256(candidate_artifact)
|
||||
if candidate.get("artifact_sha256") != candidate_sha256:
|
||||
decision = {"accepted": False, "reason": "candidate_report_artifact_hash_mismatch"}
|
||||
else:
|
||||
decision = _decision(
|
||||
current,
|
||||
candidate,
|
||||
@@ -24,6 +30,7 @@ def main() -> None:
|
||||
"reason": decision["reason"],
|
||||
"current": _summary(current),
|
||||
"candidate": _summary(candidate),
|
||||
"candidate_artifact_sha256": candidate_sha256,
|
||||
}
|
||||
if args.report:
|
||||
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"]:
|
||||
raise SystemExit(2)
|
||||
target = Path(args.target_artifact)
|
||||
candidate_artifact = Path(args.candidate_artifact)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(candidate_artifact, target)
|
||||
|
||||
@@ -83,6 +89,8 @@ def _validation_passed(report: dict[str, Any]) -> bool:
|
||||
validation = report.get("validation")
|
||||
if not isinstance(validation, dict):
|
||||
return False
|
||||
if validation.get("protocol") != "untouched_model_holdout_with_threshold_walk_forward":
|
||||
return False
|
||||
if "passed" in validation:
|
||||
return bool(validation.get("passed"))
|
||||
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 {}
|
||||
|
||||
|
||||
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__":
|
||||
main()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
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.indicators import add_indicators
|
||||
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 (
|
||||
DEFAULT_TORCH_FEATURES,
|
||||
_barrier_outcome,
|
||||
_current_volatility_scale,
|
||||
_entry_horizon,
|
||||
_entry_output_layout,
|
||||
@@ -49,6 +52,10 @@ class ForecastRecord:
|
||||
index: int
|
||||
timestamp: int
|
||||
close: float
|
||||
high: float
|
||||
low: float
|
||||
next_open: float
|
||||
next_timestamp: int
|
||||
atr: float
|
||||
expected_percent: float
|
||||
probability_up: float
|
||||
@@ -59,6 +66,7 @@ class ForecastRecord:
|
||||
future_net_percent: float
|
||||
benchmark_entry: bool
|
||||
benchmark_exit: bool
|
||||
take_profit_first: bool | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -81,13 +89,23 @@ def main() -> None:
|
||||
if torch is not None and args.threads > 0:
|
||||
torch.set_num_threads(args.threads)
|
||||
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 = 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
|
||||
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]] = {}
|
||||
for symbol in context_symbols:
|
||||
@@ -111,10 +129,12 @@ def main() -> None:
|
||||
trend_candles=trend_candles,
|
||||
artifact=artifact,
|
||||
horizon=horizon,
|
||||
horizon_is_explicit=args.horizon > 0,
|
||||
round_trip_cost=round_trip_cost,
|
||||
min_candles=max(30, settings.time_series_min_candles),
|
||||
calibration_window=args.calibration_window,
|
||||
batch_size=args.batch_size,
|
||||
orderbook_features=orderbook_features,
|
||||
)
|
||||
records.extend(symbol_records)
|
||||
per_symbol_counts[symbol] = len(symbol_records)
|
||||
@@ -123,13 +143,15 @@ def main() -> None:
|
||||
if not records:
|
||||
raise SystemExit("No forecast records could be built for calibration.")
|
||||
|
||||
results = _calibrate(
|
||||
results = _calibrate_strategy(
|
||||
records,
|
||||
edges=_float_grid(args.edge_grid),
|
||||
probabilities=_float_grid(args.probability_grid),
|
||||
confidences=_float_grid(args.confidence_grid),
|
||||
min_trades=args.min_trades,
|
||||
horizon=horizon,
|
||||
round_trip_cost=round_trip_cost,
|
||||
settings=settings,
|
||||
)
|
||||
if not results:
|
||||
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,
|
||||
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(_result_line(recommended))
|
||||
print("\nFULL_REPLAY")
|
||||
@@ -181,6 +242,16 @@ def main() -> None:
|
||||
min_profit_factor=args.min_oos_profit_factor,
|
||||
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(json.dumps(walk_forward["summary"], ensure_ascii=False, sort_keys=True))
|
||||
print("\nBENCHMARK")
|
||||
@@ -196,9 +267,13 @@ def main() -> None:
|
||||
|
||||
if args.output:
|
||||
payload = {
|
||||
"artifact_sha256": artifact_sha256,
|
||||
"artifact": _artifact_summary(artifact),
|
||||
"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,
|
||||
"walk_forward": walk_forward,
|
||||
"benchmark": benchmark,
|
||||
@@ -213,7 +288,11 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Calibrate TradeBot Torch forecast entry thresholds.")
|
||||
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("--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("--limit", type=int, default=2000, help="Hourly 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-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("--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()
|
||||
|
||||
|
||||
@@ -244,6 +325,32 @@ def _symbols(raw: str, fallback: tuple[str, ...] | list[str]) -> list[str]:
|
||||
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(
|
||||
*,
|
||||
symbol: str,
|
||||
@@ -252,10 +359,12 @@ def _forecast_records(
|
||||
trend_candles: list[Candle],
|
||||
artifact: dict[str, Any],
|
||||
horizon: int,
|
||||
horizon_is_explicit: bool,
|
||||
round_trip_cost: float,
|
||||
min_candles: int,
|
||||
calibration_window: int,
|
||||
batch_size: int,
|
||||
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
|
||||
) -> list[ForecastRecord]:
|
||||
entry = _torch_recurrent_entry(symbol, artifact)
|
||||
model = _torch_recurrent_model_name(symbol, artifact)
|
||||
@@ -268,13 +377,31 @@ def _forecast_records(
|
||||
symbol=symbol,
|
||||
market_candles=market_candles,
|
||||
trend_candles=trend_candles,
|
||||
orderbook_features=orderbook_features,
|
||||
)
|
||||
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))))
|
||||
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:
|
||||
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(
|
||||
symbol=symbol,
|
||||
candles=candles,
|
||||
@@ -288,13 +415,18 @@ def _forecast_records(
|
||||
start=start,
|
||||
end=end,
|
||||
batch_size=batch_size,
|
||||
valid_indices=valid_indices,
|
||||
)
|
||||
if batched_records is not None:
|
||||
return batched_records
|
||||
|
||||
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)):
|
||||
if index not in valid_indices:
|
||||
continue
|
||||
prediction = _torch_recurrent_predict(
|
||||
_log_returns(closes[: index + 1]),
|
||||
symbol,
|
||||
@@ -313,7 +445,25 @@ def _forecast_records(
|
||||
q50 = float(selected.get("q50", expected_return))
|
||||
expected_percent = (math.exp(expected_return) - 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
|
||||
records.append(
|
||||
ForecastRecord(
|
||||
@@ -321,6 +471,10 @@ def _forecast_records(
|
||||
index=index,
|
||||
timestamp=candles[index].timestamp,
|
||||
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),
|
||||
expected_percent=expected_percent,
|
||||
probability_up=probability_up,
|
||||
@@ -331,6 +485,7 @@ def _forecast_records(
|
||||
future_net_percent=future_net_percent,
|
||||
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
||||
benchmark_exit=_benchmark_exit_signal(candles, index),
|
||||
take_profit_first=take_profit_first,
|
||||
)
|
||||
)
|
||||
return records
|
||||
@@ -350,14 +505,15 @@ def _batch_forecast_records(
|
||||
start: int,
|
||||
end: int,
|
||||
batch_size: int,
|
||||
valid_indices: set[int] | None = None,
|
||||
) -> list[ForecastRecord] | None:
|
||||
if torch is None or RecurrentReturnModel is None:
|
||||
return None
|
||||
horizons = _entry_target_horizons(entry)
|
||||
if not horizons:
|
||||
return None
|
||||
model = _build_torch_model(entry, model_name)
|
||||
if model is None:
|
||||
models = _build_torch_models(entry, model_name)
|
||||
if not models:
|
||||
return None
|
||||
|
||||
lookback = int(_clamp(_float_entry(entry, "lookback", 64.0), 4.0, 512.0))
|
||||
@@ -368,13 +524,16 @@ def _batch_forecast_records(
|
||||
indices = [
|
||||
index
|
||||
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:
|
||||
return []
|
||||
|
||||
records: list[ForecastRecord] = []
|
||||
skill = float(entry.get("skill", 0.0) or 0.0)
|
||||
skill = _entry_validation_skill(entry)
|
||||
for model in models:
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
for offset in range(0, len(indices), max(1, batch_size)):
|
||||
@@ -390,10 +549,15 @@ def _batch_forecast_records(
|
||||
for index in batch_indices
|
||||
]
|
||||
batch = torch.tensor(windows, dtype=torch.float32)
|
||||
outputs = model(batch).detach().cpu().tolist()
|
||||
for index, output in zip(batch_indices, outputs):
|
||||
selected = _decode_selected_output(
|
||||
output,
|
||||
outputs_by_model = [model(batch).detach().cpu().tolist() for model in models]
|
||||
for batch_offset, index in enumerate(batch_indices):
|
||||
selected = _average_selected_predictions(
|
||||
[
|
||||
decoded
|
||||
for outputs in outputs_by_model
|
||||
if (
|
||||
decoded := _decode_selected_output(
|
||||
outputs[batch_offset],
|
||||
entry=entry,
|
||||
candles=candles,
|
||||
closes=closes,
|
||||
@@ -402,6 +566,9 @@ def _batch_forecast_records(
|
||||
clip=clip,
|
||||
round_trip_cost=round_trip_cost,
|
||||
)
|
||||
) is not None
|
||||
]
|
||||
)
|
||||
if selected is None:
|
||||
continue
|
||||
expected_return = float(selected["expected_return"])
|
||||
@@ -409,7 +576,25 @@ def _batch_forecast_records(
|
||||
q50 = float(selected["q50"])
|
||||
expected_percent = (math.exp(expected_return) - 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
|
||||
records.append(
|
||||
ForecastRecord(
|
||||
@@ -417,6 +602,10 @@ def _batch_forecast_records(
|
||||
index=index,
|
||||
timestamp=candles[index].timestamp,
|
||||
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),
|
||||
expected_percent=expected_percent,
|
||||
probability_up=probability_up,
|
||||
@@ -427,11 +616,26 @@ def _batch_forecast_records(
|
||||
future_net_percent=future_net_percent,
|
||||
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
||||
benchmark_exit=_benchmark_exit_signal(candles, index),
|
||||
take_profit_first=take_profit_first,
|
||||
)
|
||||
)
|
||||
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:
|
||||
if torch is None or RecurrentReturnModel is None:
|
||||
return None
|
||||
@@ -451,6 +655,10 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
||||
output_size=output_size,
|
||||
attention_pooling=bool(entry.get("attention_pooling")),
|
||||
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")
|
||||
if not isinstance(raw_state, dict):
|
||||
@@ -460,6 +668,20 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
||||
for key, value in raw_state.items()
|
||||
if isinstance(value, list)
|
||||
}
|
||||
if bool(entry.get("multitask_head")):
|
||||
for artifact_name, state_name in (
|
||||
("head_hidden_weight", "head_hidden.weight"),
|
||||
("head_hidden_bias", "head_hidden.bias"),
|
||||
("return_head_weight", "return_head.weight"),
|
||||
("return_head_bias", "return_head.bias"),
|
||||
("event_head_weight", "event_head.weight"),
|
||||
("event_head_bias", "event_head.bias"),
|
||||
):
|
||||
value = entry.get(artifact_name)
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
state[state_name] = torch.tensor(value, dtype=torch.float32)
|
||||
else:
|
||||
head_weight = entry.get("head_weight")
|
||||
head_bias = entry.get("head_bias")
|
||||
if not isinstance(head_weight, list) or not isinstance(head_bias, list):
|
||||
@@ -486,6 +708,15 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
||||
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(
|
||||
output: list[float],
|
||||
*,
|
||||
@@ -524,10 +755,20 @@ def _decode_selected_output(
|
||||
expected = decode("mean")
|
||||
q_values = sorted([decode("q10", expected), decode("q50", expected), decode("q90", expected)])
|
||||
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 {
|
||||
"expected_return": _clamp(expected, -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,
|
||||
settings: Any,
|
||||
detail_limit: int = 50,
|
||||
symbol_thresholds: dict[str, CalibrationResult] | None = None,
|
||||
require_symbol_thresholds: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
positions: dict[str, dict[str, Any]] = {}
|
||||
trades: list[float] = []
|
||||
rows: list[dict[str, Any]] = []
|
||||
max_hold = max(12, horizon * 8)
|
||||
stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
|
||||
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))
|
||||
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)):
|
||||
active_thresholds = (symbol_thresholds or {}).get(record.symbol, thresholds)
|
||||
position = positions.get(record.symbol)
|
||||
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)
|
||||
held = record.index - int(position["entry_index"])
|
||||
atr_stop_level = (
|
||||
@@ -584,26 +829,35 @@ def _full_backtest(
|
||||
)
|
||||
atr_stop = bool(
|
||||
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"])
|
||||
)
|
||||
weak_forecast = (
|
||||
record.expected_percent < thresholds.edge
|
||||
or record.probability_up < thresholds.probability
|
||||
record.expected_percent < active_thresholds.edge
|
||||
or record.probability_up < active_thresholds.probability
|
||||
or record.skill <= 0.0
|
||||
)
|
||||
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_price = stop_level
|
||||
elif record.high >= take_level:
|
||||
exit_reason = "take_profit"
|
||||
exit_price = take_level
|
||||
elif atr_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"
|
||||
elif weak_forecast and net_percent >= 0:
|
||||
exit_reason = "forecast_weak_profit_lock"
|
||||
elif held >= max_hold:
|
||||
exit_reason = "max_hold"
|
||||
if exit_reason:
|
||||
net_percent = _net_percent(position["entry_price"], exit_price, round_trip_cost)
|
||||
trades.append(net_percent)
|
||||
rows.append(
|
||||
{
|
||||
@@ -622,12 +876,14 @@ def _full_backtest(
|
||||
|
||||
if record.symbol in positions:
|
||||
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] = {
|
||||
"entry_price": record.close,
|
||||
"entry_index": record.index,
|
||||
"timestamp": record.timestamp,
|
||||
"highest": record.close,
|
||||
"entry_price": record.next_open,
|
||||
"entry_index": record.index + 1,
|
||||
"timestamp": record.next_timestamp,
|
||||
"highest": record.next_open,
|
||||
"probability_up": record.probability_up,
|
||||
"expected_percent": record.expected_percent,
|
||||
}
|
||||
@@ -669,12 +925,13 @@ def _benchmark_backtest(
|
||||
rows: list[dict[str, Any]] = []
|
||||
max_hold = max(12, horizon * 8)
|
||||
stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
|
||||
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))
|
||||
atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier)))
|
||||
for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)):
|
||||
position = positions.get(record.symbol)
|
||||
if position is not None:
|
||||
position["highest"] = max(position["highest"], record.close)
|
||||
position["highest"] = max(position["highest"], record.high)
|
||||
net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost)
|
||||
held = record.index - int(position["entry_index"])
|
||||
atr_stop_level = (
|
||||
@@ -684,19 +941,28 @@ def _benchmark_backtest(
|
||||
)
|
||||
atr_stop = bool(
|
||||
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"])
|
||||
)
|
||||
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_price = stop_level
|
||||
elif record.high >= take_level:
|
||||
exit_reason = "take_profit"
|
||||
exit_price = take_level
|
||||
elif atr_stop:
|
||||
exit_reason = "atr_trailing_stop"
|
||||
exit_price = float(atr_stop_level)
|
||||
elif record.benchmark_exit:
|
||||
exit_reason = "benchmark_exit"
|
||||
elif held >= max_hold:
|
||||
exit_reason = "max_hold"
|
||||
if exit_reason:
|
||||
net_percent = _net_percent(position["entry_price"], exit_price, round_trip_cost)
|
||||
trades.append(net_percent)
|
||||
rows.append(
|
||||
{
|
||||
@@ -715,10 +981,10 @@ def _benchmark_backtest(
|
||||
continue
|
||||
if record.benchmark_entry:
|
||||
positions[record.symbol] = {
|
||||
"entry_price": record.close,
|
||||
"entry_index": record.index,
|
||||
"timestamp": record.timestamp,
|
||||
"highest": record.close,
|
||||
"entry_price": record.next_open,
|
||||
"entry_index": record.index + 1,
|
||||
"timestamp": record.next_timestamp,
|
||||
"highest": record.next_open,
|
||||
}
|
||||
for symbol, position in list(positions.items()):
|
||||
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]
|
||||
train = [record for record in ordered if record.timestamp < test_start]
|
||||
test = [record for record in ordered if test_start <= record.timestamp <= test_end]
|
||||
train_results = _calibrate(
|
||||
train,
|
||||
probability_calibration = _fit_platt_calibration(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,
|
||||
probabilities=probabilities,
|
||||
confidences=confidences,
|
||||
min_trades=max(4, min_trades // 2),
|
||||
horizon=horizon,
|
||||
round_trip_cost=round_trip_cost,
|
||||
settings=settings,
|
||||
)
|
||||
if not train_results:
|
||||
continue
|
||||
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,
|
||||
calibrated_test,
|
||||
selected,
|
||||
horizon=horizon,
|
||||
round_trip_cost=round_trip_cost,
|
||||
settings=settings,
|
||||
detail_limit=0,
|
||||
symbol_thresholds=symbol_thresholds,
|
||||
require_symbol_thresholds=True,
|
||||
)
|
||||
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)]
|
||||
@@ -797,6 +1089,11 @@ def _walk_forward(
|
||||
"train_records": len(train),
|
||||
"test_records": len(test),
|
||||
"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"},
|
||||
}
|
||||
)
|
||||
@@ -888,6 +1185,7 @@ def _quality_gate(
|
||||
return {
|
||||
"status": "pass" if passed else "fail",
|
||||
"passed": passed,
|
||||
"protocol": "untouched_model_holdout_with_threshold_walk_forward",
|
||||
"checks": checks,
|
||||
"oos_summary": 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:
|
||||
dynamic_confidence = _forecast_confidence(record.expected_percent, record.probability_up, record.skill, edge)
|
||||
return (
|
||||
@@ -1098,6 +1401,101 @@ def _calibrate(
|
||||
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(
|
||||
records: list[ForecastRecord],
|
||||
edge: float,
|
||||
@@ -1116,7 +1514,7 @@ def _selected_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 = [
|
||||
result
|
||||
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.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(
|
||||
@@ -1137,8 +1601,10 @@ def _choose_replay_recommendation(
|
||||
horizon: int,
|
||||
round_trip_cost: float,
|
||||
settings: Any,
|
||||
) -> tuple[CalibrationResult, dict[str, Any]]:
|
||||
) -> tuple[CalibrationResult | None, dict[str, Any]]:
|
||||
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)
|
||||
if min_full_replay_trades <= 0:
|
||||
return fallback, fallback_replay
|
||||
@@ -1159,7 +1625,7 @@ def _choose_replay_recommendation(
|
||||
viable.append((result, replay))
|
||||
|
||||
if not viable:
|
||||
return fallback, fallback_replay
|
||||
return None, fallback_replay
|
||||
viable.sort(
|
||||
key=lambda item: (
|
||||
item[0].score,
|
||||
@@ -1210,12 +1676,18 @@ def _artifact_summary(artifact: dict[str, Any]) -> dict[str, Any]:
|
||||
"target_horizon": artifact.get("target_horizon"),
|
||||
"target_horizons": artifact.get("target_horizons"),
|
||||
"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": {
|
||||
symbol: {
|
||||
"model": row.get("model"),
|
||||
"lookback": row.get("lookback"),
|
||||
"hidden_size": row.get("hidden_size"),
|
||||
"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"),
|
||||
}
|
||||
for symbol, row in (artifact.get("symbols") or {}).items()
|
||||
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
@@ -6,6 +6,7 @@ param(
|
||||
[int]$PollSeconds = 10,
|
||||
[int]$WatchdogMinutes = 5,
|
||||
[string]$RepoRoot = "",
|
||||
[string]$CredentialPath = "",
|
||||
[switch]$StartNow,
|
||||
[switch]$KeepLegacyRetrainer
|
||||
)
|
||||
@@ -15,71 +16,78 @@ $ErrorActionPreference = "Stop"
|
||||
if (-not $RepoRoot) {
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
}
|
||||
$Agent = Join-Path $RepoRoot "tools\windows_training_agent.py"
|
||||
if (-not (Test-Path $Agent)) {
|
||||
throw "Windows training agent not found: $Agent"
|
||||
if (-not $CredentialPath) {
|
||||
$CredentialPath = Join-Path $env:LOCALAPPDATA "TradeBot\training-agent.token"
|
||||
}
|
||||
|
||||
function Resolve-Python {
|
||||
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
||||
if (Test-Path $venvPython) {
|
||||
return $venvPython
|
||||
}
|
||||
|
||||
$userPython = Join-Path $env:LOCALAPPDATA "Programs\TradeBotPython312\python.exe"
|
||||
if (Test-Path $userPython) {
|
||||
return $userPython
|
||||
}
|
||||
|
||||
foreach ($candidate in @("python.exe", "python")) {
|
||||
$command = Get-Command $candidate -ErrorAction SilentlyContinue
|
||||
if ($command) {
|
||||
return $command.Source
|
||||
}
|
||||
}
|
||||
throw "Python was not found. Create .venv or install Python 3.12."
|
||||
}
|
||||
|
||||
function Resolve-WindowlessPython {
|
||||
$python = Resolve-Python
|
||||
$pythonw = Join-Path (Split-Path -Parent $python) "pythonw.exe"
|
||||
if (Test-Path $pythonw) {
|
||||
return $pythonw
|
||||
}
|
||||
return $python
|
||||
$runner = Join-Path $RepoRoot "tools\run_windows_training_agent.ps1"
|
||||
if (-not (Test-Path -LiteralPath $runner)) {
|
||||
throw "Windows training agent runner not found: $runner"
|
||||
}
|
||||
|
||||
$credentialDirectory = Split-Path -Parent $CredentialPath
|
||||
New-Item -ItemType Directory -Path $credentialDirectory -Force | Out-Null
|
||||
if ($ApiAuth) {
|
||||
[Environment]::SetEnvironmentVariable("TRADEBOT_API_AUTH", $ApiAuth, "User")
|
||||
$env:TRADEBOT_API_AUTH = $ApiAuth
|
||||
$ApiAuth.Trim() |
|
||||
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_TRAINING_WORKER_NAME", $env:COMPUTERNAME, "User")
|
||||
$env:TRADEBOT_API_BASE_URL = $ApiBaseUrl
|
||||
$env:TRADEBOT_TRAINING_WORKER_NAME = $env:COMPUTERNAME
|
||||
|
||||
if (-not $KeepLegacyRetrainer) {
|
||||
foreach ($legacyName in @("TradeBot PyTorch Forecaster Retrainer", "TradeBot LSTM Retrainer")) {
|
||||
try {
|
||||
$legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue
|
||||
if ($legacyTask) {
|
||||
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
|
||||
$arguments = @(
|
||||
"-u",
|
||||
"`"$Agent`"",
|
||||
"--repo-root", "`"$RepoRoot`"",
|
||||
"--api-base-url", "`"$ApiBaseUrl`"",
|
||||
"--poll-seconds", $PollSeconds.ToString()
|
||||
$principal = New-Object System.Security.Principal.WindowsPrincipal(
|
||||
[System.Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
)
|
||||
$isAdministrator = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
$powershell = (Get-Command powershell.exe -ErrorAction Stop).Source
|
||||
$runnerArguments = @(
|
||||
"-NoProfile",
|
||||
"-WindowStyle", "Hidden",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", "`"$runner`"",
|
||||
"-RepoRoot", "`"$RepoRoot`"",
|
||||
"-ApiBaseUrl", "`"$ApiBaseUrl`"",
|
||||
"-CredentialPath", "`"$CredentialPath`"",
|
||||
"-WorkerName", "`"$env:COMPUTERNAME`"",
|
||||
"-PollSeconds", $PollSeconds.ToString()
|
||||
) -join " "
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $RepoRoot
|
||||
$trigger = @(
|
||||
$startupShortcut = Join-Path ([Environment]::GetFolderPath("Startup")) "$TaskName.lnk"
|
||||
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
Remove-ItemProperty -Path $runKey -Name "TradeBotWindowsTrainingAgent" -ErrorAction SilentlyContinue
|
||||
|
||||
$installMode = "startup shortcut"
|
||||
if ($isAdministrator) {
|
||||
if (Test-Path -LiteralPath $startupShortcut) {
|
||||
Remove-Item -LiteralPath $startupShortcut -Force
|
||||
}
|
||||
$action = New-ScheduledTaskAction -Execute $powershell -Argument $runnerArguments -WorkingDirectory $RepoRoot
|
||||
$trigger = @(
|
||||
New-ScheduledTaskTrigger -AtLogOn -User $currentUser
|
||||
New-ScheduledTaskTrigger -AtStartup
|
||||
New-ScheduledTaskTrigger `
|
||||
@@ -87,12 +95,12 @@ $trigger = @(
|
||||
-At (Get-Date).AddMinutes(1) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
|
||||
-RepetitionDuration (New-TimeSpan -Days 3650)
|
||||
)
|
||||
$principal = New-ScheduledTaskPrincipal `
|
||||
)
|
||||
$taskPrincipal = New-ScheduledTaskPrincipal `
|
||||
-UserId $currentUser `
|
||||
-LogonType Interactive `
|
||||
-RunLevel Limited
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-StartWhenAvailable `
|
||||
-MultipleInstances IgnoreNew `
|
||||
-AllowStartIfOnBatteries `
|
||||
@@ -101,19 +109,60 @@ $settings = New-ScheduledTaskSettingsSet `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Days 30)
|
||||
|
||||
Register-ScheduledTask `
|
||||
Register-ScheduledTask `
|
||||
-TaskName $TaskName `
|
||||
-Action $action `
|
||||
-Trigger $trigger `
|
||||
-Principal $principal `
|
||||
-Principal $taskPrincipal `
|
||||
-Settings $settings `
|
||||
-Description "Keeps the TradeBot Windows training agent online and polls the public bot API for retrain jobs." `
|
||||
-Description "Keeps the TradeBot Windows training agent online and polls the bot API for retrain jobs." `
|
||||
-Force | Out-Null
|
||||
$installMode = "scheduled task"
|
||||
}
|
||||
else {
|
||||
try {
|
||||
$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($existingTask) {
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Could not remove an existing elevated task: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($StartNow) {
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
$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 script: $Agent"
|
||||
Write-Host "Encrypted credential: $CredentialPath"
|
||||
Write-Host "Agent runner: $runner"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+144
-71
@@ -12,17 +12,23 @@ param(
|
||||
[string]$Features = "",
|
||||
[string]$ContextSymbols = "",
|
||||
[int]$Seed = 0,
|
||||
[string]$EnsembleSeeds = "",
|
||||
[int]$SelectionFolds = 0,
|
||||
[double]$LearningRate = 0,
|
||||
[double]$WeightDecay = 0,
|
||||
[int]$Epochs = 0,
|
||||
[int]$Patience = 0,
|
||||
[int]$ValidationWindow = 0,
|
||||
[int]$HoldoutWindow = 0,
|
||||
[string]$Interval = "",
|
||||
[string]$EnvFile = "",
|
||||
[switch]$DeployToPi,
|
||||
[string]$PiHost = "",
|
||||
[string]$PiUser = "",
|
||||
[string]$PiRoot = "",
|
||||
[string]$PiSshKeyPath = "",
|
||||
[switch]$NoPiRestart,
|
||||
[switch]$SkipGuard
|
||||
[string]$OrderbookDb = "",
|
||||
[int]$OrderbookMinSamplesPerBucket = 0,
|
||||
[int]$OrderbookMinCoveredBuckets = 0,
|
||||
[int]$OrderbookMinSymbols = 0,
|
||||
[switch]$Pooled,
|
||||
[switch]$SkipGuard,
|
||||
[switch]$ResumeCandidate
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -38,6 +44,30 @@ function Write-RetrainLog {
|
||||
"[$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 {
|
||||
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
||||
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 ($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 $Architectures) { $Architectures = if ($env:TORCH_RETRAIN_ARCHITECTURES) { $env:TORCH_RETRAIN_ARCHITECTURES } else { "lstm,gru" } }
|
||||
if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "64,96" } }
|
||||
if (-not $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" } }
|
||||
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 ($Horizon -le 0 -and $env:TORCH_RETRAIN_HORIZON) { $Horizon = [int]$env:TORCH_RETRAIN_HORIZON }
|
||||
if (-not $Horizons -and $env:TORCH_RETRAIN_HORIZONS) { $Horizons = $env:TORCH_RETRAIN_HORIZONS }
|
||||
if (-not $Dropouts) { $Dropouts = if ($env:TORCH_RETRAIN_DROPOUTS) { $env:TORCH_RETRAIN_DROPOUTS } else { "0.20" } }
|
||||
if ($Horizon -le 0) { $Horizon = if ($env:TORCH_RETRAIN_HORIZON) { [int]$env:TORCH_RETRAIN_HORIZON } else { 12 } }
|
||||
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 $ContextSymbols -and $env:TORCH_RETRAIN_CONTEXT_SYMBOLS) { $ContextSymbols = $env:TORCH_RETRAIN_CONTEXT_SYMBOLS }
|
||||
if ($Seed -le 0 -and $env:TORCH_RETRAIN_SEED) { $Seed = [int]$env:TORCH_RETRAIN_SEED }
|
||||
if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } }
|
||||
if (-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 ($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 $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 $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" }
|
||||
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"
|
||||
$CandidateCalibration = Join-Path $RuntimeDir "torch_guard_candidate.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")
|
||||
$hasLock = $false
|
||||
@@ -154,8 +167,20 @@ try {
|
||||
"--dropouts", $Dropouts,
|
||||
"--epochs", $Epochs.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
|
||||
)
|
||||
if ($Pooled) {
|
||||
$trainerArgs += "--pooled"
|
||||
}
|
||||
else {
|
||||
$trainerArgs += "--no-pooled"
|
||||
}
|
||||
if ($Symbols) { $trainerArgs += @("--symbols", $Symbols) }
|
||||
if ($Interval) { $trainerArgs += @("--interval", $Interval) }
|
||||
if ($EnvFile) { $trainerArgs += @("--env", $EnvFile) }
|
||||
@@ -164,12 +189,26 @@ try {
|
||||
if ($Features) { $trainerArgs += @("--features", $Features) }
|
||||
if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) }
|
||||
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
|
||||
$pushedLocation = $true
|
||||
if ($ResumeCandidate) {
|
||||
if (-not (Test-TorchArtifactFile $CandidateFile)) {
|
||||
throw "ResumeCandidate requested, but no valid candidate artifact exists: $CandidateFile"
|
||||
}
|
||||
Write-RetrainLog "Resuming guard from existing candidate artifact: $CandidateFile"
|
||||
}
|
||||
else {
|
||||
Write-RetrainLog "Starting PyTorch recurrent retrain: $python $($trainerArgs -join ' ')"
|
||||
& $python @trainerArgs 2>&1 | Tee-Object -FilePath $LogFile -Append
|
||||
$trainerExitCode = $LASTEXITCODE
|
||||
$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."
|
||||
@@ -179,58 +218,92 @@ try {
|
||||
}
|
||||
}
|
||||
Write-RetrainLog "Finished PyTorch recurrent retrain candidate: $CandidateFile"
|
||||
}
|
||||
|
||||
if ($SkipGuard -or -not (Test-Path $ModelFile)) {
|
||||
Move-Item -Force -LiteralPath $CandidateFile -Destination $ModelFile
|
||||
Write-RetrainLog "Accepted candidate without guard. Active artifact: $ModelFile"
|
||||
Sync-AcceptedArtifactsToPi
|
||||
exit 0
|
||||
if ($SkipGuard) {
|
||||
throw "SkipGuard is disabled: every candidate must pass untouched-holdout validation."
|
||||
}
|
||||
|
||||
$calibrationBaseArgs = @(
|
||||
"-u",
|
||||
"tools\calibrate_torch_thresholds.py",
|
||||
"--limit", "3000",
|
||||
"--calibration-window", "1200",
|
||||
"--min-trades", "60",
|
||||
"--limit", $Limit.ToString(),
|
||||
"--horizon", $Horizon.ToString(),
|
||||
"--calibration-window", ([Math]::Min(2400, [Math]::Max(1200, [int]($Limit / 2)))).ToString(),
|
||||
"--min-trades", "24",
|
||||
"--walk-forward-folds", "8",
|
||||
"--confidence-grid", "0.40"
|
||||
)
|
||||
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
|
||||
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
|
||||
if ($OrderbookDb) {
|
||||
$calibrationBaseArgs += @(
|
||||
"--orderbook-db", $OrderbookDb,
|
||||
"--orderbook-min-samples-per-bucket", $OrderbookMinSamplesPerBucket.ToString()
|
||||
)
|
||||
}
|
||||
|
||||
if (Test-Path $ModelFile) {
|
||||
Write-RetrainLog "Calibrating current artifact for guard."
|
||||
& $python @($calibrationBaseArgs + @("--artifact", $ModelFile, "--output", $CurrentCalibration)) 2>&1 | Tee-Object -FilePath $LogFile -Append
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Current artifact calibration failed with exit code $LASTEXITCODE."
|
||||
$currentCalibrationExitCode = Invoke-LoggedNativeCommand `
|
||||
-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."
|
||||
& $python @($calibrationBaseArgs + @("--artifact", $CandidateFile, "--output", $CandidateCalibration)) 2>&1 | Tee-Object -FilePath $LogFile -Append
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Candidate artifact calibration failed with exit code $LASTEXITCODE."
|
||||
$candidateCalibrationExitCode = Invoke-LoggedNativeCommand `
|
||||
-FilePath $python `
|
||||
-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."
|
||||
& $python -u "tools\accept_torch_candidate.py" `
|
||||
--current-report $CurrentCalibration `
|
||||
--candidate-report $CandidateCalibration `
|
||||
--candidate-artifact $CandidateFile `
|
||||
--target-artifact $ModelFile `
|
||||
--report $GuardReport 2>&1 | Tee-Object -FilePath $LogFile -Append
|
||||
if ($LASTEXITCODE -eq 2) {
|
||||
$GuardTarget = if ($ShadowMode) { $ShadowModelFile } else { $ModelFile }
|
||||
$guardArgs = @(
|
||||
"-u",
|
||||
"tools\accept_torch_candidate.py",
|
||||
"--current-report", $CurrentCalibration,
|
||||
"--candidate-report", $CandidateCalibration,
|
||||
"--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"
|
||||
exit 0
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Retrain guard failed with exit code $LASTEXITCODE."
|
||||
if ($guardExitCode -ne 0) {
|
||||
throw "Retrain guard failed with exit code $guardExitCode."
|
||||
}
|
||||
if (Test-Path $CandidateCalibration) {
|
||||
if ($ShadowMode) {
|
||||
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"
|
||||
Sync-AcceptedArtifactsToPi
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-RetrainLog "ERROR: $($_.Exception.Message)"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
+357
-17
@@ -7,6 +7,7 @@ import json
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -19,12 +20,25 @@ from urllib.error import URLError
|
||||
from urllib.request import Request
|
||||
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 = (
|
||||
"lstm_forecaster.json",
|
||||
"torch_retrain_guard.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:
|
||||
@@ -50,36 +64,103 @@ def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_
|
||||
api_json(args, "/api/training/heartbeat", worker)
|
||||
claim = api_json(args, "/api/training/claim", worker)
|
||||
if not claim.get("claimed"):
|
||||
maybe_auto_queue_orderbook(args, repo_root, runtime_dir, log_path)
|
||||
return
|
||||
job = claim.get("job") if isinstance(claim.get("job"), dict) else {}
|
||||
job_id = str(job.get("id") or "")
|
||||
lease_token = str(claim.get("lease_token") or "")
|
||||
if not job_id:
|
||||
return
|
||||
if not lease_token:
|
||||
raise RuntimeError("training server did not issue a job lease")
|
||||
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
|
||||
message = ""
|
||||
summary: dict[str, Any] = {}
|
||||
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")
|
||||
report_progress(args, job_id, "running", "uploading", 72, "Обучение завершено, загружаю артефакты")
|
||||
for name in ARTIFACT_NAMES:
|
||||
accepted = summary.get("accepted") is True
|
||||
if accepted:
|
||||
report_progress(
|
||||
args,
|
||||
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, path, log_path)
|
||||
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
|
||||
message = "training completed"
|
||||
log(log_path, f"Completed retrain job {job_id}")
|
||||
except Exception as exc: # noqa: BLE001 - report failure to the bot.
|
||||
message = str(exc)
|
||||
log(log_path, f"Job {job_id} failed: {message}")
|
||||
finally:
|
||||
payload = {"success": success, "message": message, "summary": summary}
|
||||
payload = {
|
||||
"success": success,
|
||||
"message": message,
|
||||
"summary": summary,
|
||||
"lease_token": lease_token,
|
||||
}
|
||||
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"
|
||||
if not script.is_file():
|
||||
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",
|
||||
"dropouts": "-Dropouts",
|
||||
"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():
|
||||
value = parameters.get(key)
|
||||
if value not in (None, ""):
|
||||
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))
|
||||
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
|
||||
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,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
**hidden_subprocess_kwargs(),
|
||||
) as process:
|
||||
reader = threading.Thread(target=read_output, name="training-output-reader", daemon=True)
|
||||
reader.start()
|
||||
@@ -140,7 +255,7 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
||||
log(log_path, message)
|
||||
line_count += 1
|
||||
if message:
|
||||
last_message = message[-220:]
|
||||
last_message = friendly_training_message(message)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
@@ -150,7 +265,16 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
||||
report_message = last_message
|
||||
if not got_line:
|
||||
report_message = training_heartbeat_message(now, started_at, last_output_at, last_message)
|
||||
safe_report_progress(args, job_id, "running", "training", progress, report_message, log_path)
|
||||
safe_report_progress(
|
||||
args,
|
||||
job_id,
|
||||
lease_token,
|
||||
"running",
|
||||
"training",
|
||||
progress,
|
||||
report_message,
|
||||
log_path,
|
||||
)
|
||||
last_report_at = now
|
||||
|
||||
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()
|
||||
if code != 0:
|
||||
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:
|
||||
@@ -185,7 +487,13 @@ def format_duration(seconds: float) -> str:
|
||||
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()
|
||||
size = path.stat().st_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,
|
||||
"sha256": digest,
|
||||
"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)
|
||||
if index == 0 or index == total - 1 or index % 10 == 0:
|
||||
progress = 72 + int(((index + 1) / total) * 23)
|
||||
report_progress(args, job_id, "running", "uploading", progress, f"Загружаю {path.name}: {index + 1}/{total}")
|
||||
report_progress(
|
||||
args,
|
||||
job_id,
|
||||
lease_token,
|
||||
"running",
|
||||
"uploading",
|
||||
progress,
|
||||
f"Загружаю {path.name}: {index + 1}/{total}",
|
||||
)
|
||||
|
||||
|
||||
def report_progress(
|
||||
args: argparse.Namespace,
|
||||
job_id: str,
|
||||
lease_token: str,
|
||||
status: str,
|
||||
phase: str,
|
||||
progress_percent: int,
|
||||
@@ -224,6 +542,7 @@ def report_progress(
|
||||
"progress_percent": progress_percent,
|
||||
"message": message,
|
||||
"worker": worker_payload(args, Path(args.repo_root).resolve()),
|
||||
"lease_token": lease_token,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -231,6 +550,7 @@ def report_progress(
|
||||
def safe_report_progress(
|
||||
args: argparse.Namespace,
|
||||
job_id: str,
|
||||
lease_token: str,
|
||||
status: str,
|
||||
phase: str,
|
||||
progress_percent: int,
|
||||
@@ -240,7 +560,15 @@ def safe_report_progress(
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
report_progress(args, job_id, status, phase, progress_percent, message)
|
||||
report_progress(
|
||||
args,
|
||||
job_id,
|
||||
lease_token,
|
||||
status,
|
||||
phase,
|
||||
progress_percent,
|
||||
message,
|
||||
)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 - keep the local training process alive.
|
||||
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}",
|
||||
"name": name,
|
||||
"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 {}
|
||||
|
||||
|
||||
def hidden_subprocess_kwargs() -> dict[str, Any]:
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
startupinfo.wShowWindow = 0
|
||||
return {
|
||||
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"startupinfo": startupinfo,
|
||||
}
|
||||
|
||||
|
||||
def quote_for_log(value: str) -> str:
|
||||
return f'"{value}"' if " " in value else value
|
||||
|
||||
|
||||
Reference in New Issue
Block a user