Compare commits
17
Commits
7186acb9a1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ce92b6428 | ||
|
|
393454c9e0 | ||
|
|
0ec64645b6 | ||
|
|
be5c9d482a | ||
|
|
991b77351c | ||
|
|
5082be2e5a | ||
|
|
0992da0ece | ||
|
|
5d8ad1437e | ||
|
|
f7a625586e | ||
|
|
2967cd607c | ||
|
|
d0869b5d29 | ||
|
|
1f2fb011a7 | ||
|
|
e1a42a9011 | ||
|
|
51a7833896 | ||
|
|
1c7701c38e | ||
|
|
4c347ed425 | ||
|
|
5c4aecfe5f |
+32
-2
@@ -1,15 +1,23 @@
|
|||||||
TRADING_MODE=paper
|
TRADING_MODE=paper
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
PORT=8787
|
PORT=8787
|
||||||
|
# Keep 127.0.0.1 when the reverse proxy is local. This Dell deployment uses
|
||||||
|
# 0.0.0.0 because Caddy reaches port 8787 from a separate LAN host.
|
||||||
|
TRADEBOT_BIND_ADDRESS=127.0.0.1
|
||||||
|
|
||||||
BYBIT_TESTNET=false
|
BYBIT_TESTNET=false
|
||||||
|
# Official regional public endpoints for this deployment. Before future live
|
||||||
|
# trading they must match the Bybit site where the API key was created.
|
||||||
|
BYBIT_REST_BASE_URL=https://api.bybit.kz
|
||||||
|
BYBIT_WEBSOCKET_URL=wss://stream.bybit.kz/v5/public/spot
|
||||||
BYBIT_API_KEY=
|
BYBIT_API_KEY=
|
||||||
BYBIT_API_SECRET=
|
BYBIT_API_SECRET=
|
||||||
|
|
||||||
STARTING_BALANCE_USDT=100
|
STARTING_BALANCE_USDT=100
|
||||||
AUTO_SELECT_SYMBOLS=false
|
AUTO_SELECT_SYMBOLS=true
|
||||||
TOP_SYMBOLS_COUNT=12
|
TOP_SYMBOLS_COUNT=12
|
||||||
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
|
# Leave empty to discover the most liquid eligible USDT Spot pairs from Bybit.
|
||||||
|
SYMBOLS=
|
||||||
|
|
||||||
STRATEGY_MODE=torch_forecast
|
STRATEGY_MODE=torch_forecast
|
||||||
BASE_INTERVAL=60
|
BASE_INTERVAL=60
|
||||||
@@ -22,6 +30,8 @@ FAST_LOOP_INTERVAL_SECONDS=1
|
|||||||
FAST_ENTRY_COOLDOWN_SECONDS=20
|
FAST_ENTRY_COOLDOWN_SECONDS=20
|
||||||
MAX_ENTRIES_PER_MINUTE=12
|
MAX_ENTRIES_PER_MINUTE=12
|
||||||
WEBSOCKET_ENABLED=true
|
WEBSOCKET_ENABLED=true
|
||||||
|
MARKET_OBSERVATION_ENABLED=true
|
||||||
|
MARKET_OBSERVATION_SAMPLE_SECONDS=30
|
||||||
MIN_SIGNAL_CONFIDENCE=0.64
|
MIN_SIGNAL_CONFIDENCE=0.64
|
||||||
MAX_SPREAD_PERCENT=0.18
|
MAX_SPREAD_PERCENT=0.18
|
||||||
MIN_24H_TURNOVER_USDT=1000000
|
MIN_24H_TURNOVER_USDT=1000000
|
||||||
@@ -75,6 +85,7 @@ TIME_SERIES_REBOUND_FALLBACK_ENABLED=false
|
|||||||
# Use the independently guarded trend/MACD strategy while no accepted fresh
|
# Use the independently guarded trend/MACD strategy while no accepted fresh
|
||||||
# Torch model is available. The rejected model is never used for entries.
|
# Torch model is available. The rejected model is never used for entries.
|
||||||
TIME_SERIES_TREND_FALLBACK_ENABLED=true
|
TIME_SERIES_TREND_FALLBACK_ENABLED=true
|
||||||
|
TIME_SERIES_FALLBACK_MODE=legacy
|
||||||
TIME_SERIES_REQUIRE_QUALITY_GATE=true
|
TIME_SERIES_REQUIRE_QUALITY_GATE=true
|
||||||
# Emergency paper-only override. Keep false unless a failed guard is accepted manually.
|
# Emergency paper-only override. Keep false unless a failed guard is accepted manually.
|
||||||
TIME_SERIES_MANUAL_QUALITY_OVERRIDE=false
|
TIME_SERIES_MANUAL_QUALITY_OVERRIDE=false
|
||||||
@@ -82,9 +93,14 @@ TIME_SERIES_REQUIRE_FRESH_MODEL=true
|
|||||||
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
|
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
|
||||||
MARKET_TICKER_MAX_AGE_SECONDS=45
|
MARKET_TICKER_MAX_AGE_SECONDS=45
|
||||||
STOP_LOSS_PERCENT=0.04
|
STOP_LOSS_PERCENT=0.04
|
||||||
|
STOP_LOSS_EXIT_ENABLED=false
|
||||||
TAKE_PROFIT_PERCENT=0.035
|
TAKE_PROFIT_PERCENT=0.035
|
||||||
TRAILING_STOP_PERCENT=0.015
|
TRAILING_STOP_PERCENT=0.015
|
||||||
MIN_HOLD_SECONDS=180
|
MIN_HOLD_SECONDS=180
|
||||||
|
# Ordinary RSI/EMA/model/exposure exits are only executed when the estimated
|
||||||
|
# result after entry fee, exit fee, spread and slippage clears this net margin.
|
||||||
|
PROFIT_ONLY_EXIT_ENABLED=true
|
||||||
|
MIN_EXIT_NET_PERCENT=0.31
|
||||||
ENTRY_COOLDOWN_SECONDS=180
|
ENTRY_COOLDOWN_SECONDS=180
|
||||||
MAX_DAILY_DRAWDOWN_USDT=6
|
MAX_DAILY_DRAWDOWN_USDT=6
|
||||||
MIN_CASH_RESERVE_USDT=5
|
MIN_CASH_RESERVE_USDT=5
|
||||||
@@ -111,6 +127,20 @@ STORAGE_PRUNE_INTERVAL_SECONDS=3600
|
|||||||
|
|
||||||
# Windows trainer keeps this final tail untouched by training and early stopping.
|
# Windows trainer keeps this final tail untouched by training and early stopping.
|
||||||
TORCH_RETRAIN_HOLDOUT_WINDOW=1000
|
TORCH_RETRAIN_HOLDOUT_WINDOW=1000
|
||||||
|
TORCH_ORDERBOOK_DB=runtime/orderbook_observations.sqlite3
|
||||||
|
TORCH_ORDERBOOK_MIN_SAMPLES_PER_BUCKET=20
|
||||||
|
TORCH_ORDERBOOK_MIN_COVERED_BUCKETS=240
|
||||||
|
TORCH_ORDERBOOK_MIN_SYMBOLS=2
|
||||||
|
TORCH_ORDERBOOK_AUTO_CHECK_SECONDS=3600
|
||||||
|
|
||||||
|
# Forward-only gate for an offline-approved shadow model. Promotion remains an
|
||||||
|
# explicit authenticated API action after every check has passed.
|
||||||
|
SHADOW_GATE_MIN_SETTLED=300
|
||||||
|
SHADOW_GATE_MIN_ELIGIBLE=30
|
||||||
|
SHADOW_GATE_MIN_SYMBOLS=2
|
||||||
|
SHADOW_GATE_MIN_PROFIT_FACTOR=1.10
|
||||||
|
SHADOW_GATE_MIN_DIRECTION_ACCURACY=0.52
|
||||||
|
SHADOW_GATE_MAX_BRIER=0.25
|
||||||
|
|
||||||
DATABASE_PATH=runtime/tradebot.sqlite3
|
DATABASE_PATH=runtime/tradebot.sqlite3
|
||||||
LOG_PATH=runtime/tradebot.log
|
LOG_PATH=runtime/tradebot.log
|
||||||
|
|||||||
+9
-5
@@ -5,12 +5,16 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt /app/requirements.txt
|
COPY requirements.txt /app/requirements.txt
|
||||||
RUN pip install --no-cache-dir --upgrade pip \
|
RUN pip install --no-cache-dir --disable-pip-version-check --upgrade pip \
|
||||||
&& pip install --no-cache-dir -r /app/requirements.txt
|
&& pip install --no-cache-dir --disable-pip-version-check -r /app/requirements.txt \
|
||||||
|
&& groupadd --gid 1000 tradebot \
|
||||||
|
&& useradd --uid 1000 --gid tradebot --home-dir /app --shell /usr/sbin/nologin tradebot
|
||||||
|
|
||||||
COPY crypto_spot_bot /app/crypto_spot_bot
|
COPY --chown=1000:1000 crypto_spot_bot /app/crypto_spot_bot
|
||||||
COPY README.md /app/README.md
|
COPY --chown=1000:1000 README.md /app/README.md
|
||||||
RUN mkdir -p /app/runtime
|
RUN mkdir -p /app/runtime && chown -R 1000:1000 /app/runtime
|
||||||
|
|
||||||
EXPOSE 8787
|
EXPOSE 8787
|
||||||
|
USER 1000:1000
|
||||||
|
STOPSIGNAL SIGTERM
|
||||||
CMD ["python", "-m", "crypto_spot_bot.main"]
|
CMD ["python", "-m", "crypto_spot_bot.main"]
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
# Crypto Spot TradeBot
|
# Crypto Spot TradeBot
|
||||||
|
|
||||||
|
Веб-панель управления доступна на корневом адресе сервиса: локально
|
||||||
|
`http://127.0.0.1:8787/`, в production — `https://tb.kusoft.xyz/`. Панель показывает
|
||||||
|
готовность торгового контура, капитал, позиции, рынки, сигналы, сделки, события и
|
||||||
|
состояние модели; из неё можно запускать и останавливать цикл и переключать быстрый
|
||||||
|
режим. Приватные данные и управляющие действия используют ту же авторизацию, что и API.
|
||||||
|
|
||||||
Spot-бот для демо-торговли криптовалютой на реальных данных Bybit. По умолчанию работает только в `paper`-режиме со стартовым балансом `100 USDT`; live-режим заблокирован до явного включения через env-переменные.
|
Spot-бот для демо-торговли криптовалютой на реальных данных Bybit. По умолчанию работает только в `paper`-режиме со стартовым балансом `100 USDT`; live-режим заблокирован до явного включения через env-переменные.
|
||||||
|
|
||||||
## Что реализовано
|
## Что реализовано
|
||||||
|
|
||||||
- Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления.
|
- Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления.
|
||||||
- Фиксированный набор 12 USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `HYPEUSDT`, `SOLUSDT`, `XRPUSDT`, `XPLUSDT`, `WLDUSDT`, `MNTUSDT`, `HUSDT`, `XAUTUSDT`, `IPUSDT`, `AAVEUSDT`.
|
- Сэмплированный L1-стакан Bybit сохраняется в SQLite: bid/ask size, spread, imbalance и microprice доступны обучающему агенту через защищённый постраничный API. Частота по умолчанию — один sample на пару каждые 30 секунд, а хранилище ограничено 1 200 000 строк.
|
||||||
|
- Торговый universe автоматически строится из актуальных Bybit Spot-инструментов: выбираются до 12 ликвидных USDT-пар по `turnover24h`, исключаются stablecoin-to-stablecoin и leveraged-token пары; фиксированный список можно задать только явным `SYMBOLS`.
|
||||||
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
|
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
|
||||||
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
|
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
|
||||||
- Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`.
|
- Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`.
|
||||||
- Основная стратегия `torch_forecast`: входы и forecast-выходы идут только от свежей экспортированной PyTorch LSTM/GRU модели с успешным quality gate; MACD/RSI/дневная EMA не являются условиями входа в этом режиме. Rebound fallback без модели выключен по умолчанию. Спред, ликвидность, stop-loss, ATR trailing stop, запрет DCA и лимиты экспозиции остаются защитой исполнения и риска.
|
- Основная стратегия `torch_forecast`: входы и forecast-выходы идут только от свежей экспортированной PyTorch LSTM/GRU модели с успешным quality gate; MACD/RSI/дневная EMA не являются условиями входа в этом режиме. Rebound fallback без модели выключен по умолчанию. Спред, ликвидность, stop-loss, ATR trailing stop, запрет DCA и лимиты экспозиции остаются защитой исполнения и риска.
|
||||||
- При `TIME_SERIES_TREND_FALLBACK_ENABLED=true` отсутствие принятой свежей Torch-модели включает самостоятельную `trend_macd`-стратегию. Отклонённый artifact не используется, fallback явно отражается в readiness и диагностике сигналов, а после появления принятой модели выключается автоматически.
|
- При `TIME_SERIES_TREND_FALLBACK_ENABLED=true` отсутствие принятой свежей Torch-модели включает самостоятельную fallback-стратегию. `TIME_SERIES_FALLBACK_MODE=legacy` разрешён только для paper и даёт многорежимные виртуальные входы; live всегда принудительно использует более строгий `trend_macd`. Отклонённый artifact не используется, fallback явно отражается в readiness и диагностике сигналов, а после появления принятой модели выключается автоматически.
|
||||||
- Основная стратегия `trend_macd`: вход на `1h`, дневной фильтр тренда на `1d`, long только если цена выше дневной EMA200 и дневная EMA50 выше EMA200.
|
- Основная стратегия `trend_macd`: вход на `1h`, дневной фильтр тренда на `1d`, long только если цена выше дневной EMA200 и дневная EMA50 выше EMA200.
|
||||||
- Вход `trend_macd`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры.
|
- Вход `trend_macd`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры.
|
||||||
- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`.
|
- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`.
|
||||||
@@ -18,13 +25,14 @@ Spot-бот для демо-торговли криптовалютой на р
|
|||||||
- DCA/мартингейл отключены: в режиме `trend_macd` брокер не разрешает вторую позицию по той же паре.
|
- DCA/мартингейл отключены: в режиме `trend_macd` брокер не разрешает вторую позицию по той же паре.
|
||||||
- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`.
|
- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`.
|
||||||
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
|
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
|
||||||
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
|
- Защищённый JSON API: equity, cash, PnL, позиции, сделки, сигналы, события, свечи, управление paper-циклом и состоянием обучения.
|
||||||
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для просмотра 12 пар, свечей, Torch/Kelly параметров, расписания удалённого retrain и live-чеклиста.
|
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для динамического списка Bybit-пар, свечей, Torch/Kelly параметров, WorkManager-расписания удалённого retrain и live-чеклиста.
|
||||||
- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`.
|
- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`.
|
||||||
- Liveness `/api/health`, readiness `/api/ready`, объединенный mobile snapshot `/api/mobile/snapshot` и Prometheus-compatible `/metrics`.
|
- Liveness `/api/health`, readiness `/api/ready`, объединенный mobile snapshot `/api/mobile/snapshot` и Prometheus-compatible `/metrics`.
|
||||||
- Все приватные API endpoints требуют токен или подтвержденный reverse-proxy user header; health и metrics остаются доступными для локального мониторинга.
|
- Все приватные API endpoints требуют токен или подтвержденный reverse-proxy user header; health и metrics остаются доступными для локального мониторинга.
|
||||||
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост.
|
- Hardened Docker Compose для установки на Dell/Linux: non-root user, read-only root filesystem, dropped capabilities, healthcheck и ротация container logs.
|
||||||
- Live trading guard: live не стартует без `ENABLE_LIVE_TRADING=true`, `LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK` и Bybit API-ключей.
|
- Live trading guard: live не стартует без `ENABLE_LIVE_TRADING=true`, `LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK` и Bybit API-ключей.
|
||||||
|
- Внешний production endpoint сохраняется на `https://tb.kusoft.xyz`; Caddy завершает TLS и проксирует API к контейнеру на loopback.
|
||||||
|
|
||||||
## Источники и принятые параметры
|
## Источники и принятые параметры
|
||||||
|
|
||||||
@@ -34,6 +42,8 @@ Spot-бот для демо-торговли криптовалютой на р
|
|||||||
|
|
||||||
Популярность пар определяется через `/v5/market/tickers`, потому что Bybit Spot ticker возвращает `turnover24h`, `volume24h`, `bid1Price`, `ask1Price` и `lastPrice`: <https://bybit-exchange.github.io/docs/v5/market/tickers>.
|
Популярность пар определяется через `/v5/market/tickers`, потому что Bybit Spot ticker возвращает `turnover24h`, `volume24h`, `bid1Price`, `ask1Price` и `lastPrice`: <https://bybit-exchange.github.io/docs/v5/market/tickers>.
|
||||||
|
|
||||||
|
Для текущего paper/training-развёртывания используется официальный региональный endpoint `api.bybit.kz`; Bybit перечисляет его в Integration Guidance. `BYBIT_REST_BASE_URL` и `BYBIT_WEBSOCKET_URL` остаются явными настройками, потому что перед будущим live-режимом домен обязан соответствовать площадке выпуска API-ключа: <https://bybit-exchange.github.io/docs/v5/guide>.
|
||||||
|
|
||||||
Лучшие bid/ask берутся из `/v5/market/orderbook`; документация Bybit описывает `GET /v5/market/orderbook` с `category=spot`: <https://bybit-exchange.github.io/docs/v5/market/orderbook>.
|
Лучшие bid/ask берутся из `/v5/market/orderbook`; документация Bybit описывает `GET /v5/market/orderbook` с `category=spot`: <https://bybit-exchange.github.io/docs/v5/market/orderbook>.
|
||||||
|
|
||||||
WebSocket-стакан использует topic `orderbook.{depth}.{symbol}`; Bybit документирует snapshot/delta-поведение и частоты push для Spot depth 1/50/200/1000: <https://bybit-exchange.github.io/docs/v5/websocket/public/orderbook>.
|
WebSocket-стакан использует topic `orderbook.{depth}.{symbol}`; Bybit документирует snapshot/delta-поведение и частоты push для Spot depth 1/50/200/1000: <https://bybit-exchange.github.io/docs/v5/websocket/public/orderbook>.
|
||||||
@@ -45,7 +55,7 @@ Live market orders используют `/v5/order/create`; Bybit докумен
|
|||||||
- Investopedia перечисляет важные свойства algo trading software: real-time market data, low latency, configurability, backtesting, broker/exchange integration, fees/costs и APIs: <https://www.investopedia.com/articles/active-trading/090815/picking-right-algorithmic-trading-software.asp>.
|
- Investopedia перечисляет важные свойства algo trading software: real-time market data, low latency, configurability, backtesting, broker/exchange integration, fees/costs и APIs: <https://www.investopedia.com/articles/active-trading/090815/picking-right-algorithmic-trading-software.asp>.
|
||||||
- Investopedia отдельно указывает, что automated trading systems задают правила entry/exit/money management, но требуют мониторинга и несут риск mechanical failures и over-optimization: <https://www.investopedia.com/articles/trading/11/automated-trading-systems.asp>.
|
- Investopedia отдельно указывает, что automated trading systems задают правила entry/exit/money management, но требуют мониторинга и несут риск mechanical failures и over-optimization: <https://www.investopedia.com/articles/trading/11/automated-trading-systems.asp>.
|
||||||
- QuantInsti описывает типовой путь разработки: стратегия, backtesting, paper trading, затем live trading, плюс GUI, order management и risk management: <https://www.quantinsti.com/articles/automated-trading-system/>.
|
- QuantInsti описывает типовой путь разработки: стратегия, backtesting, paper trading, затем live trading, плюс GUI, order management и risk management: <https://www.quantinsti.com/articles/automated-trading-system/>.
|
||||||
- Hochreiter и Schmidhuber описали LSTM как recurrent neural network architecture для последовательностей; обучение LSTM/GRU в проекте выполняется локально через PyTorch, а Raspberry Pi исполняет только экспортированные JSON-веса без PyTorch runtime: <https://direct.mit.edu/neco/article/9/8/1735/6109/Long-Short-Term-Memory>.
|
- Hochreiter и Schmidhuber описали LSTM как recurrent neural network architecture для последовательностей; обучение LSTM/GRU в проекте выполняется локально через PyTorch, а Dell исполняет только прошедшие quality gate экспортированные JSON-веса без PyTorch runtime: <https://direct.mit.edu/neco/article/9/8/1735/6109/Long-Short-Term-Memory>.
|
||||||
|
|
||||||
Я не могу подтвердить, что эта стратегия будет прибыльной. Источники выше описывают технические свойства и риски автоматической торговли, но не гарантируют прибыль.
|
Я не могу подтвердить, что эта стратегия будет прибыльной. Источники выше описывают технические свойства и риски автоматической торговли, но не гарантируют прибыль.
|
||||||
|
|
||||||
@@ -59,17 +69,17 @@ Copy-Item .env.example .env
|
|||||||
python -m crypto_spot_bot.main
|
python -m crypto_spot_bot.main
|
||||||
```
|
```
|
||||||
|
|
||||||
Dashboard: <http://127.0.0.1:8787/>
|
Liveness: <http://127.0.0.1:8787/api/health>
|
||||||
|
|
||||||
## Локальное обучение PyTorch LSTM/GRU
|
## Локальное обучение PyTorch LSTM
|
||||||
|
|
||||||
Обучение запускается на основной Windows-машине, а Raspberry Pi остается только для исполнения торгового цикла. PyTorch нужен только на машине обучения; в JSON экспортируются веса, а runtime на Raspberry Pi считает inference обычным Python-кодом:
|
Обучение запускается на основной Windows-машине, а Dell остается для исполнения торгового цикла. PyTorch нужен только на машине обучения; в JSON экспортируются веса, а runtime на Dell считает inference обычным Python-кодом:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\.venv\Scripts\python.exe -m pip install torch --index-url https://download.pytorch.org/whl/cpu
|
.\.venv\Scripts\python.exe -m pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
.\.venv\Scripts\python.exe tools\train_torch_recurrent_forecaster.py `
|
.\.venv\Scripts\python.exe tools\train_torch_recurrent_forecaster.py `
|
||||||
--limit 3000 `
|
--limit 3000 `
|
||||||
--architectures lstm,gru `
|
--architectures lstm `
|
||||||
--lookbacks 64 `
|
--lookbacks 64 `
|
||||||
--hidden-sizes 64,96 `
|
--hidden-sizes 64,96 `
|
||||||
--layers 2 `
|
--layers 2 `
|
||||||
@@ -86,11 +96,10 @@ Dashboard: <http://127.0.0.1:8787/>
|
|||||||
|
|
||||||
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В стратегии `torch_forecast` экспортированная PyTorch LSTM/GRU модель является единственным направляющим сигналом для входа и forecast-выхода. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены.
|
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В стратегии `torch_forecast` экспортированная PyTorch LSTM/GRU модель является единственным направляющим сигналом для входа и forecast-выхода. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены.
|
||||||
|
|
||||||
Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
|
Локальный retrain на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1
|
powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1
|
||||||
powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Для удалённого запуска с телефона или с бота используется Windows training agent. Бот на `tb.kusoft.xyz` хранит очередь заданий, а Windows-машина сама подключается к интернету, забирает задания, обучает модель и загружает артефакты обратно:
|
Для удалённого запуска с телефона или с бота используется Windows training agent. Бот на `tb.kusoft.xyz` хранит очередь заданий, а Windows-машина сама подключается к интернету, забирает задания, обучает модель и загружает артефакты обратно:
|
||||||
@@ -99,21 +108,15 @@ powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.p
|
|||||||
powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps1 -ApiAuth "<TRADEBOT_TRAINING_TOKEN>" -StartNow
|
powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps1 -ApiAuth "<TRADEBOT_TRAINING_TOKEN>" -StartNow
|
||||||
```
|
```
|
||||||
|
|
||||||
Установщик сохраняет worker-токен через Windows DPAPI, удаляет его старую plaintext-копию из пользовательского окружения и включает постоянный запуск агента. С правами администратора используется Scheduled Task с watchdog; без повышения прав — штатный ярлык в пользовательской папке Startup. Старые локальные retrain-задачи удаляются, чтобы обучение запускалось через очередь, а не двумя независимыми механизмами.
|
Установщик сохраняет worker-токен через Windows DPAPI, удаляет его старую plaintext-копию из пользовательского окружения и включает постоянный запуск агента. С правами администратора используется Scheduled Task с watchdog; без повышения прав — штатный ярлык в пользовательской папке Startup. Сервер выдаёт каждой попытке 10-минутную возобновляемую lease; зависшая попытка автоматически возвращается в очередь, а устаревший процесс не может загрузить артефакты по старой lease.
|
||||||
|
|
||||||
По умолчанию Windows-agent обучает отдельную PyTorch `LSTM/GRU` для каждой пары на `6000` часовых свечах. Это не заставляет разнородные активы делить одну архитектуру и один набор recurrent-весов. Прогноз усредняется по seed `7/19`, модели сравниваются на validation-folds, а пороги калибруются отдельно для каждой пары. Ensemble guard выполняется пакетно на GPU, а экспорт не дублирует первый набор весов. Search space использует lookback `32/64/128`, hidden `64/96`, dropout `0.20`, AdamW learning rate `0.0007` и weight decay `0.0005`; untouched holdout и quality gate не ослабляются. Для диагностического pooled-запуска используется ключ `-Pooled`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_ENSEMBLE_SEEDS`, `TORCH_RETRAIN_SELECTION_FOLDS`, `TORCH_RETRAIN_LEARNING_RATE`, `TORCH_RETRAIN_WEIGHT_DECAY`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
|
По умолчанию 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`.
|
||||||
|
|
||||||
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.
|
Loss и выбор гиперпараметров учитывают after-cost trading utility, ошибку ожидаемого чистого PnL, quantile-loss и focal BCE для события `TP before SL`, а не только MAE направления цены. В каждом walk-forward fold вероятность успеха калибруется Platt-моделью исключительно на train-части; затем на этой же train-части выбираются глобальные и per-symbol пороги, которые применяются к test-части. Для выбора порога требуется минимум 24 непересекающиеся сделки, а финальный quality gate по-прежнему требует не менее 30 OOS-сделок. Калибратор не имеет fallback на единичные сделки: если минимальная статистика не набрана, кандидат получает `calibration_insufficient` и не может пройти gate.
|
||||||
|
|
||||||
Основной decision horizon — `12h`, дополнительные горизонты — `3/6/12/24`. Размеры обучающих барьеров берутся из `STOP_LOSS_PERCENT` и `TAKE_PROFIT_PERCENT`, а round-trip cost — из fee/slippage настроек. Threshold search оценивается тем же execution replay со stop-loss, take-profit, ATR trailing и forecast-exit, который используется в walk-forward. `holdout_skill` остаётся только в финальном отчёте и никогда не участвует в фильтрации входов или подборе порогов.
|
Основной decision horizon — `12h`, дополнительные горизонты — `3/6/12/24`. Размеры обучающих барьеров берутся из `STOP_LOSS_PERCENT` и `TAKE_PROFIT_PERCENT`, а round-trip cost — из fee/slippage настроек. Threshold search оценивается тем же execution replay со stop-loss, take-profit, ATR trailing и forecast-exit, который используется в walk-forward. `holdout_skill` остаётся только в финальном отчёте и никогда не участвует в фильтрации входов или подборе порогов.
|
||||||
|
|
||||||
Если retrain запускается с `-DeployToPi`, после успешного guard он синхронизирует `runtime/lstm_forecaster.json`, `runtime/torch_retrain_guard.json` и `runtime/torch_threshold_calibration.json` на Raspberry Pi через SSH-ключ и перезапускает сервис `tradebot`. Отдельный запуск sync:
|
Внутри recurrent модели используются exportable attention pooling и LayerNorm. После recurrent-контекста добавлена нелинейная GELU-проекция и две отдельные экспортируемые головы: одна для ожидаемого PnL/quantiles, вторая для `P(TP before SL)`. Принятый bundle загружается агентом через защищённый API `tb.kusoft.xyz`, проходит серверную проверку SHA-256/guard/calibration и атомарно становится активным на Dell.
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell -ExecutionPolicy Bypass -File tools\sync_torch_artifacts_to_pi.ps1 -RemoteHost 192.168.0.185 -RemoteUser sevenhill -RemoteRoot /mnt/data/tradebot
|
|
||||||
```
|
|
||||||
|
|
||||||
Внутри recurrent модели используются exportable attention pooling и LayerNorm. После recurrent-контекста добавлена нелинейная GELU-проекция и две отдельные экспортируемые головы: одна для ожидаемого PnL/quantiles, вторая для `P(TP before SL)`. Raspberry Pi по-прежнему исполняет модель из JSON без PyTorch runtime.
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -123,18 +126,21 @@ docker compose up -d --build
|
|||||||
docker compose logs -f tradebot
|
docker compose logs -f tradebot
|
||||||
```
|
```
|
||||||
|
|
||||||
Dashboard: `http://<host>:8787/`
|
Локальная проверка: `http://127.0.0.1:8787/api/health`; внешний адрес: `https://tb.kusoft.xyz`.
|
||||||
|
|
||||||
Для Raspberry Pi 5 проект использует `python:3.12-slim`, без Node.js build step. Runtime-данные лежат в volume `./runtime:/app/runtime`; на внешнем диске можно разместить папку проекта или заменить volume на абсолютный путь внешнего диска.
|
На Dell проект использует `python:3.12-slim`, без Node.js build step. Runtime-данные лежат в bind mount `./runtime:/app/runtime`; корневая файловая система контейнера read-only, процесс работает как UID/GID 1000, а container logs ротируются по `10 MiB × 3`. Порт по умолчанию привязан к `127.0.0.1`; для текущей схемы с Caddy на отдельном LAN-хосте в серверном `.env` задаётся `TRADEBOT_BIND_ADDRESS=0.0.0.0`, чтобы сохранить `https://tb.kusoft.xyz`.
|
||||||
|
|
||||||
## Основные env-параметры
|
## Основные env-параметры
|
||||||
|
|
||||||
```env
|
```env
|
||||||
TRADING_MODE=paper
|
TRADING_MODE=paper
|
||||||
STARTING_BALANCE_USDT=100
|
STARTING_BALANCE_USDT=100
|
||||||
AUTO_SELECT_SYMBOLS=false
|
TRADEBOT_BIND_ADDRESS=127.0.0.1
|
||||||
|
BYBIT_REST_BASE_URL=https://api.bybit.kz
|
||||||
|
BYBIT_WEBSOCKET_URL=wss://stream.bybit.kz/v5/public/spot
|
||||||
|
AUTO_SELECT_SYMBOLS=true
|
||||||
TOP_SYMBOLS_COUNT=12
|
TOP_SYMBOLS_COUNT=12
|
||||||
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
|
SYMBOLS=
|
||||||
STRATEGY_MODE=torch_forecast
|
STRATEGY_MODE=torch_forecast
|
||||||
BASE_INTERVAL=60
|
BASE_INTERVAL=60
|
||||||
TREND_INTERVAL=D
|
TREND_INTERVAL=D
|
||||||
@@ -145,6 +151,8 @@ FAST_LOOP_INTERVAL_SECONDS=1
|
|||||||
FAST_ENTRY_COOLDOWN_SECONDS=20
|
FAST_ENTRY_COOLDOWN_SECONDS=20
|
||||||
MAX_ENTRIES_PER_MINUTE=12
|
MAX_ENTRIES_PER_MINUTE=12
|
||||||
WEBSOCKET_ENABLED=true
|
WEBSOCKET_ENABLED=true
|
||||||
|
MARKET_OBSERVATION_ENABLED=true
|
||||||
|
MARKET_OBSERVATION_SAMPLE_SECONDS=30
|
||||||
MIN_SIGNAL_CONFIDENCE=0.64
|
MIN_SIGNAL_CONFIDENCE=0.64
|
||||||
PATTERN_ANALYSIS_ENABLED=true
|
PATTERN_ANALYSIS_ENABLED=true
|
||||||
PATTERN_SCORE_WEIGHT=0.18
|
PATTERN_SCORE_WEIGHT=0.18
|
||||||
@@ -194,14 +202,19 @@ TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
|
|||||||
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
|
||||||
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
|
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
|
||||||
TIME_SERIES_REBOUND_FALLBACK_ENABLED=false
|
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_QUALITY_GATE=true
|
||||||
TIME_SERIES_REQUIRE_FRESH_MODEL=true
|
TIME_SERIES_REQUIRE_FRESH_MODEL=true
|
||||||
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
|
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
|
||||||
MARKET_TICKER_MAX_AGE_SECONDS=45
|
MARKET_TICKER_MAX_AGE_SECONDS=45
|
||||||
STOP_LOSS_PERCENT=0.04
|
STOP_LOSS_PERCENT=0.04
|
||||||
|
STOP_LOSS_EXIT_ENABLED=false
|
||||||
TAKE_PROFIT_PERCENT=0.035
|
TAKE_PROFIT_PERCENT=0.035
|
||||||
TRAILING_STOP_PERCENT=0.015
|
TRAILING_STOP_PERCENT=0.015
|
||||||
MIN_HOLD_SECONDS=180
|
MIN_HOLD_SECONDS=180
|
||||||
|
PROFIT_ONLY_EXIT_ENABLED=true
|
||||||
|
MIN_EXIT_NET_PERCENT=0.31
|
||||||
ENTRY_COOLDOWN_SECONDS=180
|
ENTRY_COOLDOWN_SECONDS=180
|
||||||
MAX_DAILY_DRAWDOWN_USDT=6
|
MAX_DAILY_DRAWDOWN_USDT=6
|
||||||
TAKER_FEE_RATE=0.001
|
TAKER_FEE_RATE=0.001
|
||||||
@@ -214,6 +227,12 @@ SLIPPAGE_RATE=0.0003
|
|||||||
|
|
||||||
Для быстрого режима рекомендуется оставлять `WEBSOCKET_ENABLED=true`: WebSocket дает частые рыночные обновления, а REST используется как периодическая сверка. Я не могу подтвердить, что быстрый режим повысит прибыльность; он только уменьшает техническую задержку реакции стратегии.
|
Для быстрого режима рекомендуется оставлять `WEBSOCKET_ENABLED=true`: WebSocket дает частые рыночные обновления, а REST используется как периодическая сверка. Я не могу подтвердить, что быстрый режим повысит прибыльность; он только уменьшает техническую задержку реакции стратегии.
|
||||||
|
|
||||||
|
## Profit-only выходы
|
||||||
|
|
||||||
|
При `PROFIT_ONLY_EXIT_ENABLED=true` единый gate перед исполнением блокирует любой обычный `SELL`, если ожидаемый чистый результат с учетом входной и выходной комиссии, bid и проскальзывания ниже `MIN_EXIT_NET_PERCENT`. Это распространяется на RSI, EMA/MACD, ослабление прогноза, trailing и адаптивное снижение экспозиции. Явно помеченные аварийные выходы не блокируются; к ним относятся включенный оператором stop-loss, отказ установки защитного ордера в live и удаление старой paper-пары из торговой вселенной.
|
||||||
|
|
||||||
|
Количество зависших позиций ограничивается `MAX_OPEN_POSITIONS`, `MAX_POSITIONS_PER_SYMBOL`, `MAX_SYMBOL_EXPOSURE_USDT` и `MAX_TOTAL_EXPOSURE_USDT`. Когда лимит достигнут, новые покупки блокируются, но существующие позиции продолжают отслеживаться.
|
||||||
|
|
||||||
## Live-режим
|
## Live-режим
|
||||||
|
|
||||||
Live-режим специально заблокирован. Для включения нужны все значения:
|
Live-режим специально заблокирован. Для включения нужны все значения:
|
||||||
@@ -241,7 +260,13 @@ Live-исполнение ведет журнал order intent до отправ
|
|||||||
|
|
||||||
- `GET /api/health` — healthcheck.
|
- `GET /api/health` — healthcheck.
|
||||||
- `GET /api/status` — статус бота, account snapshot, позиции.
|
- `GET /api/status` — статус бота, account snapshot, позиции.
|
||||||
|
- `GET /api/dashboard/snapshot` — компактный защищённый snapshot для веб-панели; внешний UI использует эквивалентный `/web-api/dashboard/snapshot`, чтобы не смешивать Basic-auth браузера с токеном мобильного API.
|
||||||
- `GET /api/markets` — пары, ticker, свечи, инструменты.
|
- `GET /api/markets` — пары, ticker, свечи, инструменты.
|
||||||
|
- `GET /api/training/market-observations?symbol=BTCUSDT&after_id=0&limit=5000` — защищённая training-token выгрузка L1-наблюдений.
|
||||||
|
- `GET /api/training/market-observations/manifest` — training-token manifest для инкрементальной синхронизации forward L1-данных.
|
||||||
|
- `GET /api/training/shadow` — состояние изолированной shadow-модели и повторного forward-gate.
|
||||||
|
- `POST /api/training/shadow/promote` — атомарное продвижение shadow-модели; возвращает `409`, пока forward-gate не пройден.
|
||||||
|
- `POST /api/training/retrain/auto` — ограниченная training-token команда Windows-agent; ставит только orderbook-retrain без произвольных параметров.
|
||||||
- `GET /api/trades` — последние сделки.
|
- `GET /api/trades` — последние сделки.
|
||||||
- `GET /api/signals` — последние сигналы стратегии.
|
- `GET /api/signals` — последние сигналы стратегии.
|
||||||
- `GET /api/events` — события.
|
- `GET /api/events` — события.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill.
|
- Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill.
|
||||||
- Обзор equity/cash/exposure/PnL и последних решений.
|
- Обзор equity/cash/exposure/PnL и последних решений.
|
||||||
- Удалённый запуск retrain через очередь заданий на боте и закреплённый Windows-компьютер обучения.
|
- Удалённый запуск retrain через очередь заданий на боте и закреплённый Windows-компьютер обучения.
|
||||||
- Расписание retrain на телефоне: Android отправляет команду по расписанию, но обучение идёт на Windows-машине.
|
- Расписание retrain через Android WorkManager: команда отправляется только при наличии сети, а обучение идёт на Windows-машине.
|
||||||
- Настройки API, токена команд, тёмной/светлой темы.
|
- Настройки API, токена команд, тёмной/светлой темы.
|
||||||
- Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой.
|
- Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой.
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ https://tb.kusoft.xyz
|
|||||||
|
|
||||||
## Переобучение
|
## Переобучение
|
||||||
|
|
||||||
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `SEVENHILL` (`G:\Repos\TradeBot`) сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети.
|
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на этой машине сам выходит в интернет, забирает задание, обучает модель и отправляет проверенный bundle обратно боту. Имя и путь активного worker приложение получает от сервера, без прошитого имени компьютера.
|
||||||
|
|
||||||
## Live-торговля
|
## Live-торговля
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "xyz.kusoft.tradebotmonitor"
|
namespace = "xyz.kusoft.tradebotmonitor"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "xyz.kusoft.tradebotmonitor"
|
applicationId = "xyz.kusoft.tradebotmonitor"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = 21
|
versionCode = 24
|
||||||
versionName = "0.4.2"
|
versionName = "0.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.work:work-runtime:2.11.2")
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@drawable/ic_launcher"
|
android:icon="@drawable/ic_launcher"
|
||||||
android:label="TradeBot AI"
|
android:label="TradeBot AI"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@drawable/ic_launcher"
|
android:roundIcon="@drawable/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
@@ -22,16 +23,5 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".RetrainAlarmReceiver"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".BootReceiver"
|
|
||||||
android:exported="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -18,12 +18,9 @@ class AppPrefs(context: Context) {
|
|||||||
}
|
}
|
||||||
val trainingComputerName = prefs.getString("training_computer_name", null)?.trim()
|
val trainingComputerName = prefs.getString("training_computer_name", null)?.trim()
|
||||||
val trainingComputerPath = prefs.getString("training_computer_path", null)?.trim()
|
val trainingComputerPath = prefs.getString("training_computer_path", null)?.trim()
|
||||||
if (
|
val staleFallback = trainingComputerName in setOf("SEVENHILL", "DESKTOP-TMFDL0H") ||
|
||||||
trainingComputerName.isNullOrBlank() ||
|
trainingComputerPath in setOf("G:\\Repos\\TradeBot", "C:\\Repos\\TradeBot")
|
||||||
trainingComputerName == LEGACY_TRAINING_COMPUTER_NAME ||
|
if (trainingComputerName.isNullOrBlank() || trainingComputerPath.isNullOrBlank() || staleFallback) {
|
||||||
trainingComputerPath.isNullOrBlank() ||
|
|
||||||
trainingComputerPath == LEGACY_TRAINING_COMPUTER_PATH
|
|
||||||
) {
|
|
||||||
pinDefaultTrainingComputer()
|
pinDefaultTrainingComputer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,10 +138,8 @@ class AppPrefs(context: Context) {
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz"
|
const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz"
|
||||||
const val LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787"
|
const val LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787"
|
||||||
const val DEFAULT_TRAINING_COMPUTER_NAME = "SEVENHILL"
|
const val DEFAULT_TRAINING_COMPUTER_NAME = "Ожидание Windows-agent"
|
||||||
const val DEFAULT_TRAINING_COMPUTER_PATH = "G:\\Repos\\TradeBot"
|
const val DEFAULT_TRAINING_COMPUTER_PATH = "Имя и путь поступят от сервера"
|
||||||
const val LEGACY_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
|
|
||||||
const val LEGACY_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
|
|
||||||
const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1"
|
const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-24
@@ -203,7 +203,7 @@ class MainActivity : Activity() {
|
|||||||
|
|
||||||
private fun shouldRenderAfterRefresh(silent: Boolean, hadSnapshot: Boolean, hadError: Boolean, trainingChanged: Boolean): Boolean {
|
private fun shouldRenderAfterRefresh(silent: Boolean, hadSnapshot: Boolean, hadError: Boolean, trainingChanged: Boolean): Boolean {
|
||||||
val focused = contentHost.findFocus()
|
val focused = contentHost.findFocus()
|
||||||
if (activeTab == "settings" && focused is EditText) {
|
if (focused is EditText) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
@@ -502,6 +502,8 @@ class MainActivity : Activity() {
|
|||||||
"Сохранить" to {
|
"Сохранить" to {
|
||||||
prefs.apiBaseUrl = apiInput.text.toString()
|
prefs.apiBaseUrl = apiInput.text.toString()
|
||||||
prefs.commandToken = tokenInput.text.toString()
|
prefs.commandToken = tokenInput.text.toString()
|
||||||
|
apiInput.clearFocus()
|
||||||
|
tokenInput.clearFocus()
|
||||||
toast("Подключение сохранено")
|
toast("Подключение сохранено")
|
||||||
refreshData(silent = false)
|
refreshData(silent = false)
|
||||||
},
|
},
|
||||||
@@ -1102,6 +1104,8 @@ class MainActivity : Activity() {
|
|||||||
addView(trainingComputerPanel(retrain).top(dp(12)))
|
addView(trainingComputerPanel(retrain).top(dp(12)))
|
||||||
addView(thinDivider().top(dp(12)))
|
addView(thinDivider().top(dp(12)))
|
||||||
addView(trainingProcessPanel(coordination))
|
addView(trainingProcessPanel(coordination))
|
||||||
|
addView(orderbookStagePanel(coordination).top(dp(10)))
|
||||||
|
addView(shadowStagePanel(retrain.optJSONObject("shadow") ?: JSONObject()).top(dp(10)))
|
||||||
if (displayedEvaluation.optJSONObject("candidate") != null) {
|
if (displayedEvaluation.optJSONObject("candidate") != null) {
|
||||||
addView(guardSummaryPanel(displayedEvaluation).top(dp(10)))
|
addView(guardSummaryPanel(displayedEvaluation).top(dp(10)))
|
||||||
}
|
}
|
||||||
@@ -1153,6 +1157,36 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun orderbookStagePanel(coordination: JSONObject): View =
|
||||||
|
LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val summary = coordination.optJSONObject("latest_job")?.optJSONObject("summary") ?: JSONObject()
|
||||||
|
val state = summary.optStringClean("state")
|
||||||
|
val minimum = summary.optInt("minimum_covered_buckets", 240)
|
||||||
|
val eligible = summary.optInt("eligible_symbol_count", 0)
|
||||||
|
val requiredSymbols = summary.optInt("minimum_symbols", 2)
|
||||||
|
val coverage = summary.optJSONObject("covered_buckets_by_symbol") ?: JSONObject()
|
||||||
|
val bestCoverage = coverage.keys().asSequence().map { coverage.optInt(it, 0) }.maxOrNull() ?: 0
|
||||||
|
addView(keyValueLine("Forward-стакан", if (state == "ready") "готов к обучению" else "накапливается", if (state == "ready") palette.green else palette.amber))
|
||||||
|
addView(keyValueLine("Лучшее покрытие", "$bestCoverage / $minimum свечей").top(dp(4)))
|
||||||
|
addView(keyValueLine("Готовые пары", "$eligible / $requiredSymbols").top(dp(4)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shadowStagePanel(shadow: JSONObject): View =
|
||||||
|
LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val state = shadow.optStringClean("state").ifBlank { "нет модели" }
|
||||||
|
val color = when (state) {
|
||||||
|
"passed" -> palette.green
|
||||||
|
"failed" -> palette.red
|
||||||
|
else -> palette.amber
|
||||||
|
}
|
||||||
|
addView(keyValueLine("Shadow gate", state, color))
|
||||||
|
addView(keyValueLine("Forward-прогнозы", "${shadow.optInt("settled_predictions", 0)} / ${shadow.optInt("total_predictions", 0)}").top(dp(4)))
|
||||||
|
addView(keyValueLine("Shadow P&L", signedPercent(shadow.optDouble("total_net_percent", 0.0)), colorForSigned(shadow.optDouble("total_net_percent", 0.0))).top(dp(4)))
|
||||||
|
addView(keyValueLine("Direction / Brier", "${percent(shadow.optDouble("direction_accuracy", 0.0) * 100.0, 1)} / ${number(shadow.optDouble("brier", 0.0), 4)}").top(dp(4)))
|
||||||
|
}
|
||||||
|
|
||||||
private fun guardSummaryPanel(retrain: JSONObject): View =
|
private fun guardSummaryPanel(retrain: JSONObject): View =
|
||||||
LinearLayout(this).apply {
|
LinearLayout(this).apply {
|
||||||
val accepted = retrain.optBoolean("accepted", false)
|
val accepted = retrain.optBoolean("accepted", false)
|
||||||
@@ -1287,6 +1321,8 @@ class MainActivity : Activity() {
|
|||||||
"Подключиться" to {
|
"Подключиться" to {
|
||||||
prefs.apiBaseUrl = apiInput.text.toString()
|
prefs.apiBaseUrl = apiInput.text.toString()
|
||||||
prefs.commandToken = tokenInput.text.toString()
|
prefs.commandToken = tokenInput.text.toString()
|
||||||
|
apiInput.clearFocus()
|
||||||
|
tokenInput.clearFocus()
|
||||||
toast("Доступ сохранен, проверяю API")
|
toast("Доступ сохранен, проверяю API")
|
||||||
refreshData(silent = false)
|
refreshData(silent = false)
|
||||||
},
|
},
|
||||||
@@ -1512,12 +1548,15 @@ class MainActivity : Activity() {
|
|||||||
private fun rankedMarkets(data: BotSnapshot): List<MarketItem> =
|
private fun rankedMarkets(data: BotSnapshot): List<MarketItem> =
|
||||||
orderedMarkets(data.markets).sortedWith(
|
orderedMarkets(data.markets).sortedWith(
|
||||||
compareByDescending<MarketItem> { marketRankScore(it, data.signalsBySymbol[it.symbol]) }
|
compareByDescending<MarketItem> { marketRankScore(it, data.signalsBySymbol[it.symbol]) }
|
||||||
.thenBy { fixedSymbolIndex(it.symbol) }
|
.thenByDescending { it.ticker?.turnover24h ?: 0.0 }
|
||||||
.thenBy { it.symbol },
|
.thenBy { it.symbol },
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun orderedMarkets(markets: List<MarketItem>): List<MarketItem> =
|
private fun orderedMarkets(markets: List<MarketItem>): List<MarketItem> =
|
||||||
markets.sortedWith(compareBy({ fixedSymbolIndex(it.symbol) }, { it.symbol }))
|
markets.sortedWith(
|
||||||
|
compareByDescending<MarketItem> { it.ticker?.turnover24h ?: 0.0 }
|
||||||
|
.thenBy { it.symbol },
|
||||||
|
)
|
||||||
|
|
||||||
private fun marketRankScore(market: MarketItem, signal: SignalData?): Double {
|
private fun marketRankScore(market: MarketItem, signal: SignalData?): Double {
|
||||||
val actionScore = when (normalizedAction(signal?.action)) {
|
val actionScore = when (normalizedAction(signal?.action)) {
|
||||||
@@ -1615,11 +1654,6 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fixedSymbolIndex(symbol: String): Int {
|
|
||||||
val index = FIXED_SYMBOLS.indexOf(symbol.uppercase(Locale.US))
|
|
||||||
return if (index >= 0) index else FIXED_SYMBOLS.size + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun trainingStatusSignature(retrain: JSONObject): String =
|
private fun trainingStatusSignature(retrain: JSONObject): String =
|
||||||
(retrain.optJSONObject("coordination") ?: JSONObject()).toString()
|
(retrain.optJSONObject("coordination") ?: JSONObject()).toString()
|
||||||
|
|
||||||
@@ -1895,20 +1929,4 @@ class MainActivity : Activity() {
|
|||||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val FIXED_SYMBOLS = listOf(
|
|
||||||
"BTCUSDT",
|
|
||||||
"ETHUSDT",
|
|
||||||
"HYPEUSDT",
|
|
||||||
"SOLUSDT",
|
|
||||||
"XRPUSDT",
|
|
||||||
"XPLUSDT",
|
|
||||||
"WLDUSDT",
|
|
||||||
"MNTUSDT",
|
|
||||||
"HUSDT",
|
|
||||||
"XAUTUSDT",
|
|
||||||
"IPUSDT",
|
|
||||||
"AAVEUSDT",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-47
@@ -1,65 +1,55 @@
|
|||||||
package xyz.kusoft.tradebotmonitor
|
package xyz.kusoft.tradebotmonitor
|
||||||
|
|
||||||
import android.app.AlarmManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import androidx.work.Constraints
|
||||||
import java.util.concurrent.Executors
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
object RetrainScheduler {
|
object RetrainScheduler {
|
||||||
private const val ACTION_RETRAIN = "xyz.kusoft.tradebotmonitor.RETRAIN"
|
private const val UNIQUE_WORK_NAME = "tradebot-periodic-retrain"
|
||||||
private const val REQUEST_CODE = 6406
|
|
||||||
|
|
||||||
fun schedule(context: Context, hours: Int) {
|
fun schedule(context: Context, hours: Int) {
|
||||||
val interval = hours.coerceAtLeast(1) * 60L * 60L * 1000L
|
val constraints = Constraints.Builder()
|
||||||
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
manager.setInexactRepeating(
|
.build()
|
||||||
AlarmManager.RTC_WAKEUP,
|
val request = PeriodicWorkRequest.Builder(
|
||||||
System.currentTimeMillis() + interval,
|
RetrainWorker::class.java,
|
||||||
interval,
|
hours.coerceAtLeast(1).toLong(),
|
||||||
pendingIntent(context),
|
TimeUnit.HOURS,
|
||||||
|
)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
UNIQUE_WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
request,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancel(context: Context) {
|
fun cancel(context: Context) {
|
||||||
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||||
manager.cancel(pendingIntent(context))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pendingIntent(context: Context): PendingIntent =
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
REQUEST_CODE,
|
|
||||||
Intent(context, RetrainAlarmReceiver::class.java).setAction(ACTION_RETRAIN),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class RetrainAlarmReceiver : BroadcastReceiver() {
|
class RetrainWorker(
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
context: Context,
|
||||||
val pending = goAsync()
|
parameters: WorkerParameters,
|
||||||
val executor = Executors.newSingleThreadExecutor()
|
) : Worker(context, parameters) {
|
||||||
executor.execute {
|
override fun doWork(): Result {
|
||||||
try {
|
val prefs = AppPrefs(applicationContext)
|
||||||
val prefs = AppPrefs(context)
|
if (!prefs.retrainScheduleEnabled || prefs.commandToken.isBlank()) {
|
||||||
if (prefs.retrainScheduleEnabled) {
|
return Result.success()
|
||||||
|
}
|
||||||
|
return try {
|
||||||
TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
|
TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
|
||||||
}
|
Result.success()
|
||||||
} finally {
|
} catch (_: Exception) {
|
||||||
pending.finish()
|
Result.retry()
|
||||||
executor.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ class TradeBotApi(
|
|||||||
qualityScore = quality.optDouble("score", 0.0),
|
qualityScore = quality.optDouble("score", 0.0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return output.sortedBy { it.symbol }
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseTicker(row: JSONObject): TickerData =
|
private fun parseTicker(row: JSONObject): TickerData =
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup disableIfNoEncryptionCapabilities="true">
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="root" path="." />
|
||||||
|
<exclude domain="file" path="." />
|
||||||
|
<exclude domain="database" path="." />
|
||||||
|
<exclude domain="sharedpref" path="." />
|
||||||
|
<exclude domain="external" path="." />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
|
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
|
||||||
<text x="338" y="48" class="text small">91%</text>
|
<text x="338" y="48" class="text small">91%</text>
|
||||||
<text x="34" y="88" class="text h2">Рынки</text>
|
<text x="34" y="88" class="text h2">Рынки</text>
|
||||||
<text x="34" y="114" class="muted small">12 фиксированных spot-пар</text>
|
<text x="34" y="114" class="muted small">Динамические Bybit spot-пары</text>
|
||||||
<rect x="34" y="136" width="340" height="42" rx="7" fill="#11141a" stroke="#242a36"/>
|
<rect x="34" y="136" width="340" height="42" rx="7" fill="#11141a" stroke="#242a36"/>
|
||||||
<text x="52" y="162" class="dim small">Поиск пары или сигнала</text>
|
<text x="52" y="162" class="dim small">Поиск пары или сигнала</text>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@@ -1,7 +1,7 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=60000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Crypto spot trading bot package."""
|
"""Crypto spot trading bot package."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "1.1.2"
|
||||||
|
|||||||
+145
-6
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -12,9 +13,13 @@ from crypto_spot_bot.learning import TradeLearner
|
|||||||
from crypto_spot_bot.market_data import MarketData
|
from crypto_spot_bot.market_data import MarketData
|
||||||
from crypto_spot_bot.models import BotStatus, Signal, Ticker, utc_now
|
from crypto_spot_bot.models import BotStatus, Signal, Ticker, utc_now
|
||||||
from crypto_spot_bot.patterns import PatternAnalyzer
|
from crypto_spot_bot.patterns import PatternAnalyzer
|
||||||
from crypto_spot_bot.strategy import SpotStrategy, torch_model_readiness_reasons
|
from crypto_spot_bot.strategy import (
|
||||||
|
SpotStrategy,
|
||||||
|
apply_profit_only_exit_policy,
|
||||||
|
torch_model_readiness_reasons,
|
||||||
|
)
|
||||||
from crypto_spot_bot.storage import Storage
|
from crypto_spot_bot.storage import Storage
|
||||||
from crypto_spot_bot.time_series import TimeSeriesForecaster
|
from crypto_spot_bot.time_series import TimeSeriesForecaster, _barrier_outcome
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -31,6 +36,7 @@ class CryptoSpotBot:
|
|||||||
pattern_analyzer: PatternAnalyzer,
|
pattern_analyzer: PatternAnalyzer,
|
||||||
learner: TradeLearner,
|
learner: TradeLearner,
|
||||||
forecaster: TimeSeriesForecaster | None = None,
|
forecaster: TimeSeriesForecaster | None = None,
|
||||||
|
shadow_forecaster: TimeSeriesForecaster | None = None,
|
||||||
llm_advisor=None,
|
llm_advisor=None,
|
||||||
):
|
):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -41,6 +47,7 @@ class CryptoSpotBot:
|
|||||||
self.pattern_analyzer = pattern_analyzer
|
self.pattern_analyzer = pattern_analyzer
|
||||||
self.learner = learner
|
self.learner = learner
|
||||||
self.forecaster = forecaster
|
self.forecaster = forecaster
|
||||||
|
self.shadow_forecaster = shadow_forecaster
|
||||||
self.llm_advisor = llm_advisor
|
self.llm_advisor = llm_advisor
|
||||||
self.running = False
|
self.running = False
|
||||||
self.started_at: datetime | None = None
|
self.started_at: datetime | None = None
|
||||||
@@ -52,6 +59,8 @@ class CryptoSpotBot:
|
|||||||
self._last_reconciliation_at: datetime | None = None
|
self._last_reconciliation_at: datetime | None = None
|
||||||
self._last_prune_at: datetime | None = None
|
self._last_prune_at: datetime | None = None
|
||||||
self._consecutive_loop_errors = 0
|
self._consecutive_loop_errors = 0
|
||||||
|
self._orderbook_feature_cache_key: tuple[tuple[str, int], ...] = ()
|
||||||
|
self._orderbook_feature_cache: dict[str, dict[int, dict[str, float]]] = {}
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
if self.running:
|
if self.running:
|
||||||
@@ -160,6 +169,8 @@ class CryptoSpotBot:
|
|||||||
adaptive_rules["reduce_now"] = position.id is not None and position.id == reduction_candidate_id
|
adaptive_rules["reduce_now"] = position.id is not None and position.id == reduction_candidate_id
|
||||||
learning = {"adaptive_rules": adaptive_rules}
|
learning = {"adaptive_rules": adaptive_rules}
|
||||||
signal = self.strategy.exit_signal(position, candles, ticker, learning, forecast)
|
signal = self.strategy.exit_signal(position, candles, ticker, learning, forecast)
|
||||||
|
if ticker is not None:
|
||||||
|
signal = apply_profit_only_exit_policy(self.settings, position, ticker, signal)
|
||||||
self._record_signal(signal)
|
self._record_signal(signal)
|
||||||
if signal.action == "SELL" and ticker is not None:
|
if signal.action == "SELL" and ticker is not None:
|
||||||
await asyncio.to_thread(self.broker.sell, position, ticker, signal.reason)
|
await asyncio.to_thread(self.broker.sell, position, ticker, signal.reason)
|
||||||
@@ -366,14 +377,28 @@ class CryptoSpotBot:
|
|||||||
volume_24h=0.0,
|
volume_24h=0.0,
|
||||||
change_24h=0.0,
|
change_24h=0.0,
|
||||||
)
|
)
|
||||||
self.broker.sell(
|
candidate = Signal(
|
||||||
position,
|
position.symbol,
|
||||||
synthetic_ticker,
|
"SELL",
|
||||||
f"{self.settings.strategy_mode}: закрыта старая paper-позиция вне списка разрешенных пар",
|
0.5,
|
||||||
|
f"{self.settings.strategy_mode}: старая paper-позиция вне списка разрешенных пар",
|
||||||
|
{
|
||||||
|
"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(
|
self.storage.event(
|
||||||
f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}"
|
f"{position.symbol}: старая paper-позиция закрыта при переходе на {self.settings.strategy_mode}"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
self.storage.event(
|
||||||
|
f"{position.symbol}: старая paper-позиция сохранена политикой profit-only",
|
||||||
|
"WARN",
|
||||||
|
)
|
||||||
|
|
||||||
def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None:
|
def _reduction_candidate_id(self, prices: dict[str, float]) -> int | None:
|
||||||
rules = self._with_exposure_context(self.learner.state.adaptive_rules or {})
|
rules = self._with_exposure_context(self.learner.state.adaptive_rules or {})
|
||||||
@@ -397,6 +422,11 @@ class CryptoSpotBot:
|
|||||||
self.settings.pattern_analysis_enabled
|
self.settings.pattern_analysis_enabled
|
||||||
or self.settings.grid_trading_enabled
|
or self.settings.grid_trading_enabled
|
||||||
or self.settings.rebound_trading_enabled
|
or self.settings.rebound_trading_enabled
|
||||||
|
or (
|
||||||
|
self.settings.strategy_mode == "torch_forecast"
|
||||||
|
and self.settings.time_series_trend_fallback_enabled
|
||||||
|
and self.settings.time_series_fallback_mode == "legacy"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if self.settings.strategy_mode == "trend_macd" or not patterns_needed:
|
if self.settings.strategy_mode == "trend_macd" or not patterns_needed:
|
||||||
self.market.patterns = {}
|
self.market.patterns = {}
|
||||||
@@ -411,6 +441,25 @@ class CryptoSpotBot:
|
|||||||
self.market.patterns = patterns
|
self.market.patterns = patterns
|
||||||
|
|
||||||
def _update_forecasts(self) -> None:
|
def _update_forecasts(self) -> None:
|
||||||
|
cache_key = tuple(
|
||||||
|
(symbol, rows[-1].timestamp if rows else 0)
|
||||||
|
for symbol, rows in sorted(self.market.candles.items())
|
||||||
|
)
|
||||||
|
earliest_timestamp = min(
|
||||||
|
(rows[0].timestamp for rows in self.market.candles.values() if rows),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
if cache_key != self._orderbook_feature_cache_key:
|
||||||
|
orderbook_features, _manifest = self.storage.recent_aggregated_orderbook_features(
|
||||||
|
interval=self.settings.base_interval,
|
||||||
|
symbols=self.market.symbols,
|
||||||
|
after_timestamp_ms=earliest_timestamp,
|
||||||
|
min_samples_per_bucket=20,
|
||||||
|
)
|
||||||
|
self._orderbook_feature_cache = orderbook_features
|
||||||
|
self._orderbook_feature_cache_key = cache_key
|
||||||
|
else:
|
||||||
|
orderbook_features = self._orderbook_feature_cache
|
||||||
if (
|
if (
|
||||||
self.forecaster is None
|
self.forecaster is None
|
||||||
or not self.settings.time_series_forecast_enabled
|
or not self.settings.time_series_forecast_enabled
|
||||||
@@ -424,8 +473,98 @@ class CryptoSpotBot:
|
|||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
market_candles=self.market.candles,
|
market_candles=self.market.candles,
|
||||||
trend_candles=self.market.trend_candles.get(symbol, []),
|
trend_candles=self.market.trend_candles.get(symbol, []),
|
||||||
|
orderbook_features=orderbook_features,
|
||||||
).as_dict()
|
).as_dict()
|
||||||
self.market.forecasts = forecasts
|
self.market.forecasts = forecasts
|
||||||
|
self._update_shadow_forecasts(orderbook_features)
|
||||||
|
|
||||||
|
def _update_shadow_forecasts(
|
||||||
|
self,
|
||||||
|
orderbook_features: dict[str, dict[int, dict[str, float]]],
|
||||||
|
) -> None:
|
||||||
|
if self.shadow_forecaster is None:
|
||||||
|
self.market.shadow_forecasts = {}
|
||||||
|
return
|
||||||
|
model_sha256 = self.shadow_forecaster.artifact_sha256()
|
||||||
|
if not model_sha256:
|
||||||
|
self.market.shadow_forecasts = {}
|
||||||
|
return
|
||||||
|
forecasts: dict[str, dict] = {}
|
||||||
|
for symbol in self.market.symbols:
|
||||||
|
candles = self.market.candles.get(symbol, [])
|
||||||
|
forecast = self.shadow_forecaster.forecast(
|
||||||
|
candles,
|
||||||
|
symbol=symbol,
|
||||||
|
market_candles=self.market.candles,
|
||||||
|
trend_candles=self.market.trend_candles.get(symbol, []),
|
||||||
|
orderbook_features=orderbook_features,
|
||||||
|
).as_dict()
|
||||||
|
forecast["shadow"] = True
|
||||||
|
forecast["model_sha256"] = model_sha256
|
||||||
|
forecasts[symbol] = forecast
|
||||||
|
self._record_and_settle_shadow(symbol, candles, forecast, model_sha256)
|
||||||
|
self.market.shadow_forecasts = forecasts
|
||||||
|
|
||||||
|
def _record_and_settle_shadow(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
candles: list,
|
||||||
|
forecast: dict,
|
||||||
|
model_sha256: str,
|
||||||
|
) -> None:
|
||||||
|
if candles and forecast.get("usable"):
|
||||||
|
probability = float(
|
||||||
|
forecast.get("probability_take_profit_first")
|
||||||
|
if forecast.get("probability_take_profit_first") is not None
|
||||||
|
else forecast.get("probability_up", 0.5)
|
||||||
|
)
|
||||||
|
expected = float(forecast.get("expected_return_percent", 0.0) or 0.0)
|
||||||
|
eligible = bool(
|
||||||
|
not forecast.get("block_entry")
|
||||||
|
and expected >= float(forecast.get("calibrated_min_edge_percent", 0.0) or 0.0)
|
||||||
|
and probability >= float(forecast.get("calibrated_min_probability_up", 0.5) or 0.5)
|
||||||
|
)
|
||||||
|
self.storage.insert_shadow_prediction(
|
||||||
|
model_sha256=model_sha256,
|
||||||
|
symbol=symbol,
|
||||||
|
forecast_timestamp_ms=candles[-1].timestamp,
|
||||||
|
horizon=max(1, int(forecast.get("horizon", 1) or 1)),
|
||||||
|
reference_price=float(candles[-1].close),
|
||||||
|
expected_return_percent=expected,
|
||||||
|
probability_up=probability,
|
||||||
|
eligible_signal=eligible,
|
||||||
|
)
|
||||||
|
if not candles:
|
||||||
|
return
|
||||||
|
indexes = {candle.timestamp: index for index, candle in enumerate(candles)}
|
||||||
|
round_trip_cost = 2.0 * (
|
||||||
|
float(self.settings.taker_fee_rate) + float(self.settings.slippage_rate)
|
||||||
|
)
|
||||||
|
for row in self.storage.pending_shadow_predictions(
|
||||||
|
model_sha256=model_sha256,
|
||||||
|
symbol=symbol,
|
||||||
|
):
|
||||||
|
index = indexes.get(int(row.get("forecast_timestamp_ms", 0) or 0))
|
||||||
|
horizon = max(1, int(row.get("horizon", 1) or 1))
|
||||||
|
if index is None or index + horizon >= len(candles):
|
||||||
|
continue
|
||||||
|
outcome = _barrier_outcome(
|
||||||
|
candles,
|
||||||
|
end_index=index,
|
||||||
|
horizon=horizon,
|
||||||
|
stop_loss_percent=float(self.settings.stop_loss_percent),
|
||||||
|
take_profit_percent=float(self.settings.take_profit_percent),
|
||||||
|
round_trip_cost=round_trip_cost,
|
||||||
|
)
|
||||||
|
if outcome is None:
|
||||||
|
continue
|
||||||
|
actual_log_return, take_profit_first = outcome
|
||||||
|
actual_return_percent = (math.exp(actual_log_return) - 1.0) * 100.0
|
||||||
|
self.storage.settle_shadow_prediction(
|
||||||
|
int(row["id"]),
|
||||||
|
actual_return_percent=actual_return_percent,
|
||||||
|
take_profit_first=take_profit_first >= 0.5,
|
||||||
|
)
|
||||||
|
|
||||||
def status(self) -> BotStatus:
|
def status(self) -> BotStatus:
|
||||||
live_ready = self.settings.live_ready
|
live_ready = self.settings.live_ready
|
||||||
|
|||||||
@@ -220,6 +220,10 @@ class BybitClient:
|
|||||||
return candles
|
return candles
|
||||||
|
|
||||||
def orderbook_top(self, symbol: str) -> tuple[float, float]:
|
def orderbook_top(self, symbol: str) -> tuple[float, float]:
|
||||||
|
bid, _bid_size, ask, _ask_size = self.orderbook_level_one(symbol)
|
||||||
|
return bid, ask
|
||||||
|
|
||||||
|
def orderbook_level_one(self, symbol: str) -> tuple[float, float, float, float]:
|
||||||
result = self.public_get(
|
result = self.public_get(
|
||||||
"/v5/market/orderbook",
|
"/v5/market/orderbook",
|
||||||
{"category": "spot", "symbol": symbol, "limit": 1},
|
{"category": "spot", "symbol": symbol, "limit": 1},
|
||||||
@@ -227,8 +231,10 @@ class BybitClient:
|
|||||||
bids = result.get("b") or []
|
bids = result.get("b") or []
|
||||||
asks = result.get("a") or []
|
asks = result.get("a") or []
|
||||||
bid = _float(bids[0][0]) if bids else 0.0
|
bid = _float(bids[0][0]) if bids else 0.0
|
||||||
|
bid_size = _float(bids[0][1]) if bids and len(bids[0]) > 1 else 0.0
|
||||||
ask = _float(asks[0][0]) if asks else 0.0
|
ask = _float(asks[0][0]) if asks else 0.0
|
||||||
return bid, ask
|
ask_size = _float(asks[0][1]) if asks and len(asks[0]) > 1 else 0.0
|
||||||
|
return bid, bid_size, ask, ask_size
|
||||||
|
|
||||||
def place_spot_market_order(
|
def place_spot_market_order(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class Settings:
|
|||||||
database_path: Path
|
database_path: Path
|
||||||
log_path: Path
|
log_path: Path
|
||||||
env_file_path: Path
|
env_file_path: Path
|
||||||
|
profit_only_exit_enabled: bool = True
|
||||||
api_auth_token: str = ""
|
api_auth_token: str = ""
|
||||||
training_worker_token: str = ""
|
training_worker_token: str = ""
|
||||||
trusted_proxy_user_header: str = ""
|
trusted_proxy_user_header: str = ""
|
||||||
@@ -169,17 +170,26 @@ class Settings:
|
|||||||
hold_signal_sample_seconds: int = 60
|
hold_signal_sample_seconds: int = 60
|
||||||
storage_retention_days: int = 30
|
storage_retention_days: int = 30
|
||||||
storage_prune_interval_seconds: int = 3600
|
storage_prune_interval_seconds: int = 3600
|
||||||
|
bybit_rest_base_url_override: str = ""
|
||||||
|
bybit_websocket_url_override: str = ""
|
||||||
|
time_series_fallback_mode: str = "trend_macd"
|
||||||
|
market_observation_enabled: bool = True
|
||||||
|
market_observation_sample_seconds: float = 30.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rest_base_url(self) -> str:
|
def rest_base_url(self) -> str:
|
||||||
return "https://api-testnet.bybit.com" if self.bybit_testnet else "https://api.bybit.com"
|
if self.bybit_rest_base_url_override:
|
||||||
|
return self.bybit_rest_base_url_override.rstrip("/")
|
||||||
|
return "https://api-testnet.bybit.com" if self.bybit_testnet else "https://api.bybit.kz"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def websocket_url(self) -> str:
|
def websocket_url(self) -> str:
|
||||||
|
if self.bybit_websocket_url_override:
|
||||||
|
return self.bybit_websocket_url_override
|
||||||
return (
|
return (
|
||||||
"wss://stream-testnet.bybit.com/v5/public/spot"
|
"wss://stream-testnet.bybit.com/v5/public/spot"
|
||||||
if self.bybit_testnet
|
if self.bybit_testnet
|
||||||
else "wss://stream.bybit.com/v5/public/spot"
|
else "wss://stream.bybit.kz/v5/public/spot"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -220,7 +230,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
strategy_mode = os.getenv("STRATEGY_MODE", "torch_forecast").strip().lower()
|
strategy_mode = os.getenv("STRATEGY_MODE", "torch_forecast").strip().lower()
|
||||||
if strategy_mode not in STRATEGY_MODES:
|
if strategy_mode not in STRATEGY_MODES:
|
||||||
raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast")
|
raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast")
|
||||||
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False)
|
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", True)
|
||||||
top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS))
|
top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS))
|
||||||
requested_symbols = _symbols_env("SYMBOLS")
|
requested_symbols = _symbols_env("SYMBOLS")
|
||||||
symbols = requested_symbols if requested_symbols else (() if auto_select_symbols else FIXED_SPOT_SYMBOLS)
|
symbols = requested_symbols if requested_symbols else (() if auto_select_symbols else FIXED_SPOT_SYMBOLS)
|
||||||
@@ -323,6 +333,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
database_path=Path(os.getenv("DATABASE_PATH", "runtime/tradebot.sqlite3")),
|
database_path=Path(os.getenv("DATABASE_PATH", "runtime/tradebot.sqlite3")),
|
||||||
log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")),
|
log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")),
|
||||||
env_file_path=env_path,
|
env_file_path=env_path,
|
||||||
|
profit_only_exit_enabled=_bool_env("PROFIT_ONLY_EXIT_ENABLED", True),
|
||||||
api_auth_token=os.getenv("TRADEBOT_API_TOKEN", "").strip(),
|
api_auth_token=os.getenv("TRADEBOT_API_TOKEN", "").strip(),
|
||||||
training_worker_token=os.getenv("TRADEBOT_TRAINING_TOKEN", "").strip(),
|
training_worker_token=os.getenv("TRADEBOT_TRAINING_TOKEN", "").strip(),
|
||||||
trusted_proxy_user_header=os.getenv("TRUSTED_PROXY_USER_HEADER", "").strip(),
|
trusted_proxy_user_header=os.getenv("TRUSTED_PROXY_USER_HEADER", "").strip(),
|
||||||
@@ -343,6 +354,18 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
|||||||
hold_signal_sample_seconds=_int_env("HOLD_SIGNAL_SAMPLE_SECONDS", 60),
|
hold_signal_sample_seconds=_int_env("HOLD_SIGNAL_SAMPLE_SECONDS", 60),
|
||||||
storage_retention_days=_int_env("STORAGE_RETENTION_DAYS", 30),
|
storage_retention_days=_int_env("STORAGE_RETENTION_DAYS", 30),
|
||||||
storage_prune_interval_seconds=_int_env("STORAGE_PRUNE_INTERVAL_SECONDS", 3600),
|
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)
|
_validate_settings(settings)
|
||||||
if settings.trading_mode == "live" and not settings.live_ready:
|
if settings.trading_mode == "live" and not settings.live_ready:
|
||||||
@@ -371,6 +394,8 @@ def _validate_settings(settings: Settings) -> None:
|
|||||||
errors.append("position count limits must be positive")
|
errors.append("position count limits must be positive")
|
||||||
if settings.taker_fee_rate < 0 or settings.slippage_rate < 0:
|
if settings.taker_fee_rate < 0 or settings.slippage_rate < 0:
|
||||||
errors.append("TAKER_FEE_RATE and SLIPPAGE_RATE must be non-negative")
|
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:
|
if settings.market_ticker_max_age_seconds <= 0:
|
||||||
errors.append("MARKET_TICKER_MAX_AGE_SECONDS must be positive")
|
errors.append("MARKET_TICKER_MAX_AGE_SECONDS must be positive")
|
||||||
if settings.time_series_model_max_age_hours <= 0:
|
if settings.time_series_model_max_age_hours <= 0:
|
||||||
@@ -379,6 +404,10 @@ def _validate_settings(settings: Settings) -> None:
|
|||||||
errors.append("LIVE_ORDER_FILL_TIMEOUT_SECONDS must be positive")
|
errors.append("LIVE_ORDER_FILL_TIMEOUT_SECONDS must be positive")
|
||||||
if settings.live_reconciliation_interval_seconds <= 0:
|
if settings.live_reconciliation_interval_seconds <= 0:
|
||||||
errors.append("LIVE_RECONCILIATION_INTERVAL_SECONDS must be positive")
|
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:
|
if errors:
|
||||||
raise ValueError("; ".join(errors))
|
raise ValueError("; ".join(errors))
|
||||||
|
|
||||||
|
|||||||
+189
-10
@@ -4,14 +4,18 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Response
|
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from crypto_spot_bot.analytics import analytics_snapshot
|
from crypto_spot_bot.analytics import analytics_snapshot
|
||||||
from crypto_spot_bot.auth import ApiAuthorizer
|
from crypto_spot_bot.auth import ApiAuthorizer
|
||||||
from crypto_spot_bot.bot import CryptoSpotBot
|
from crypto_spot_bot.bot import CryptoSpotBot
|
||||||
|
from crypto_spot_bot import __version__
|
||||||
from crypto_spot_bot.bybit import BybitClient
|
from crypto_spot_bot.bybit import BybitClient
|
||||||
from crypto_spot_bot.config import Settings, load_settings, update_env_value
|
from crypto_spot_bot.config import Settings, load_settings, update_env_value
|
||||||
from crypto_spot_bot.execution import LiveBroker, PaperBroker
|
from crypto_spot_bot.execution import LiveBroker, PaperBroker
|
||||||
@@ -19,13 +23,15 @@ from crypto_spot_bot.learning import TradeLearner
|
|||||||
from crypto_spot_bot.market_data import MarketData
|
from crypto_spot_bot.market_data import MarketData
|
||||||
from crypto_spot_bot.patterns import PatternAnalyzer
|
from crypto_spot_bot.patterns import PatternAnalyzer
|
||||||
from crypto_spot_bot.reconciliation import reconciliation_snapshot
|
from crypto_spot_bot.reconciliation import reconciliation_snapshot
|
||||||
|
from crypto_spot_bot.shadow import shadow_gate_snapshot
|
||||||
from crypto_spot_bot.storage import Storage
|
from crypto_spot_bot.storage import Storage
|
||||||
from crypto_spot_bot.strategy import SpotStrategy
|
from crypto_spot_bot.strategy import SpotStrategy
|
||||||
from crypto_spot_bot.time_series import TimeSeriesForecaster
|
from crypto_spot_bot.time_series import TimeSeriesForecaster
|
||||||
from crypto_spot_bot.training_coordination import TrainingCoordinator
|
from crypto_spot_bot.training_coordination import TrainingCoordinator
|
||||||
|
|
||||||
|
|
||||||
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
|
WEB_ROOT = Path(__file__).with_name("web")
|
||||||
|
WEB_INDEX = WEB_ROOT / "index.html"
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,7 +52,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
pattern_analyzer = PatternAnalyzer()
|
pattern_analyzer = PatternAnalyzer()
|
||||||
learner = TradeLearner(settings, storage)
|
learner = TradeLearner(settings, storage)
|
||||||
forecaster = TimeSeriesForecaster(settings)
|
forecaster = TimeSeriesForecaster(settings)
|
||||||
bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster)
|
runtime_dir = settings.time_series_lstm_model_path.parent
|
||||||
|
shadow_forecaster = TimeSeriesForecaster(
|
||||||
|
settings,
|
||||||
|
model_path=runtime_dir / "lstm_forecaster.shadow.json",
|
||||||
|
calibration_path=runtime_dir / "torch_shadow_calibration.json",
|
||||||
|
)
|
||||||
|
bot = CryptoSpotBot(
|
||||||
|
settings,
|
||||||
|
storage,
|
||||||
|
market,
|
||||||
|
broker,
|
||||||
|
strategy,
|
||||||
|
pattern_analyzer,
|
||||||
|
learner,
|
||||||
|
forecaster,
|
||||||
|
shadow_forecaster,
|
||||||
|
)
|
||||||
training = TrainingCoordinator(settings.time_series_lstm_model_path.parent)
|
training = TrainingCoordinator(settings.time_series_lstm_model_path.parent)
|
||||||
authorizer = ApiAuthorizer(settings)
|
authorizer = ApiAuthorizer(settings)
|
||||||
|
|
||||||
@@ -58,16 +80,33 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
finally:
|
finally:
|
||||||
await bot.stop()
|
await bot.stop()
|
||||||
|
|
||||||
app = FastAPI(title="Крипто спот-бот", lifespan=lifespan)
|
app = FastAPI(title="Крипто спот-бот", version=__version__, lifespan=lifespan)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.storage = storage
|
app.state.storage = storage
|
||||||
app.state.bot = bot
|
app.state.bot = bot
|
||||||
app.state.market = market
|
app.state.market = market
|
||||||
app.state.training = training
|
app.state.training = training
|
||||||
|
app.mount("/assets", StaticFiles(directory=WEB_ROOT), name="dashboard-assets")
|
||||||
|
|
||||||
@app.get("/", response_class=PlainTextResponse, status_code=410)
|
@app.middleware("http")
|
||||||
async def index() -> str:
|
async def security_headers(request: Request, call_next) -> Response:
|
||||||
return WEB_UI_REMOVED_MESSAGE
|
response = await call_next(request)
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; style-src 'self'; script-src 'self'; "
|
||||||
|
"img-src 'self' data:; connect-src 'self'; font-src 'self'; "
|
||||||
|
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||||
|
)
|
||||||
|
if request.url.path == "/":
|
||||||
|
response.headers.setdefault("Cache-Control", "no-store")
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.get("/", response_class=FileResponse)
|
||||||
|
async def index() -> FileResponse:
|
||||||
|
return FileResponse(WEB_INDEX, media_type="text/html")
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health() -> dict[str, Any]:
|
async def health() -> dict[str, Any]:
|
||||||
@@ -76,6 +115,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
"running": bot.running,
|
"running": bot.running,
|
||||||
"mode": settings.trading_mode,
|
"mode": settings.trading_mode,
|
||||||
"auth_configured": authorizer.configured(),
|
"auth_configured": authorizer.configured(),
|
||||||
|
"version": __version__,
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/ready")
|
@app.get("/api/ready")
|
||||||
@@ -141,12 +181,62 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
async def retrain(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
async def retrain(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
data = _runtime_json(settings, "torch_retrain_guard.json")
|
data = _runtime_json(settings, "torch_retrain_guard.json")
|
||||||
data["coordination"] = training.status()
|
data["coordination"] = training.status()
|
||||||
|
data["shadow"] = shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256())
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@app.get("/api/training/status")
|
@app.get("/api/training/status")
|
||||||
async def training_status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
async def training_status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
return training.status()
|
return training.status()
|
||||||
|
|
||||||
|
@app.get("/api/training/shadow")
|
||||||
|
async def training_shadow_status(
|
||||||
|
_: None = Depends(authorizer.require),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256())
|
||||||
|
|
||||||
|
@app.post("/api/training/shadow/promote")
|
||||||
|
async def training_shadow_promote(
|
||||||
|
_: None = Depends(authorizer.require),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
gate = shadow_gate_snapshot(storage, shadow_forecaster.artifact_sha256())
|
||||||
|
if not gate.get("passed"):
|
||||||
|
raise HTTPException(status_code=409, detail={"message": "shadow forward gate has not passed", "gate": gate})
|
||||||
|
try:
|
||||||
|
return training.promote_shadow(gate)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
@app.get("/api/training/market-observations")
|
||||||
|
async def training_market_observations(
|
||||||
|
symbol: str,
|
||||||
|
after_id: int = 0,
|
||||||
|
limit: int = 5000,
|
||||||
|
_: None = Depends(authorizer.require_training),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
normalized_symbol = symbol.strip().upper()
|
||||||
|
if not normalized_symbol:
|
||||||
|
raise HTTPException(status_code=400, detail="symbol is required")
|
||||||
|
items = storage.market_observations_after(
|
||||||
|
symbol=normalized_symbol,
|
||||||
|
after_id=max(0, after_id),
|
||||||
|
limit=max(1, min(limit, 5000)),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"symbol": normalized_symbol,
|
||||||
|
"items": items,
|
||||||
|
"next_after_id": int(items[-1]["id"]) if items else max(0, after_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/training/market-observations/manifest")
|
||||||
|
async def training_market_observation_manifest(
|
||||||
|
_: None = Depends(authorizer.require_training),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
items = storage.market_observation_manifest()
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total_samples": sum(int(item.get("samples", 0) or 0) for item in items),
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/api/training/retrain")
|
@app.post("/api/training/retrain")
|
||||||
async def training_retrain(
|
async def training_retrain(
|
||||||
payload: dict[str, Any] | None = None,
|
payload: dict[str, Any] | None = None,
|
||||||
@@ -154,6 +244,17 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return training.request_retrain(payload)
|
return training.request_retrain(payload)
|
||||||
|
|
||||||
|
@app.post("/api/training/retrain/auto")
|
||||||
|
async def training_retrain_auto(
|
||||||
|
_: None = Depends(authorizer.require_training),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return training.request_retrain(
|
||||||
|
{
|
||||||
|
"source": "windows-agent-auto",
|
||||||
|
"parameters": {"use_orderbook": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@app.post("/api/training/heartbeat")
|
@app.post("/api/training/heartbeat")
|
||||||
async def training_heartbeat(
|
async def training_heartbeat(
|
||||||
payload: dict[str, Any] | None = None,
|
payload: dict[str, Any] | None = None,
|
||||||
@@ -210,6 +311,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
row_limit = 220
|
row_limit = 220
|
||||||
retrain_data = _runtime_json(settings, "torch_retrain_guard.json")
|
retrain_data = _runtime_json(settings, "torch_retrain_guard.json")
|
||||||
retrain_data["coordination"] = training.status()
|
retrain_data["coordination"] = training.status()
|
||||||
|
retrain_data["shadow"] = shadow_gate_snapshot(
|
||||||
|
storage,
|
||||||
|
shadow_forecaster.artifact_sha256(),
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"health": {
|
"health": {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -220,8 +325,6 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
"status": bot.status().as_dict(),
|
"status": bot.status().as_dict(),
|
||||||
"account": bot.account_snapshot(),
|
"account": bot.account_snapshot(),
|
||||||
"positions": bot.positions_snapshot(),
|
"positions": bot.positions_snapshot(),
|
||||||
"learning": bot.learning_snapshot(),
|
|
||||||
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
|
|
||||||
"readiness": bot.readiness_snapshot(),
|
"readiness": bot.readiness_snapshot(),
|
||||||
},
|
},
|
||||||
"markets": market.snapshot(),
|
"markets": market.snapshot(),
|
||||||
@@ -236,6 +339,45 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
"backtest": _runtime_json(settings, "torch_threshold_calibration.json"),
|
"backtest": _runtime_json(settings, "torch_threshold_calibration.json"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get("/web-api/dashboard/snapshot", include_in_schema=False)
|
||||||
|
@app.get("/api/dashboard/snapshot")
|
||||||
|
async def dashboard_snapshot(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
|
market_data = market.snapshot()
|
||||||
|
retrain_data = _runtime_json(settings, "torch_retrain_guard.json")
|
||||||
|
retrain_data["coordination"] = training.status()
|
||||||
|
retrain_data["shadow"] = shadow_gate_snapshot(
|
||||||
|
storage,
|
||||||
|
shadow_forecaster.artifact_sha256(),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"health": {
|
||||||
|
"ok": True,
|
||||||
|
"running": bot.running,
|
||||||
|
"mode": settings.trading_mode,
|
||||||
|
"version": __version__,
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"status": bot.status().as_dict(),
|
||||||
|
"account": bot.account_snapshot(),
|
||||||
|
"positions": bot.positions_snapshot(),
|
||||||
|
"learning": bot.learning_snapshot(),
|
||||||
|
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
|
||||||
|
"readiness": bot.readiness_snapshot(),
|
||||||
|
},
|
||||||
|
"markets": _compact_markets(market_data),
|
||||||
|
"signals": {"items": storage.recent_signals(16)},
|
||||||
|
"trades": {
|
||||||
|
"items": storage.recent_trades(16, mode=settings.trading_mode),
|
||||||
|
"closed_items": storage.closed_trades(16, mode=settings.trading_mode),
|
||||||
|
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
|
||||||
|
},
|
||||||
|
"events": {"items": storage.recent_events(20)},
|
||||||
|
"retrain": retrain_data,
|
||||||
|
"config": _safe_config(settings),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/web-api/config/fast-trading", include_in_schema=False)
|
||||||
@app.post("/api/config/fast-trading")
|
@app.post("/api/config/fast-trading")
|
||||||
async def set_fast_trading(
|
async def set_fast_trading(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
@@ -247,11 +389,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
response["env_persisted"] = env_persisted
|
response["env_persisted"] = env_persisted
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@app.post("/web-api/control/start", include_in_schema=False)
|
||||||
@app.post("/api/control/start")
|
@app.post("/api/control/start")
|
||||||
async def start(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
async def start(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
await bot.start()
|
await bot.start()
|
||||||
return bot.status().as_dict()
|
return bot.status().as_dict()
|
||||||
|
|
||||||
|
@app.post("/web-api/control/stop", include_in_schema=False)
|
||||||
@app.post("/api/control/stop")
|
@app.post("/api/control/stop")
|
||||||
async def stop(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
async def stop(_: None = Depends(authorizer.require)) -> dict[str, Any]:
|
||||||
await bot.stop()
|
await bot.stop()
|
||||||
@@ -299,6 +443,37 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_markets(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
markets: list[dict[str, Any]] = []
|
||||||
|
for market in snapshot.get("markets", []):
|
||||||
|
candles = market.get("candles") or []
|
||||||
|
markets.append(
|
||||||
|
{
|
||||||
|
"ticker": market.get("ticker"),
|
||||||
|
"sparkline": [
|
||||||
|
{
|
||||||
|
"timestamp": candle.get("timestamp"),
|
||||||
|
"close": candle.get("close"),
|
||||||
|
}
|
||||||
|
for candle in candles[-48:]
|
||||||
|
],
|
||||||
|
"forecast": market.get("forecast"),
|
||||||
|
"quality": market.get("quality"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"symbols": snapshot.get("symbols", []),
|
||||||
|
"ws_connected": snapshot.get("ws_connected", False),
|
||||||
|
"rest_error_count": snapshot.get("rest_error_count", 0),
|
||||||
|
"last_rest_error": snapshot.get("last_rest_error", ""),
|
||||||
|
"last_rest_refresh_at": snapshot.get("last_rest_refresh_at"),
|
||||||
|
"last_ws_message_at": snapshot.get("last_ws_message_at"),
|
||||||
|
"observation_collector": snapshot.get("observation_collector", {}),
|
||||||
|
"quality": snapshot.get("quality", {}),
|
||||||
|
"markets": markets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _limit(value: int) -> int:
|
def _limit(value: int) -> int:
|
||||||
return max(1, min(int(value), 500))
|
return max(1, min(int(value), 500))
|
||||||
|
|
||||||
@@ -399,11 +574,14 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
|||||||
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
|
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
|
||||||
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
|
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
|
||||||
"time_series_trend_fallback_enabled": settings.time_series_trend_fallback_enabled,
|
"time_series_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_require_quality_gate": settings.time_series_require_quality_gate,
|
||||||
"time_series_manual_quality_override": settings.time_series_manual_quality_override,
|
"time_series_manual_quality_override": settings.time_series_manual_quality_override,
|
||||||
"time_series_require_fresh_model": settings.time_series_require_fresh_model,
|
"time_series_require_fresh_model": settings.time_series_require_fresh_model,
|
||||||
"time_series_model_max_age_hours": settings.time_series_model_max_age_hours,
|
"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_ticker_max_age_seconds": settings.market_ticker_max_age_seconds,
|
||||||
|
"market_observation_enabled": settings.market_observation_enabled,
|
||||||
|
"market_observation_sample_seconds": settings.market_observation_sample_seconds,
|
||||||
"time_series_model_artifact": _time_series_model_artifact(settings),
|
"time_series_model_artifact": _time_series_model_artifact(settings),
|
||||||
"stop_loss_percent": settings.stop_loss_percent,
|
"stop_loss_percent": settings.stop_loss_percent,
|
||||||
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
|
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
|
||||||
@@ -411,6 +589,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
|||||||
"trailing_stop_percent": settings.trailing_stop_percent,
|
"trailing_stop_percent": settings.trailing_stop_percent,
|
||||||
"min_hold_seconds": settings.min_hold_seconds,
|
"min_hold_seconds": settings.min_hold_seconds,
|
||||||
"min_exit_net_percent": settings.min_exit_net_percent,
|
"min_exit_net_percent": settings.min_exit_net_percent,
|
||||||
|
"profit_only_exit_enabled": settings.profit_only_exit_enabled,
|
||||||
"entry_cooldown_seconds": settings.entry_cooldown_seconds,
|
"entry_cooldown_seconds": settings.entry_cooldown_seconds,
|
||||||
"max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt,
|
"max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt,
|
||||||
"min_cash_reserve_usdt": settings.min_cash_reserve_usdt,
|
"min_cash_reserve_usdt": settings.min_cash_reserve_usdt,
|
||||||
|
|||||||
+118
-15
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -51,8 +52,10 @@ class MarketData:
|
|||||||
self.candles: dict[str, list[Candle]] = {}
|
self.candles: dict[str, list[Candle]] = {}
|
||||||
self.trend_candles: dict[str, list[Candle]] = {}
|
self.trend_candles: dict[str, list[Candle]] = {}
|
||||||
self.orderbook_top: dict[str, tuple[float, float]] = {}
|
self.orderbook_top: dict[str, tuple[float, float]] = {}
|
||||||
|
self.orderbook_metrics: dict[str, dict[str, Any]] = {}
|
||||||
self.patterns: dict[str, dict[str, Any]] = {}
|
self.patterns: dict[str, dict[str, Any]] = {}
|
||||||
self.forecasts: dict[str, dict[str, Any]] = {}
|
self.forecasts: dict[str, dict[str, Any]] = {}
|
||||||
|
self.shadow_forecasts: dict[str, dict[str, Any]] = {}
|
||||||
self.last_rest_refresh_at: datetime | None = None
|
self.last_rest_refresh_at: datetime | None = None
|
||||||
self.last_ws_message_at: datetime | None = None
|
self.last_ws_message_at: datetime | None = None
|
||||||
self.ws_connected = False
|
self.ws_connected = False
|
||||||
@@ -60,6 +63,11 @@ class MarketData:
|
|||||||
self._refresh_lock = threading.Lock()
|
self._refresh_lock = threading.Lock()
|
||||||
self.rest_error_count = 0
|
self.rest_error_count = 0
|
||||||
self.last_rest_error = ""
|
self.last_rest_error = ""
|
||||||
|
self.observation_samples = 0
|
||||||
|
self.last_observation_at: datetime | None = None
|
||||||
|
self.observation_error_count = 0
|
||||||
|
self.last_observation_error = ""
|
||||||
|
self._last_observation_monotonic: dict[str, float] = {}
|
||||||
|
|
||||||
async def bootstrap(self) -> None:
|
async def bootstrap(self) -> None:
|
||||||
self.instruments = await asyncio.to_thread(self.client.instruments)
|
self.instruments = await asyncio.to_thread(self.client.instruments)
|
||||||
@@ -112,19 +120,8 @@ class MarketData:
|
|||||||
trend_candles = _closed_candles(trend_candles, self.settings.trend_interval)
|
trend_candles = _closed_candles(trend_candles, self.settings.trend_interval)
|
||||||
add_indicators(trend_candles)
|
add_indicators(trend_candles)
|
||||||
self.trend_candles[symbol] = trend_candles
|
self.trend_candles[symbol] = trend_candles
|
||||||
bid, ask = self.client.orderbook_top(symbol)
|
bid, bid_size, ask, ask_size = self.client.orderbook_level_one(symbol)
|
||||||
self.orderbook_top[symbol] = (bid, ask)
|
self._update_orderbook(symbol, bid, bid_size, ask, ask_size)
|
||||||
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,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.rest_error_count += 1
|
self.rest_error_count += 1
|
||||||
self.last_rest_error = str(exc)
|
self.last_rest_error = str(exc)
|
||||||
@@ -180,7 +177,11 @@ class MarketData:
|
|||||||
elif topic.startswith("orderbook.") and isinstance(data, dict):
|
elif topic.startswith("orderbook.") and isinstance(data, dict):
|
||||||
parts = topic.split(".")
|
parts = topic.split(".")
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
self._handle_orderbook(parts[2], data)
|
self._handle_orderbook(
|
||||||
|
parts[2],
|
||||||
|
data,
|
||||||
|
source_timestamp_ms=int(_float(message.get("ts"))),
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_ticker(self, symbol: str, data: dict[str, Any]) -> None:
|
def _handle_ticker(self, symbol: str, data: dict[str, Any]) -> None:
|
||||||
current = self.tickers.get(symbol)
|
current = self.tickers.get(symbol)
|
||||||
@@ -222,14 +223,66 @@ class MarketData:
|
|||||||
add_indicators(candles)
|
add_indicators(candles)
|
||||||
self.candles[symbol] = candles
|
self.candles[symbol] = candles
|
||||||
|
|
||||||
def _handle_orderbook(self, symbol: str, data: dict[str, Any]) -> None:
|
def _handle_orderbook(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
source_timestamp_ms: int = 0,
|
||||||
|
) -> None:
|
||||||
bids = data.get("b") or []
|
bids = data.get("b") or []
|
||||||
asks = data.get("a") or []
|
asks = data.get("a") or []
|
||||||
bid = _float(bids[0][0]) if bids else 0.0
|
bid = _float(bids[0][0]) if bids else 0.0
|
||||||
|
bid_size = _float(bids[0][1]) if bids and len(bids[0]) > 1 else 0.0
|
||||||
ask = _float(asks[0][0]) if asks else 0.0
|
ask = _float(asks[0][0]) if asks else 0.0
|
||||||
|
ask_size = _float(asks[0][1]) if asks and len(asks[0]) > 1 else 0.0
|
||||||
|
self._update_orderbook(
|
||||||
|
symbol,
|
||||||
|
bid,
|
||||||
|
bid_size,
|
||||||
|
ask,
|
||||||
|
ask_size,
|
||||||
|
source_timestamp_ms=source_timestamp_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_orderbook(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
bid: float,
|
||||||
|
bid_size: float,
|
||||||
|
ask: float,
|
||||||
|
ask_size: float,
|
||||||
|
*,
|
||||||
|
source_timestamp_ms: int = 0,
|
||||||
|
) -> None:
|
||||||
if bid > 0 and ask > 0:
|
if bid > 0 and ask > 0:
|
||||||
self.orderbook_top[symbol] = (bid, ask)
|
self.orderbook_top[symbol] = (bid, ask)
|
||||||
current = self.tickers.get(symbol)
|
current = self.tickers.get(symbol)
|
||||||
|
size_total = max(0.0, bid_size) + max(0.0, ask_size)
|
||||||
|
mid_price = (bid + ask) / 2.0
|
||||||
|
imbalance = (
|
||||||
|
(max(0.0, bid_size) - max(0.0, ask_size)) / size_total
|
||||||
|
if size_total > 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
microprice = (
|
||||||
|
(ask * max(0.0, bid_size) + bid * max(0.0, ask_size)) / size_total
|
||||||
|
if size_total > 0
|
||||||
|
else mid_price
|
||||||
|
)
|
||||||
|
observed_at = utc_now()
|
||||||
|
metrics = {
|
||||||
|
"bid_price": bid,
|
||||||
|
"bid_size": max(0.0, bid_size),
|
||||||
|
"ask_price": ask,
|
||||||
|
"ask_size": max(0.0, ask_size),
|
||||||
|
"mid_price": mid_price,
|
||||||
|
"microprice": microprice,
|
||||||
|
"spread_bps": ((ask - bid) / mid_price) * 10_000 if mid_price > 0 else 0.0,
|
||||||
|
"imbalance": imbalance,
|
||||||
|
"source_timestamp_ms": max(0, source_timestamp_ms),
|
||||||
|
"observed_at": observed_at.isoformat(),
|
||||||
|
}
|
||||||
|
self.orderbook_metrics[symbol] = metrics
|
||||||
if current:
|
if current:
|
||||||
self.tickers[symbol] = Ticker(
|
self.tickers[symbol] = Ticker(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
@@ -240,6 +293,44 @@ class MarketData:
|
|||||||
volume_24h=current.volume_24h,
|
volume_24h=current.volume_24h,
|
||||||
change_24h=current.change_24h,
|
change_24h=current.change_24h,
|
||||||
)
|
)
|
||||||
|
self._sample_orderbook(symbol, metrics, current.last_price if current else mid_price, observed_at)
|
||||||
|
|
||||||
|
def _sample_orderbook(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
metrics: dict[str, Any],
|
||||||
|
last_price: float,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
if not self.settings.market_observation_enabled:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
previous = self._last_observation_monotonic.get(symbol)
|
||||||
|
if previous is not None and now - previous < self.settings.market_observation_sample_seconds:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.storage.insert_market_observation(
|
||||||
|
symbol=symbol,
|
||||||
|
bid_price=float(metrics["bid_price"]),
|
||||||
|
bid_size=float(metrics["bid_size"]),
|
||||||
|
ask_price=float(metrics["ask_price"]),
|
||||||
|
ask_size=float(metrics["ask_size"]),
|
||||||
|
mid_price=float(metrics["mid_price"]),
|
||||||
|
microprice=float(metrics["microprice"]),
|
||||||
|
spread_bps=float(metrics["spread_bps"]),
|
||||||
|
imbalance=float(metrics["imbalance"]),
|
||||||
|
last_price=last_price,
|
||||||
|
source_timestamp_ms=int(metrics["source_timestamp_ms"]),
|
||||||
|
created_at=observed_at,
|
||||||
|
)
|
||||||
|
except Exception as exc: # Storage errors must not disconnect market data.
|
||||||
|
self.observation_error_count += 1
|
||||||
|
self.last_observation_error = str(exc)
|
||||||
|
return
|
||||||
|
self._last_observation_monotonic[symbol] = now
|
||||||
|
self.observation_samples += 1
|
||||||
|
self.last_observation_at = observed_at
|
||||||
|
self.last_observation_error = ""
|
||||||
|
|
||||||
def prices(self) -> dict[str, float]:
|
def prices(self) -> dict[str, float]:
|
||||||
return {symbol: ticker.last_price for symbol, ticker in self.tickers.items()}
|
return {symbol: ticker.last_price for symbol, ticker in self.tickers.items()}
|
||||||
@@ -286,6 +377,16 @@ class MarketData:
|
|||||||
"last_ws_message_at": self.last_ws_message_at.isoformat()
|
"last_ws_message_at": self.last_ws_message_at.isoformat()
|
||||||
if self.last_ws_message_at
|
if self.last_ws_message_at
|
||||||
else None,
|
else None,
|
||||||
|
"observation_collector": {
|
||||||
|
"enabled": self.settings.market_observation_enabled,
|
||||||
|
"sample_seconds": self.settings.market_observation_sample_seconds,
|
||||||
|
"samples_since_start": self.observation_samples,
|
||||||
|
"last_observation_at": self.last_observation_at.isoformat()
|
||||||
|
if self.last_observation_at
|
||||||
|
else None,
|
||||||
|
"error_count": self.observation_error_count,
|
||||||
|
"last_error": self.last_observation_error,
|
||||||
|
},
|
||||||
"markets": [
|
"markets": [
|
||||||
{
|
{
|
||||||
"ticker": self.tickers[symbol].as_dict() if symbol in self.tickers else None,
|
"ticker": self.tickers[symbol].as_dict() if symbol in self.tickers else None,
|
||||||
@@ -293,6 +394,8 @@ class MarketData:
|
|||||||
"trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]],
|
"trend_candles": [candle.as_dict() for candle in self.trend_candles.get(symbol, [])[-5:]],
|
||||||
"pattern": self.patterns.get(symbol),
|
"pattern": self.patterns.get(symbol),
|
||||||
"forecast": self.forecasts.get(symbol),
|
"forecast": self.forecasts.get(symbol),
|
||||||
|
"shadow_forecast": self.shadow_forecasts.get(symbol),
|
||||||
|
"orderbook": self.orderbook_metrics.get(symbol),
|
||||||
"quality": analyze_symbol_quality(
|
"quality": analyze_symbol_quality(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
candles=self.candles.get(symbol, []),
|
candles=self.candles.get(symbol, []),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+279
-3
@@ -4,11 +4,12 @@ import json
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
from crypto_spot_bot.models import Position, Signal, Trade, utc_now
|
from crypto_spot_bot.models import Position, Signal, Trade, utc_now
|
||||||
|
from crypto_spot_bot.orderbook_features import aggregate_orderbook_observations, load_orderbook_feature_map
|
||||||
|
|
||||||
|
|
||||||
MAX_SIGNAL_DIAGNOSTICS_BYTES = 4 * 1024
|
MAX_SIGNAL_DIAGNOSTICS_BYTES = 4 * 1024
|
||||||
@@ -18,6 +19,8 @@ MAX_RUNTIME_ROWS = {
|
|||||||
"equity": 100_000,
|
"equity": 100_000,
|
||||||
"events": 20_000,
|
"events": 20_000,
|
||||||
"llm_advice": 20_000,
|
"llm_advice": 20_000,
|
||||||
|
"market_observations": 1_200_000,
|
||||||
|
"shadow_predictions": 250_000,
|
||||||
}
|
}
|
||||||
_STORED_FORECAST_KEYS = {
|
_STORED_FORECAST_KEYS = {
|
||||||
"enabled",
|
"enabled",
|
||||||
@@ -171,6 +174,37 @@ class Storage:
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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
|
CREATE INDEX IF NOT EXISTS idx_positions_status_opened
|
||||||
ON positions(status, opened_at);
|
ON positions(status, opened_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_trades_closed
|
CREATE INDEX IF NOT EXISTS idx_trades_closed
|
||||||
@@ -183,6 +217,14 @@ class Storage:
|
|||||||
ON events(created_at DESC);
|
ON events(created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_status_updated
|
CREATE INDEX IF NOT EXISTS idx_orders_status_updated
|
||||||
ON orders(status, updated_at DESC);
|
ON orders(status, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_market_observations_symbol_id
|
||||||
|
ON market_observations(symbol, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_market_observations_created
|
||||||
|
ON market_observations(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_market_observations_symbol_source_timestamp
|
||||||
|
ON market_observations(symbol, source_timestamp_ms);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shadow_predictions_model_status
|
||||||
|
ON shadow_predictions(model_sha256, settled_at, symbol);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
columns = {
|
columns = {
|
||||||
@@ -458,6 +500,223 @@ class Storage:
|
|||||||
rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def insert_market_observation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
bid_price: float,
|
||||||
|
bid_size: float,
|
||||||
|
ask_price: float,
|
||||||
|
ask_size: float,
|
||||||
|
mid_price: float,
|
||||||
|
microprice: float,
|
||||||
|
spread_bps: float,
|
||||||
|
imbalance: float,
|
||||||
|
last_price: float,
|
||||||
|
source_timestamp_ms: int = 0,
|
||||||
|
created_at: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
timestamp = (created_at or utc_now()).isoformat()
|
||||||
|
with self.connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO market_observations (
|
||||||
|
symbol, bid_price, bid_size, ask_price, ask_size,
|
||||||
|
mid_price, microprice, spread_bps, imbalance, last_price,
|
||||||
|
source_timestamp_ms, created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
symbol.upper(),
|
||||||
|
bid_price,
|
||||||
|
bid_size,
|
||||||
|
ask_price,
|
||||||
|
ask_size,
|
||||||
|
mid_price,
|
||||||
|
microprice,
|
||||||
|
spread_bps,
|
||||||
|
imbalance,
|
||||||
|
last_price,
|
||||||
|
max(0, source_timestamp_ms),
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def market_observations_after(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
symbol: str,
|
||||||
|
after_id: int = 0,
|
||||||
|
limit: int = 5000,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
row_limit = max(1, min(limit, 5000))
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM market_observations
|
||||||
|
WHERE symbol = ? AND id > ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(symbol.upper(), max(0, after_id), row_limit),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def market_observation_manifest(self) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT symbol, COUNT(*) AS samples, MIN(id) AS min_id, MAX(id) AS max_id,
|
||||||
|
MIN(source_timestamp_ms) AS first_source_timestamp_ms,
|
||||||
|
MAX(source_timestamp_ms) AS last_source_timestamp_ms,
|
||||||
|
MIN(created_at) AS first_created_at,
|
||||||
|
MAX(created_at) AS last_created_at
|
||||||
|
FROM market_observations
|
||||||
|
GROUP BY symbol
|
||||||
|
ORDER BY symbol
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def aggregated_orderbook_features(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
interval: str,
|
||||||
|
symbols: list[str] | None = None,
|
||||||
|
min_samples_per_bucket: int = 20,
|
||||||
|
) -> tuple[dict[str, dict[int, dict[str, float]]], dict[str, dict[str, Any]]]:
|
||||||
|
return load_orderbook_feature_map(
|
||||||
|
self.path,
|
||||||
|
interval=interval,
|
||||||
|
symbols=symbols,
|
||||||
|
min_samples_per_bucket=min_samples_per_bucket,
|
||||||
|
)
|
||||||
|
|
||||||
|
def recent_aggregated_orderbook_features(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
interval: str,
|
||||||
|
symbols: list[str],
|
||||||
|
after_timestamp_ms: int,
|
||||||
|
min_samples_per_bucket: int = 20,
|
||||||
|
) -> tuple[dict[str, dict[int, dict[str, float]]], dict[str, dict[str, Any]]]:
|
||||||
|
selected = sorted({symbol.strip().upper() for symbol in symbols if symbol.strip()})
|
||||||
|
if not selected:
|
||||||
|
return {}, {}
|
||||||
|
placeholders = ",".join("?" for _ in selected)
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT symbol, bid_price, bid_size, ask_price, ask_size, mid_price,
|
||||||
|
microprice, spread_bps, imbalance, source_timestamp_ms, created_at
|
||||||
|
FROM market_observations
|
||||||
|
WHERE symbol IN ({placeholders}) AND source_timestamp_ms >= ?
|
||||||
|
ORDER BY symbol, source_timestamp_ms
|
||||||
|
""",
|
||||||
|
(*selected, max(0, int(after_timestamp_ms))),
|
||||||
|
).fetchall()
|
||||||
|
return aggregate_orderbook_observations(
|
||||||
|
(dict(row) for row in rows),
|
||||||
|
interval=interval,
|
||||||
|
min_samples_per_bucket=min_samples_per_bucket,
|
||||||
|
)
|
||||||
|
|
||||||
|
def insert_shadow_prediction(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model_sha256: str,
|
||||||
|
symbol: str,
|
||||||
|
forecast_timestamp_ms: int,
|
||||||
|
horizon: int,
|
||||||
|
reference_price: float,
|
||||||
|
expected_return_percent: float,
|
||||||
|
probability_up: float,
|
||||||
|
eligible_signal: bool,
|
||||||
|
) -> bool:
|
||||||
|
with self.connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO shadow_predictions (
|
||||||
|
model_sha256, symbol, forecast_timestamp_ms, horizon,
|
||||||
|
reference_price, expected_return_percent, probability_up,
|
||||||
|
eligible_signal, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
model_sha256,
|
||||||
|
symbol.upper(),
|
||||||
|
max(0, int(forecast_timestamp_ms)),
|
||||||
|
max(1, int(horizon)),
|
||||||
|
max(0.0, float(reference_price)),
|
||||||
|
float(expected_return_percent),
|
||||||
|
max(0.0, min(1.0, float(probability_up))),
|
||||||
|
1 if eligible_signal else 0,
|
||||||
|
utc_now().isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return bool(cursor.rowcount)
|
||||||
|
|
||||||
|
def pending_shadow_predictions(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model_sha256: str,
|
||||||
|
symbol: str,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM shadow_predictions
|
||||||
|
WHERE model_sha256 = ? AND symbol = ? AND settled_at IS NULL
|
||||||
|
ORDER BY forecast_timestamp_ms
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(model_sha256, symbol.upper(), max(1, min(5000, int(limit)))),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def settle_shadow_prediction(
|
||||||
|
self,
|
||||||
|
prediction_id: int,
|
||||||
|
*,
|
||||||
|
actual_return_percent: float,
|
||||||
|
take_profit_first: bool,
|
||||||
|
) -> bool:
|
||||||
|
with self.connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE shadow_predictions
|
||||||
|
SET settled_at = ?, actual_return_percent = ?, take_profit_first = ?
|
||||||
|
WHERE id = ? AND settled_at IS NULL
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
utc_now().isoformat(),
|
||||||
|
float(actual_return_percent),
|
||||||
|
1 if take_profit_first else 0,
|
||||||
|
int(prediction_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return bool(cursor.rowcount)
|
||||||
|
|
||||||
|
def shadow_prediction_rows(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model_sha256: str,
|
||||||
|
settled_only: bool = False,
|
||||||
|
limit: int = 250_000,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
where = "WHERE model_sha256 = ?"
|
||||||
|
if settled_only:
|
||||||
|
where += " AND settled_at IS NOT NULL"
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM shadow_predictions {where} ORDER BY id DESC LIMIT ?",
|
||||||
|
(model_sha256, max(1, min(250_000, int(limit)))),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
def insert_equity(
|
def insert_equity(
|
||||||
self,
|
self,
|
||||||
equity: float,
|
equity: float,
|
||||||
@@ -635,7 +894,14 @@ class Storage:
|
|||||||
return {}
|
return {}
|
||||||
cutoff = (utc_now() - timedelta(days=retention_days)).isoformat()
|
cutoff = (utc_now() - timedelta(days=retention_days)).isoformat()
|
||||||
deleted: dict[str, int] = {}
|
deleted: dict[str, int] = {}
|
||||||
for table in ("signals", "equity", "events", "llm_advice"):
|
for table in (
|
||||||
|
"signals",
|
||||||
|
"equity",
|
||||||
|
"events",
|
||||||
|
"llm_advice",
|
||||||
|
"market_observations",
|
||||||
|
"shadow_predictions",
|
||||||
|
):
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
max_id_row = conn.execute(f"SELECT MAX(id) AS value FROM {table}").fetchone()
|
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
|
max_id = int(max_id_row["value"] or 0) if max_id_row else 0
|
||||||
@@ -676,7 +942,17 @@ class Storage:
|
|||||||
|
|
||||||
def clear_all(self) -> None:
|
def clear_all(self) -> None:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
for table in ("positions", "trades", "signals", "equity", "events", "runtime", "llm_advice", "orders"):
|
for table in (
|
||||||
|
"positions",
|
||||||
|
"trades",
|
||||||
|
"signals",
|
||||||
|
"equity",
|
||||||
|
"events",
|
||||||
|
"runtime",
|
||||||
|
"llm_advice",
|
||||||
|
"orders",
|
||||||
|
"market_observations",
|
||||||
|
):
|
||||||
conn.execute(f"DELETE FROM {table}")
|
conn.execute(f"DELETE FROM {table}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+164
-3
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from crypto_spot_bot.config import Settings
|
from crypto_spot_bot.config import Settings
|
||||||
from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now
|
from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now
|
||||||
|
|
||||||
@@ -27,6 +29,28 @@ class SpotStrategy:
|
|||||||
if self.settings.strategy_mode == "torch_forecast":
|
if self.settings.strategy_mode == "torch_forecast":
|
||||||
fallback_reasons = torch_model_readiness_reasons(self.settings, forecast or {})
|
fallback_reasons = torch_model_readiness_reasons(self.settings, forecast or {})
|
||||||
if self.settings.time_series_trend_fallback_enabled and fallback_reasons:
|
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(
|
fallback = _trend_macd_entry_signal(
|
||||||
settings=self.settings,
|
settings=self.settings,
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
@@ -36,12 +60,14 @@ class SpotStrategy:
|
|||||||
open_positions_for_symbol=open_positions_for_symbol,
|
open_positions_for_symbol=open_positions_for_symbol,
|
||||||
account=account,
|
account=account,
|
||||||
)
|
)
|
||||||
|
trade_mode = "TREND_MACD_FALLBACK"
|
||||||
|
entry_path = "trend_macd_fallback"
|
||||||
diagnostics = dict(fallback.diagnostics)
|
diagnostics = dict(fallback.diagnostics)
|
||||||
diagnostics.update(
|
diagnostics.update(
|
||||||
{
|
{
|
||||||
"strategy_mode": "torch_forecast",
|
"strategy_mode": "torch_forecast",
|
||||||
"trade_mode": "TREND_MACD_FALLBACK",
|
"trade_mode": trade_mode,
|
||||||
"entry_path": "trend_macd_fallback",
|
"entry_path": entry_path,
|
||||||
"forecast_fallback_active": True,
|
"forecast_fallback_active": True,
|
||||||
"forecast_fallback_reasons": fallback_reasons,
|
"forecast_fallback_reasons": fallback_reasons,
|
||||||
"forecast": forecast or {},
|
"forecast": forecast or {},
|
||||||
@@ -345,6 +371,7 @@ class SpotStrategy:
|
|||||||
latest = candles[-1]
|
latest = candles[-1]
|
||||||
previous = candles[-2] if len(candles) >= 2 else latest
|
previous = candles[-2] if len(candles) >= 2 else latest
|
||||||
price = ticker.last_price
|
price = ticker.last_price
|
||||||
|
adaptive = _adaptive_rules(learning or {})
|
||||||
trailing = position.trailing_stop(self.settings.trailing_stop_percent)
|
trailing = position.trailing_stop(self.settings.trailing_stop_percent)
|
||||||
diagnostics = {
|
diagnostics = {
|
||||||
"price": price,
|
"price": price,
|
||||||
@@ -357,8 +384,11 @@ class SpotStrategy:
|
|||||||
"rsi_14": latest.rsi_14,
|
"rsi_14": latest.rsi_14,
|
||||||
"ema_20": latest.ema_20,
|
"ema_20": latest.ema_20,
|
||||||
"ema_50": latest.ema_50,
|
"ema_50": latest.ema_50,
|
||||||
|
"adaptive_rules": adaptive,
|
||||||
}
|
}
|
||||||
if self.settings.stop_loss_exit_enabled and price <= position.stop_loss:
|
if self.settings.stop_loss_exit_enabled and price <= position.stop_loss:
|
||||||
|
diagnostics["emergency_exit"] = True
|
||||||
|
diagnostics["emergency_exit_type"] = "configured_stop_loss"
|
||||||
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
|
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
|
||||||
if price >= position.take_profit:
|
if price >= position.take_profit:
|
||||||
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
|
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
|
||||||
@@ -397,7 +427,36 @@ class SpotStrategy:
|
|||||||
forecast: dict | None = None,
|
forecast: dict | None = None,
|
||||||
) -> Signal:
|
) -> Signal:
|
||||||
if self.settings.strategy_mode == "torch_forecast":
|
if self.settings.strategy_mode == "torch_forecast":
|
||||||
if str(position.entry_diagnostics.get("entry_path", "")) == "trend_macd_fallback":
|
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)
|
fallback = _trend_macd_exit_signal(self.settings, position, candles, ticker)
|
||||||
diagnostics = dict(fallback.diagnostics)
|
diagnostics = dict(fallback.diagnostics)
|
||||||
diagnostics.update(
|
diagnostics.update(
|
||||||
@@ -460,6 +519,8 @@ class SpotStrategy:
|
|||||||
"min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0),
|
"min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0),
|
||||||
}
|
}
|
||||||
if effective_stop_loss is not None and price <= effective_stop_loss:
|
if effective_stop_loss is not None and price <= effective_stop_loss:
|
||||||
|
diagnostics["emergency_exit"] = True
|
||||||
|
diagnostics["emergency_exit_type"] = "configured_stop_loss"
|
||||||
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
|
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
|
||||||
if price >= effective_take_profit:
|
if price >= effective_take_profit:
|
||||||
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
|
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
|
||||||
@@ -534,6 +595,12 @@ def _has_entry_indicators(candle: Candle) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_fallback_mode(settings: Settings) -> str:
|
||||||
|
if settings.trading_mode != "paper":
|
||||||
|
return "trend_macd"
|
||||||
|
return settings.time_series_fallback_mode
|
||||||
|
|
||||||
|
|
||||||
def _trend_macd_entry_signal(
|
def _trend_macd_entry_signal(
|
||||||
*,
|
*,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -655,6 +722,8 @@ def _trend_macd_exit_signal(
|
|||||||
"close_below_ema50": close_below_ema50,
|
"close_below_ema50": close_below_ema50,
|
||||||
}
|
}
|
||||||
if effective_stop_loss is not None and price <= effective_stop_loss:
|
if effective_stop_loss is not None and price <= effective_stop_loss:
|
||||||
|
diagnostics["emergency_exit"] = True
|
||||||
|
diagnostics["emergency_exit_type"] = "configured_stop_loss"
|
||||||
return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics)
|
return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics)
|
||||||
if atr_trailing_stop is not None and price <= atr_trailing_stop:
|
if atr_trailing_stop is not None and price <= atr_trailing_stop:
|
||||||
return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics)
|
return Signal(position.symbol, "SELL", 0.94, "trend_macd: сработал ATR trailing stop", diagnostics)
|
||||||
@@ -985,6 +1054,8 @@ def _torch_forecast_exit_signal(
|
|||||||
diagnostics["hold_seconds"] = hold_seconds
|
diagnostics["hold_seconds"] = hold_seconds
|
||||||
diagnostics["min_hold_seconds"] = settings.min_hold_seconds
|
diagnostics["min_hold_seconds"] = settings.min_hold_seconds
|
||||||
if effective_stop_loss is not None and price <= effective_stop_loss:
|
if effective_stop_loss is not None and price <= effective_stop_loss:
|
||||||
|
diagnostics["emergency_exit"] = True
|
||||||
|
diagnostics["emergency_exit_type"] = "configured_stop_loss"
|
||||||
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics)
|
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics)
|
||||||
if price >= position.take_profit:
|
if price >= position.take_profit:
|
||||||
return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics)
|
return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics)
|
||||||
@@ -1764,6 +1835,96 @@ def _estimated_exit_net_percent(position: Position, price: float, settings: Sett
|
|||||||
return gross_percent - round_trip_cost_percent
|
return gross_percent - round_trip_cost_percent
|
||||||
|
|
||||||
|
|
||||||
|
def apply_profit_only_exit_policy(
|
||||||
|
settings: Settings,
|
||||||
|
position: Position,
|
||||||
|
ticker: Ticker,
|
||||||
|
signal: Signal,
|
||||||
|
) -> Signal:
|
||||||
|
"""Block every ordinary exit that would realize less than the configured net profit.
|
||||||
|
|
||||||
|
The estimate mirrors the paper broker fill calculation. Live fills can still differ,
|
||||||
|
so the configured minimum also acts as a safety margin. A loss-making exit is only
|
||||||
|
allowed when the producing subsystem marks it explicitly as an emergency.
|
||||||
|
"""
|
||||||
|
if signal.action != "SELL" or not settings.profit_only_exit_enabled:
|
||||||
|
return signal
|
||||||
|
|
||||||
|
diagnostics = dict(signal.diagnostics)
|
||||||
|
expected_fill_price = _expected_sell_fill_price(ticker, settings)
|
||||||
|
expected_net_usdt = _expected_exit_net_usdt(position, expected_fill_price, settings)
|
||||||
|
expected_net_percent = (
|
||||||
|
expected_net_usdt / position.notional_usdt * 100
|
||||||
|
if position.notional_usdt > 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
adaptive = diagnostics.get("adaptive_rules")
|
||||||
|
adaptive_minimum = (
|
||||||
|
_safe_float(adaptive.get("min_exit_profit_percent"), 0.0)
|
||||||
|
if isinstance(adaptive, dict)
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
signal_minimum = _safe_float(diagnostics.get("min_exit_profit_percent"), 0.0)
|
||||||
|
minimum_net_percent = max(
|
||||||
|
_min_exit_net_percent(settings),
|
||||||
|
adaptive_minimum,
|
||||||
|
signal_minimum,
|
||||||
|
)
|
||||||
|
emergency = diagnostics.get("emergency_exit") is True
|
||||||
|
diagnostics.update(
|
||||||
|
{
|
||||||
|
"exit_policy": "profit_only",
|
||||||
|
"profit_only_exit_enabled": True,
|
||||||
|
"expected_exit_fill_price": round(expected_fill_price, 12),
|
||||||
|
"expected_exit_net_usdt": round(expected_net_usdt, 8),
|
||||||
|
"expected_exit_net_percent": round(expected_net_percent, 4),
|
||||||
|
"required_exit_net_percent": round(minimum_net_percent, 4),
|
||||||
|
"emergency_exit": emergency,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if emergency or expected_net_percent + 1e-9 >= minimum_net_percent:
|
||||||
|
diagnostics["exit_policy_blocked"] = False
|
||||||
|
return Signal(
|
||||||
|
signal.symbol,
|
||||||
|
signal.action,
|
||||||
|
signal.confidence,
|
||||||
|
signal.reason,
|
||||||
|
diagnostics,
|
||||||
|
signal.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
diagnostics.update(
|
||||||
|
{
|
||||||
|
"exit_policy_blocked": True,
|
||||||
|
"blocked_sell_reason": signal.reason,
|
||||||
|
"blocked_sell_confidence": signal.confidence,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Signal(
|
||||||
|
signal.symbol,
|
||||||
|
"HOLD",
|
||||||
|
min(signal.confidence, 0.49),
|
||||||
|
(
|
||||||
|
"profit-only: продажа заблокирована, ожидаемая чистая доходность "
|
||||||
|
f"{expected_net_percent:.4f}% ниже минимума {minimum_net_percent:.4f}%"
|
||||||
|
),
|
||||||
|
diagnostics,
|
||||||
|
signal.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_sell_fill_price(ticker: Ticker, settings: Settings) -> float:
|
||||||
|
base = ticker.bid if ticker.bid > 0 else ticker.last_price
|
||||||
|
return base * (1 - settings.slippage_rate)
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_exit_net_usdt(position: Position, fill_price: float, settings: Settings) -> float:
|
||||||
|
exit_notional = position.qty * fill_price
|
||||||
|
exit_fee = exit_notional * settings.taker_fee_rate
|
||||||
|
gross_pnl = (fill_price - position.entry_price) * position.qty
|
||||||
|
return gross_pnl - position.entry_fee_usdt - exit_fee
|
||||||
|
|
||||||
|
|
||||||
def _min_exit_net_percent(settings: Settings) -> float:
|
def _min_exit_net_percent(settings: Settings) -> float:
|
||||||
return round(_clamp(settings.min_exit_net_percent, 0.0, 5.0), 4)
|
return round(_clamp(settings.min_exit_net_percent, 0.0, 5.0), 4)
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import hashlib
|
||||||
from bisect import bisect_right
|
from bisect import bisect_right
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from crypto_spot_bot.config import Settings
|
from crypto_spot_bot.config import Settings
|
||||||
from crypto_spot_bot.models import Candle
|
from crypto_spot_bot.models import Candle
|
||||||
|
from crypto_spot_bot.orderbook_features import ORDERBOOK_FEATURES
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TORCH_FEATURES = (
|
DEFAULT_TORCH_FEATURES = (
|
||||||
@@ -170,8 +173,18 @@ class TimeSeriesForecast:
|
|||||||
|
|
||||||
|
|
||||||
class TimeSeriesForecaster:
|
class TimeSeriesForecaster:
|
||||||
def __init__(self, settings: Settings):
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
*,
|
||||||
|
model_path: Path | None = None,
|
||||||
|
calibration_path: Path | None = None,
|
||||||
|
):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
self.model_path = model_path or settings.time_series_lstm_model_path
|
||||||
|
self.calibration_path = calibration_path or (
|
||||||
|
self.model_path.parent / "torch_threshold_calibration.json"
|
||||||
|
)
|
||||||
self._lstm_artifact_mtime: float | None = None
|
self._lstm_artifact_mtime: float | None = None
|
||||||
self._lstm_artifact: dict[str, Any] = {}
|
self._lstm_artifact: dict[str, Any] = {}
|
||||||
self._calibration_mtime: float | None = None
|
self._calibration_mtime: float | None = None
|
||||||
@@ -184,6 +197,7 @@ class TimeSeriesForecaster:
|
|||||||
*,
|
*,
|
||||||
market_candles: dict[str, list[Candle]] | None = None,
|
market_candles: dict[str, list[Candle]] | None = None,
|
||||||
trend_candles: list[Candle] | None = None,
|
trend_candles: list[Candle] | None = None,
|
||||||
|
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
|
||||||
) -> TimeSeriesForecast:
|
) -> TimeSeriesForecast:
|
||||||
if not self.settings.time_series_forecast_enabled:
|
if not self.settings.time_series_forecast_enabled:
|
||||||
return _empty_forecast(False, "time-series forecast is disabled")
|
return _empty_forecast(False, "time-series forecast is disabled")
|
||||||
@@ -225,6 +239,7 @@ class TimeSeriesForecaster:
|
|||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
market_candles=market_candles,
|
market_candles=market_candles,
|
||||||
trend_candles=trend_candles,
|
trend_candles=trend_candles,
|
||||||
|
orderbook_features=orderbook_features,
|
||||||
)
|
)
|
||||||
if entry
|
if entry
|
||||||
else []
|
else []
|
||||||
@@ -413,7 +428,7 @@ class TimeSeriesForecaster:
|
|||||||
def _load_lstm_artifact(self) -> dict[str, Any]:
|
def _load_lstm_artifact(self) -> dict[str, Any]:
|
||||||
if not self.settings.time_series_lstm_enabled:
|
if not self.settings.time_series_lstm_enabled:
|
||||||
return {}
|
return {}
|
||||||
path = self.settings.time_series_lstm_model_path
|
path = self.model_path
|
||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -431,7 +446,7 @@ class TimeSeriesForecaster:
|
|||||||
return self._lstm_artifact
|
return self._lstm_artifact
|
||||||
|
|
||||||
def _load_quality_gate(self) -> dict[str, Any]:
|
def _load_quality_gate(self) -> dict[str, Any]:
|
||||||
path = self.settings.time_series_lstm_model_path.parent / "torch_threshold_calibration.json"
|
path = self.calibration_path
|
||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -448,6 +463,12 @@ class TimeSeriesForecaster:
|
|||||||
self._calibration_mtime = stat.st_mtime
|
self._calibration_mtime = stat.st_mtime
|
||||||
return self._quality_gate
|
return self._quality_gate
|
||||||
|
|
||||||
|
def artifact_sha256(self) -> str:
|
||||||
|
try:
|
||||||
|
return hashlib.sha256(self.model_path.read_bytes()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
|
def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
|
||||||
return TimeSeriesForecast(
|
return TimeSeriesForecast(
|
||||||
@@ -554,6 +575,7 @@ def _feature_matrix(
|
|||||||
symbol: str | None = None,
|
symbol: str | None = None,
|
||||||
market_candles: dict[str, list[Candle]] | None = None,
|
market_candles: dict[str, list[Candle]] | None = None,
|
||||||
trend_candles: list[Candle] | None = None,
|
trend_candles: list[Candle] | None = None,
|
||||||
|
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
|
||||||
) -> list[list[float]]:
|
) -> list[list[float]]:
|
||||||
names = list(feature_names or DEFAULT_TORCH_FEATURES)
|
names = list(feature_names or DEFAULT_TORCH_FEATURES)
|
||||||
context = _feature_context(
|
context = _feature_context(
|
||||||
@@ -561,6 +583,7 @@ def _feature_matrix(
|
|||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
market_candles=market_candles,
|
market_candles=market_candles,
|
||||||
trend_candles=trend_candles,
|
trend_candles=trend_candles,
|
||||||
|
orderbook_features=orderbook_features,
|
||||||
)
|
)
|
||||||
rows: list[list[float]] = []
|
rows: list[list[float]] = []
|
||||||
for index, candle in enumerate(candles):
|
for index, candle in enumerate(candles):
|
||||||
@@ -574,6 +597,7 @@ def _feature_context(
|
|||||||
symbol: str | None,
|
symbol: str | None,
|
||||||
market_candles: dict[str, list[Candle]] | None,
|
market_candles: dict[str, list[Candle]] | None,
|
||||||
trend_candles: list[Candle] | None,
|
trend_candles: list[Candle] | None,
|
||||||
|
orderbook_features: dict[str, dict[int, dict[str, float]]] | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
market_candles = market_candles or {}
|
market_candles = market_candles or {}
|
||||||
normalized_market = {key.upper(): value for key, value in market_candles.items()}
|
normalized_market = {key.upper(): value for key, value in market_candles.items()}
|
||||||
@@ -595,6 +619,9 @@ def _feature_context(
|
|||||||
"context_indexes": context_indexes,
|
"context_indexes": context_indexes,
|
||||||
"trend_candles": trend_rows,
|
"trend_candles": trend_rows,
|
||||||
"trend_positions": trend_positions,
|
"trend_positions": trend_positions,
|
||||||
|
"orderbook_features": {
|
||||||
|
key.upper(): value for key, value in (orderbook_features or {}).items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -603,6 +630,12 @@ def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle,
|
|||||||
previous = candles[index - 1] if index >= 1 else candle
|
previous = candles[index - 1] if index >= 1 else candle
|
||||||
if name.startswith("symbol_is_"):
|
if name.startswith("symbol_is_"):
|
||||||
return 1.0 if context.get("symbol") == name.removeprefix("symbol_is_").upper() else 0.0
|
return 1.0 if context.get("symbol") == name.removeprefix("symbol_is_").upper() else 0.0
|
||||||
|
if name in ORDERBOOK_FEATURES:
|
||||||
|
symbol_features = (context.get("orderbook_features") or {}).get(
|
||||||
|
context.get("symbol"), {}
|
||||||
|
)
|
||||||
|
values = symbol_features.get(candle.timestamp, {})
|
||||||
|
return _safe_feature(float(values.get(name, 0.0) or 0.0))
|
||||||
if name == "return_1":
|
if name == "return_1":
|
||||||
return _log_change(candle.close, previous.close)
|
return _log_change(candle.close, previous.close)
|
||||||
if name == "return_3":
|
if name == "return_3":
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
@@ -15,19 +17,26 @@ from threading import Lock
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_TRAINING_ARTIFACTS = {
|
ACTIVE_TRAINING_ARTIFACTS = {
|
||||||
"lstm_forecaster.json",
|
"lstm_forecaster.json",
|
||||||
"torch_retrain_guard.json",
|
"torch_retrain_guard.json",
|
||||||
"torch_threshold_calibration.json",
|
"torch_threshold_calibration.json",
|
||||||
}
|
}
|
||||||
RUNNING_TIMEOUT = timedelta(hours=12)
|
SHADOW_TRAINING_ARTIFACTS = {
|
||||||
|
"lstm_forecaster.shadow.json",
|
||||||
|
"torch_shadow_guard.json",
|
||||||
|
"torch_shadow_calibration.json",
|
||||||
|
}
|
||||||
|
ALLOWED_TRAINING_ARTIFACTS = ACTIVE_TRAINING_ARTIFACTS | SHADOW_TRAINING_ARTIFACTS
|
||||||
|
RUNNING_LEASE_TIMEOUT = timedelta(minutes=10)
|
||||||
ONLINE_WINDOW = timedelta(minutes=3)
|
ONLINE_WINDOW = timedelta(minutes=3)
|
||||||
|
MAX_JOB_ATTEMPTS = 3
|
||||||
MAX_ARTIFACT_CHUNK_BYTES = 1024 * 1024
|
MAX_ARTIFACT_CHUNK_BYTES = 1024 * 1024
|
||||||
# Independent per-symbol ensembles are intentionally larger than pooled models.
|
# Keep uploads bounded while leaving room for explicitly requested per-symbol bundles.
|
||||||
# Keep a bounded limit, but leave enough room for the supported 12-symbol bundle.
|
|
||||||
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
||||||
MAX_ARTIFACT_CHUNKS = 1024
|
MAX_ARTIFACT_CHUNKS = 1024
|
||||||
REQUIRED_MODEL_BUNDLE = set(ALLOWED_TRAINING_ARTIFACTS)
|
REQUIRED_MODEL_BUNDLE = set(ACTIVE_TRAINING_ARTIFACTS)
|
||||||
|
REQUIRED_SHADOW_BUNDLE = set(SHADOW_TRAINING_ARTIFACTS)
|
||||||
|
|
||||||
|
|
||||||
class TrainingCoordinator:
|
class TrainingCoordinator:
|
||||||
@@ -44,6 +53,61 @@ class TrainingCoordinator:
|
|||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
return self._public_status(state)
|
return self._public_status(state)
|
||||||
|
|
||||||
|
def promote_shadow(self, forward_gate: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
if not bool(forward_gate.get("passed")):
|
||||||
|
raise ValueError("shadow forward gate has not passed")
|
||||||
|
shadow_model = self.runtime_dir / "lstm_forecaster.shadow.json"
|
||||||
|
shadow_calibration = self.runtime_dir / "torch_shadow_calibration.json"
|
||||||
|
shadow_guard = self.runtime_dir / "torch_shadow_guard.json"
|
||||||
|
missing = [
|
||||||
|
path.name
|
||||||
|
for path in (shadow_model, shadow_calibration, shadow_guard)
|
||||||
|
if not path.is_file()
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ValueError("shadow bundle is incomplete: " + ", ".join(missing))
|
||||||
|
model_sha256 = hashlib.sha256(shadow_model.read_bytes()).hexdigest()
|
||||||
|
if str(forward_gate.get("model_sha256") or "") != model_sha256:
|
||||||
|
raise ValueError("shadow forward gate is bound to another model")
|
||||||
|
calibration = _read_json(shadow_calibration)
|
||||||
|
guard = _read_json(shadow_guard)
|
||||||
|
if calibration.get("artifact_sha256") != model_sha256:
|
||||||
|
raise ValueError("shadow calibration is not bound to the model")
|
||||||
|
if not bool(guard.get("accepted")) or guard.get("candidate_artifact_sha256") != model_sha256:
|
||||||
|
raise ValueError("shadow offline guard is invalid")
|
||||||
|
|
||||||
|
promotion_id = str(uuid.uuid4())
|
||||||
|
backup_dir = self.runtime_dir / ".model_backups" / f"{_compact_now()}-shadow-{promotion_id}"
|
||||||
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
targets = {
|
||||||
|
"lstm_forecaster.json": shadow_model,
|
||||||
|
"torch_threshold_calibration.json": shadow_calibration,
|
||||||
|
"torch_retrain_guard.json": shadow_guard,
|
||||||
|
}
|
||||||
|
for target_name in targets:
|
||||||
|
current = self.runtime_dir / target_name
|
||||||
|
if current.is_file():
|
||||||
|
shutil.copy2(current, backup_dir / target_name)
|
||||||
|
for target_name, source in targets.items():
|
||||||
|
target_tmp = self.runtime_dir / f".{target_name}.{promotion_id}.promote"
|
||||||
|
shutil.copy2(source, target_tmp)
|
||||||
|
os.replace(target_tmp, self.runtime_dir / target_name)
|
||||||
|
gate_path = self.runtime_dir / "torch_shadow_forward_gate.json"
|
||||||
|
gate_tmp = gate_path.with_suffix(".tmp")
|
||||||
|
gate_tmp.write_text(
|
||||||
|
json.dumps(forward_gate, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(gate_tmp, gate_path)
|
||||||
|
return {
|
||||||
|
"promoted": True,
|
||||||
|
"model_sha256": model_sha256,
|
||||||
|
"promotion_id": promotion_id,
|
||||||
|
"backup_dir": str(backup_dir),
|
||||||
|
"promoted_at": _now(),
|
||||||
|
}
|
||||||
|
|
||||||
def request_retrain(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
def request_retrain(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -52,7 +116,12 @@ class TrainingCoordinator:
|
|||||||
existing = self._active_job(state)
|
existing = self._active_job(state)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
return {"queued": False, "reason": "active_job_exists", "job": existing, "status": self._public_status(state)}
|
return {
|
||||||
|
"queued": False,
|
||||||
|
"reason": "active_job_exists",
|
||||||
|
"job": self._public_job(existing),
|
||||||
|
"status": self._public_status(state),
|
||||||
|
}
|
||||||
|
|
||||||
now = _now()
|
now = _now()
|
||||||
job = {
|
job = {
|
||||||
@@ -63,11 +132,16 @@ class TrainingCoordinator:
|
|||||||
"parameters": _safe_parameters(payload.get("parameters")),
|
"parameters": _safe_parameters(payload.get("parameters")),
|
||||||
"message": "",
|
"message": "",
|
||||||
"artifacts": [],
|
"artifacts": [],
|
||||||
|
"attempts": 0,
|
||||||
}
|
}
|
||||||
state.setdefault("jobs", []).append(job)
|
state.setdefault("jobs", []).append(job)
|
||||||
self._trim_jobs(state)
|
self._trim_jobs(state)
|
||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
return {"queued": True, "job": job, "status": self._public_status(state)}
|
return {
|
||||||
|
"queued": True,
|
||||||
|
"job": self._public_job(job),
|
||||||
|
"status": self._public_status(state),
|
||||||
|
}
|
||||||
|
|
||||||
def heartbeat(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
def heartbeat(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
@@ -91,12 +165,21 @@ class TrainingCoordinator:
|
|||||||
return {"claimed": False, "job": None, "status": self._public_status(state)}
|
return {"claimed": False, "job": None, "status": self._public_status(state)}
|
||||||
|
|
||||||
now = _now()
|
now = _now()
|
||||||
|
lease_token = secrets.token_urlsafe(32)
|
||||||
job["status"] = "running"
|
job["status"] = "running"
|
||||||
job["claimed_at"] = now
|
job["claimed_at"] = now
|
||||||
|
job["updated_at"] = now
|
||||||
job["claimed_by"] = worker["id"]
|
job["claimed_by"] = worker["id"]
|
||||||
job["worker"] = worker
|
job["worker"] = worker
|
||||||
|
job["lease_token"] = lease_token
|
||||||
|
job["attempts"] = int(job.get("attempts", 0)) + 1
|
||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
return {"claimed": True, "job": job, "status": self._public_status(state)}
|
return {
|
||||||
|
"claimed": True,
|
||||||
|
"job": self._public_job(job),
|
||||||
|
"lease_token": lease_token,
|
||||||
|
"status": self._public_status(state),
|
||||||
|
}
|
||||||
|
|
||||||
def save_artifact_chunk(self, job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
def save_artifact_chunk(self, job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
job_id = _valid_job_id(job_id)
|
job_id = _valid_job_id(job_id)
|
||||||
@@ -126,6 +209,7 @@ class TrainingCoordinator:
|
|||||||
raise ValueError(f"training job not found: {job_id}")
|
raise ValueError(f"training job not found: {job_id}")
|
||||||
if job.get("status") != "running" or not job.get("claimed_by"):
|
if job.get("status") != "running" or not job.get("claimed_by"):
|
||||||
raise ValueError("training job is not claimed and running")
|
raise ValueError("training job is not claimed and running")
|
||||||
|
self._require_lease(job, payload)
|
||||||
uploads = job.setdefault("uploads", {})
|
uploads = job.setdefault("uploads", {})
|
||||||
upload = uploads.setdefault(name, {"sha256": sha256, "total": total})
|
upload = uploads.setdefault(name, {"sha256": sha256, "total": total})
|
||||||
if upload.get("sha256") != sha256 or int(upload.get("total", 0)) != total:
|
if upload.get("sha256") != sha256 or int(upload.get("total", 0)) != total:
|
||||||
@@ -138,6 +222,7 @@ class TrainingCoordinator:
|
|||||||
received = sum(1 for part in range(total) if (chunk_dir / f"{part:06d}.part").is_file())
|
received = sum(1 for part in range(total) if (chunk_dir / f"{part:06d}.part").is_file())
|
||||||
if received < total:
|
if received < total:
|
||||||
upload["received"] = received
|
upload["received"] = received
|
||||||
|
job["updated_at"] = _now()
|
||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
return {"complete": False, "received": received, "total": total}
|
return {"complete": False, "received": received, "total": total}
|
||||||
|
|
||||||
@@ -169,6 +254,7 @@ class TrainingCoordinator:
|
|||||||
{"name": name, "sha256": sha256, "size": size, "staged_at": _now()}
|
{"name": name, "sha256": sha256, "size": size, "staged_at": _now()}
|
||||||
)
|
)
|
||||||
job["artifacts"] = artifacts
|
job["artifacts"] = artifacts
|
||||||
|
job["updated_at"] = _now()
|
||||||
upload["received"] = total
|
upload["received"] = total
|
||||||
upload["complete"] = True
|
upload["complete"] = True
|
||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
@@ -184,6 +270,7 @@ class TrainingCoordinator:
|
|||||||
raise ValueError(f"training job not found: {job_id}")
|
raise ValueError(f"training job not found: {job_id}")
|
||||||
if job.get("status") != "running" or not job.get("claimed_by"):
|
if job.get("status") != "running" or not job.get("claimed_by"):
|
||||||
raise ValueError("training job is not claimed and running")
|
raise ValueError("training job is not claimed and running")
|
||||||
|
self._require_lease(job, payload)
|
||||||
if isinstance(payload.get("worker"), dict):
|
if isinstance(payload.get("worker"), dict):
|
||||||
state["worker"] = self._worker_from_payload(payload["worker"])
|
state["worker"] = self._worker_from_payload(payload["worker"])
|
||||||
job["status"] = "running"
|
job["status"] = "running"
|
||||||
@@ -194,7 +281,11 @@ class TrainingCoordinator:
|
|||||||
if isinstance(payload.get("details"), dict):
|
if isinstance(payload.get("details"), dict):
|
||||||
job["details"] = payload["details"]
|
job["details"] = payload["details"]
|
||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
return {"ok": True, "job": job, "status": self._public_status(state)}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"job": self._public_job(job),
|
||||||
|
"status": self._public_status(state),
|
||||||
|
}
|
||||||
|
|
||||||
def complete(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
def complete(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
@@ -206,8 +297,18 @@ class TrainingCoordinator:
|
|||||||
raise ValueError(f"training job not found: {job_id}")
|
raise ValueError(f"training job not found: {job_id}")
|
||||||
if job.get("status") != "running" or not job.get("claimed_by"):
|
if job.get("status") != "running" or not job.get("claimed_by"):
|
||||||
raise ValueError("training job is not claimed and running")
|
raise ValueError("training job is not claimed and running")
|
||||||
|
self._require_lease(job, payload)
|
||||||
success = bool(payload.get("success", payload.get("status") == "completed"))
|
success = bool(payload.get("success", payload.get("status") == "completed"))
|
||||||
if success and job.get("artifacts"):
|
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)
|
promoted = self._validate_and_promote(job_id, job)
|
||||||
job["promoted_artifacts"] = promoted
|
job["promoted_artifacts"] = promoted
|
||||||
job["status"] = "completed" if success else "failed"
|
job["status"] = "completed" if success else "failed"
|
||||||
@@ -217,12 +318,19 @@ class TrainingCoordinator:
|
|||||||
job["message"] = str(payload.get("message") or "")
|
job["message"] = str(payload.get("message") or "")
|
||||||
if isinstance(payload.get("summary"), dict):
|
if isinstance(payload.get("summary"), dict):
|
||||||
job["summary"] = payload["summary"]
|
job["summary"] = payload["summary"]
|
||||||
if isinstance(payload["summary"].get("accepted"), bool):
|
if str(payload["summary"].get("state") or "").startswith("collecting"):
|
||||||
|
job["model_decision"] = "collecting"
|
||||||
|
elif isinstance(payload["summary"].get("accepted"), bool):
|
||||||
job["model_decision"] = (
|
job["model_decision"] = (
|
||||||
"accepted" if payload["summary"]["accepted"] else "rejected"
|
"accepted" if payload["summary"]["accepted"] else "rejected"
|
||||||
)
|
)
|
||||||
|
job.pop("lease_token", None)
|
||||||
self._save_state(state)
|
self._save_state(state)
|
||||||
return {"ok": True, "job": job, "status": self._public_status(state)}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"job": self._public_job(job),
|
||||||
|
"status": self._public_status(state),
|
||||||
|
}
|
||||||
|
|
||||||
def _validate_and_promote(self, job_id: str, job: dict[str, Any]) -> list[dict[str, Any]]:
|
def _validate_and_promote(self, job_id: str, job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
ready_dir = self.upload_root / job_id / "ready"
|
ready_dir = self.upload_root / job_id / "ready"
|
||||||
@@ -283,6 +391,60 @@ class TrainingCoordinator:
|
|||||||
_remove_tree(self.upload_root / job_id)
|
_remove_tree(self.upload_root / job_id)
|
||||||
return promoted
|
return promoted
|
||||||
|
|
||||||
|
def _validate_and_stage_shadow(self, job_id: str, job: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
ready_dir = self.upload_root / job_id / "ready"
|
||||||
|
staged = {path.name for path in ready_dir.iterdir() if path.is_file()} if ready_dir.is_dir() else set()
|
||||||
|
missing = REQUIRED_SHADOW_BUNDLE - staged
|
||||||
|
if missing:
|
||||||
|
raise ValueError("shadow training bundle is incomplete: " + ", ".join(sorted(missing)))
|
||||||
|
|
||||||
|
model_path = ready_dir / "lstm_forecaster.shadow.json"
|
||||||
|
calibration_path = ready_dir / "torch_shadow_calibration.json"
|
||||||
|
guard_path = ready_dir / "torch_shadow_guard.json"
|
||||||
|
model = _read_json(model_path)
|
||||||
|
calibration = _read_json(calibration_path)
|
||||||
|
guard = _read_json(guard_path)
|
||||||
|
if model.get("type") != "pytorch_recurrent_forecaster":
|
||||||
|
raise ValueError("shadow candidate model type is invalid")
|
||||||
|
symbols = model.get("symbols")
|
||||||
|
if not isinstance(symbols, dict) or not symbols:
|
||||||
|
raise ValueError("shadow candidate model has no symbol models")
|
||||||
|
_validate_symbol_models(symbols)
|
||||||
|
model_sha256 = hashlib.sha256(model_path.read_bytes()).hexdigest()
|
||||||
|
if calibration.get("artifact_sha256") != model_sha256:
|
||||||
|
raise ValueError("shadow calibration is not bound to the uploaded model")
|
||||||
|
if not bool(guard.get("accepted")):
|
||||||
|
raise ValueError("shadow candidate did not pass the offline guard")
|
||||||
|
if guard.get("candidate_artifact_sha256") != model_sha256:
|
||||||
|
raise ValueError("shadow guard is not bound to the uploaded model")
|
||||||
|
validation = calibration.get("validation")
|
||||||
|
if not isinstance(validation, dict) or not _validation_passed(validation):
|
||||||
|
raise ValueError("shadow candidate offline quality gate did not pass")
|
||||||
|
if validation.get("protocol") != "untouched_model_holdout_with_threshold_walk_forward":
|
||||||
|
raise ValueError("shadow candidate validation protocol is not an untouched holdout")
|
||||||
|
|
||||||
|
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
artifact_rows = {
|
||||||
|
str(item.get("name")): item
|
||||||
|
for item in job.get("artifacts", [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
installed: list[dict[str, Any]] = []
|
||||||
|
for name in sorted(REQUIRED_SHADOW_BUNDLE):
|
||||||
|
target_tmp = self.runtime_dir / f".{name}.{job_id}.stage"
|
||||||
|
shutil.copy2(ready_dir / name, target_tmp)
|
||||||
|
os.replace(target_tmp, self.runtime_dir / name)
|
||||||
|
row = artifact_rows.get(name, {})
|
||||||
|
installed.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"sha256": row.get("sha256", ""),
|
||||||
|
"staged_at": _now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_remove_tree(self.upload_root / job_id)
|
||||||
|
return installed
|
||||||
|
|
||||||
def _load_state(self) -> dict[str, Any]:
|
def _load_state(self) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
data = json.loads(self.state_path.read_text(encoding="utf-8"))
|
data = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||||
@@ -327,11 +489,27 @@ class TrainingCoordinator:
|
|||||||
"agent_recently_seen": recently_seen,
|
"agent_recently_seen": recently_seen,
|
||||||
"agent_busy": agent_busy,
|
"agent_busy": agent_busy,
|
||||||
"worker": worker,
|
"worker": worker,
|
||||||
"active_job": active,
|
"active_job": self._public_job(active),
|
||||||
"latest_job": latest,
|
"latest_job": self._public_job(latest),
|
||||||
"pending_jobs": sum(1 for job in state.get("jobs", []) if job.get("status") == "pending"),
|
"pending_jobs": sum(1 for job in state.get("jobs", []) if job.get("status") == "pending"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _public_job(job: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if job is None:
|
||||||
|
return None
|
||||||
|
public = dict(job)
|
||||||
|
public.pop("lease_token", None)
|
||||||
|
public.pop("uploads", None)
|
||||||
|
return public
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_lease(job: dict[str, Any], payload: dict[str, Any]) -> None:
|
||||||
|
expected = str(job.get("lease_token") or "")
|
||||||
|
supplied = str(payload.get("lease_token") or "")
|
||||||
|
if not expected or not supplied or not hmac.compare_digest(expected, supplied):
|
||||||
|
raise ValueError("training job lease is invalid or expired")
|
||||||
|
|
||||||
def _active_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
|
def _active_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
for job in reversed(state.get("jobs", [])):
|
for job in reversed(state.get("jobs", [])):
|
||||||
if job.get("status") in {"pending", "running"}:
|
if job.get("status") in {"pending", "running"}:
|
||||||
@@ -355,11 +533,30 @@ class TrainingCoordinator:
|
|||||||
for job in state.get("jobs", []):
|
for job in state.get("jobs", []):
|
||||||
if job.get("status") != "running":
|
if job.get("status") != "running":
|
||||||
continue
|
continue
|
||||||
claimed_at = _parse_time(str(job.get("claimed_at") or ""))
|
lease_updated_at = _parse_time(
|
||||||
if claimed_at and now - claimed_at > RUNNING_TIMEOUT:
|
str(job.get("updated_at") or job.get("claimed_at") or "")
|
||||||
|
)
|
||||||
|
if not lease_updated_at or now - lease_updated_at <= RUNNING_LEASE_TIMEOUT:
|
||||||
|
continue
|
||||||
|
job_id = str(job.get("id") or "")
|
||||||
|
if job_id:
|
||||||
|
_remove_tree(self.upload_root / job_id)
|
||||||
|
job.pop("lease_token", None)
|
||||||
|
job.pop("uploads", None)
|
||||||
|
attempts = int(job.get("attempts", 0))
|
||||||
|
if attempts < MAX_JOB_ATTEMPTS:
|
||||||
|
job["status"] = "pending"
|
||||||
|
job["phase"] = "queued"
|
||||||
|
job["progress_percent"] = 0
|
||||||
|
job["message"] = "training worker lease expired; queued for retry"
|
||||||
|
job["retry_queued_at"] = _now()
|
||||||
|
for key in ("claimed_at", "claimed_by", "worker", "updated_at"):
|
||||||
|
job.pop(key, None)
|
||||||
|
else:
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
|
job["phase"] = "failed"
|
||||||
job["completed_at"] = _now()
|
job["completed_at"] = _now()
|
||||||
job["message"] = "training worker timeout"
|
job["message"] = "training worker lease expired after maximum retries"
|
||||||
|
|
||||||
def _trim_jobs(self, state: dict[str, Any]) -> None:
|
def _trim_jobs(self, state: dict[str, Any]) -> None:
|
||||||
jobs = state.get("jobs", [])
|
jobs = state.get("jobs", [])
|
||||||
@@ -394,6 +591,10 @@ def _safe_parameters(value: Any) -> dict[str, Any]:
|
|||||||
"interval",
|
"interval",
|
||||||
"pooled",
|
"pooled",
|
||||||
"resume_candidate",
|
"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}
|
result = {key: value[key] for key in allowed if key in value}
|
||||||
for key, low, high in (
|
for key, low, high in (
|
||||||
@@ -405,6 +606,9 @@ def _safe_parameters(value: Any) -> dict[str, Any]:
|
|||||||
("horizon", 1, 96),
|
("horizon", 1, 96),
|
||||||
("patience", 1, 50),
|
("patience", 1, 50),
|
||||||
("seed", 1, 2_147_483_647),
|
("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:
|
if key not in result:
|
||||||
continue
|
continue
|
||||||
@@ -453,6 +657,8 @@ def _safe_parameters(value: Any) -> dict[str, Any]:
|
|||||||
result["pooled"] = result["pooled"] is True
|
result["pooled"] = result["pooled"] is True
|
||||||
if "resume_candidate" in result:
|
if "resume_candidate" in result:
|
||||||
result["resume_candidate"] = result["resume_candidate"] is True
|
result["resume_candidate"] = result["resume_candidate"] is True
|
||||||
|
if "use_orderbook" in result:
|
||||||
|
result["use_orderbook"] = result["use_orderbook"] is True
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
+8
-2
@@ -7,15 +7,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PYTHONDONTWRITEBYTECODE: "1"
|
PYTHONDONTWRITEBYTECODE: "1"
|
||||||
user: "1000:1000"
|
|
||||||
init: true
|
init: true
|
||||||
read_only: true
|
read_only: true
|
||||||
|
pids_limit: 128
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8787:8787"
|
- "${TRADEBOT_BIND_ADDRESS:-127.0.0.1}:8787:8787"
|
||||||
volumes:
|
volumes:
|
||||||
- ./.env:/app/.env:ro
|
- ./.env:/app/.env:ro
|
||||||
- ./runtime:/app/runtime
|
- ./runtime:/app/runtime
|
||||||
@@ -27,4 +27,10 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
stop_grace_period: 30s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
pytest==8.4.2
|
pytest==9.1.1
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.139.0
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.51.0
|
||||||
requests==2.32.3
|
requests==2.34.2
|
||||||
websockets==14.1
|
websockets==16.1
|
||||||
|
|||||||
@@ -125,3 +125,14 @@ def test_websocket_subscribe_uses_configured_kline_interval() -> None:
|
|||||||
|
|
||||||
assert "kline.60.BTCUSDT" in payload
|
assert "kline.60.BTCUSDT" in payload
|
||||||
assert "kline.1.BTCUSDT" not in payload
|
assert "kline.1.BTCUSDT" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_orderbook_level_one_preserves_sizes(make_settings, tmp_path) -> None:
|
||||||
|
client = BybitClient(make_settings(tmp_path))
|
||||||
|
client.public_get = lambda *_args, **_kwargs: {
|
||||||
|
"b": [["100.5", "2.25"]],
|
||||||
|
"a": [["100.7", "1.75"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert client.orderbook_level_one("BTCUSDT") == (100.5, 2.25, 100.7, 1.75)
|
||||||
|
assert client.orderbook_top("BTCUSDT") == (100.5, 100.7)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from tools.calibrate_torch_thresholds import (
|
|||||||
_average_selected_predictions,
|
_average_selected_predictions,
|
||||||
_apply_platt_calibration,
|
_apply_platt_calibration,
|
||||||
_build_torch_model,
|
_build_torch_model,
|
||||||
|
_calibration_horizon,
|
||||||
|
_calibration_symbols,
|
||||||
_choose_recommendation,
|
_choose_recommendation,
|
||||||
_full_backtest,
|
_full_backtest,
|
||||||
_fit_platt_calibration,
|
_fit_platt_calibration,
|
||||||
@@ -62,6 +64,29 @@ def _record(index: int, probability: float, future: float) -> ForecastRecord:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def test_calibration_does_not_fallback_to_too_few_trades() -> None:
|
||||||
selected = _choose_recommendation(
|
selected = _choose_recommendation(
|
||||||
[_result(trades=1, average=2.0, total=2.0, profit_factor=999.0)],
|
[_result(trades=1, average=2.0, total=2.0, profit_factor=999.0)],
|
||||||
|
|||||||
+21
-3
@@ -78,7 +78,7 @@ def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None:
|
|||||||
assert settings.llm_advisor_enabled is False
|
assert settings.llm_advisor_enabled is False
|
||||||
|
|
||||||
|
|
||||||
def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None:
|
def test_default_symbols_are_discovered_from_bybit(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.delenv("AUTO_SELECT_SYMBOLS", raising=False)
|
monkeypatch.delenv("AUTO_SELECT_SYMBOLS", raising=False)
|
||||||
monkeypatch.delenv("TOP_SYMBOLS_COUNT", raising=False)
|
monkeypatch.delenv("TOP_SYMBOLS_COUNT", raising=False)
|
||||||
monkeypatch.delenv("SYMBOLS", raising=False)
|
monkeypatch.delenv("SYMBOLS", raising=False)
|
||||||
@@ -89,9 +89,9 @@ def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None:
|
|||||||
|
|
||||||
settings = load_settings(env_file)
|
settings = load_settings(env_file)
|
||||||
|
|
||||||
assert settings.auto_select_symbols is False
|
assert settings.auto_select_symbols is True
|
||||||
assert settings.top_symbols_count == len(FIXED_SPOT_SYMBOLS)
|
assert settings.top_symbols_count == len(FIXED_SPOT_SYMBOLS)
|
||||||
assert settings.symbols == FIXED_SPOT_SYMBOLS
|
assert settings.symbols == ()
|
||||||
assert settings.strategy_mode == "torch_forecast"
|
assert settings.strategy_mode == "torch_forecast"
|
||||||
assert settings.base_interval == "60"
|
assert settings.base_interval == "60"
|
||||||
assert settings.trend_interval == "D"
|
assert settings.trend_interval == "D"
|
||||||
@@ -171,3 +171,21 @@ def test_load_settings_rejects_inconsistent_exposure_limits(tmp_path, monkeypatc
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="MAX_SYMBOL_EXPOSURE_USDT"):
|
with pytest.raises(ValueError, match="MAX_SYMBOL_EXPOSURE_USDT"):
|
||||||
load_settings(env_file)
|
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
|
import json
|
||||||
|
|
||||||
|
from crypto_spot_bot.dashboard import _compact_markets
|
||||||
from crypto_spot_bot.dashboard import _apply_fast_trading
|
from crypto_spot_bot.dashboard import _apply_fast_trading
|
||||||
from crypto_spot_bot.dashboard import _safe_config
|
from crypto_spot_bot.dashboard import _safe_config
|
||||||
from crypto_spot_bot.dashboard import WEB_UI_REMOVED_MESSAGE
|
from crypto_spot_bot.dashboard import WEB_INDEX
|
||||||
from crypto_spot_bot.storage import Storage
|
from crypto_spot_bot.storage import Storage
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +46,9 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
|
|||||||
assert config["time_series_probe_min_probability_up"] == 0.55
|
assert config["time_series_probe_min_probability_up"] == 0.55
|
||||||
assert config["time_series_probe_size_multiplier"] == 0.40
|
assert config["time_series_probe_size_multiplier"] == 0.40
|
||||||
assert config["time_series_rebound_fallback_enabled"] is True
|
assert config["time_series_rebound_fallback_enabled"] is True
|
||||||
|
assert config["time_series_fallback_mode"] == "trend_macd"
|
||||||
|
assert config["market_observation_enabled"] is True
|
||||||
|
assert config["market_observation_sample_seconds"] == 30.0
|
||||||
assert config["time_series_model_artifact"] == {
|
assert config["time_series_model_artifact"] == {
|
||||||
"available": True,
|
"available": True,
|
||||||
"type": "pytorch_recurrent_forecaster",
|
"type": "pytorch_recurrent_forecaster",
|
||||||
@@ -58,6 +62,49 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_web_ui_is_removed_from_api_service() -> None:
|
def test_web_ui_assets_are_available() -> None:
|
||||||
assert "Web UI removed" in WEB_UI_REMOVED_MESSAGE
|
html = WEB_INDEX.read_text(encoding="utf-8")
|
||||||
assert "/api/*" in WEB_UI_REMOVED_MESSAGE
|
script = WEB_INDEX.with_name("dashboard.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "TradeBot — панель управления" in html
|
||||||
|
assert "/assets/dashboard.css" in html
|
||||||
|
assert "/assets/dashboard.js" in html
|
||||||
|
assert 'id="usernameInput"' in html
|
||||||
|
assert 'id="passwordInput"' in html
|
||||||
|
assert "/web-api/dashboard/snapshot" in script
|
||||||
|
assert 'headers["X-TradeBot-Token"] = state.token' in script
|
||||||
|
assert 'credentials: "omit"' in script
|
||||||
|
assert "AbortController" in script
|
||||||
|
assert 'id="authSubmitButton"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_compact_markets_keeps_dashboard_fields_and_limits_candles() -> None:
|
||||||
|
candles = [{"timestamp": index, "close": 100 + index, "open": 1} for index in range(60)]
|
||||||
|
|
||||||
|
compact = _compact_markets(
|
||||||
|
{
|
||||||
|
"symbols": ["BTCUSDT"],
|
||||||
|
"ws_connected": True,
|
||||||
|
"rest_error_count": 2,
|
||||||
|
"markets": [
|
||||||
|
{
|
||||||
|
"ticker": {"symbol": "BTCUSDT", "last_price": 160},
|
||||||
|
"candles": candles,
|
||||||
|
"forecast": {"expected_return_percent": 0.4},
|
||||||
|
"shadow_forecast": {"expected_return_percent": 0.5},
|
||||||
|
"orderbook": {"spread_bps": 1.2},
|
||||||
|
"quality": {"status": "ok"},
|
||||||
|
"instrument": {"symbol": "BTCUSDT"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert compact["ws_connected"] is True
|
||||||
|
assert compact["rest_error_count"] == 2
|
||||||
|
assert compact["markets"][0]["ticker"]["symbol"] == "BTCUSDT"
|
||||||
|
assert len(compact["markets"][0]["sparkline"]) == 48
|
||||||
|
assert compact["markets"][0]["sparkline"][0] == {"timestamp": 12, "close": 112}
|
||||||
|
assert "instrument" not in compact["markets"][0]
|
||||||
|
assert "orderbook" not in compact["markets"][0]
|
||||||
|
assert "shadow_forecast" not in compact["markets"][0]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from crypto_spot_bot.market_data import _candles_due, _closed_candles, _is_closed_kline_row
|
from crypto_spot_bot.market_data import MarketData, _candles_due, _closed_candles, _is_closed_kline_row
|
||||||
from crypto_spot_bot.models import Candle
|
from crypto_spot_bot.models import Candle
|
||||||
|
from crypto_spot_bot.storage import Storage
|
||||||
|
|
||||||
|
|
||||||
def test_closed_candles_excludes_current_open_interval() -> None:
|
def test_closed_candles_excludes_current_open_interval() -> None:
|
||||||
@@ -26,3 +27,37 @@ def test_rest_candles_refresh_only_after_next_bar_closes() -> None:
|
|||||||
|
|
||||||
assert _candles_due([candle], "1", now_ms=11 * 60_000 + 30_000) is False
|
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
|
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": "",
|
||||||
|
}
|
||||||
@@ -62,6 +62,57 @@ def test_prune_deletes_only_one_bounded_batch_per_table(tmp_path) -> None:
|
|||||||
assert len(storage.recent_signals(PRUNE_BATCH_SIZE + 10)) == 5
|
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:
|
def test_runtime_compaction_preserves_durable_state_and_bounds_telemetry(tmp_path) -> None:
|
||||||
database = tmp_path / "tradebot.sqlite3"
|
database = tmp_path / "tradebot.sqlite3"
|
||||||
storage = Storage(database)
|
storage = Storage(database)
|
||||||
|
|||||||
+202
-2
@@ -2,9 +2,85 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from crypto_spot_bot.models import Candle, Position, Ticker, utc_now
|
from crypto_spot_bot.models import Candle, Position, Signal, Ticker, utc_now
|
||||||
from crypto_spot_bot.patterns import PatternAnalyzer
|
from crypto_spot_bot.patterns import PatternAnalyzer
|
||||||
from crypto_spot_bot.strategy import SpotStrategy
|
from crypto_spot_bot.strategy import SpotStrategy, apply_profit_only_exit_policy
|
||||||
|
|
||||||
|
|
||||||
|
def test_profit_only_policy_blocks_every_ordinary_loss_exit(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
profit_only_exit_enabled=True,
|
||||||
|
min_exit_net_percent=0.31,
|
||||||
|
taker_fee_rate=0.001,
|
||||||
|
slippage_rate=0.0003,
|
||||||
|
)
|
||||||
|
position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 100)
|
||||||
|
ticker = Ticker("ETHUSDT", 100.2, 100.19, 100.21, 1_000_000, 100, 0)
|
||||||
|
candidate = Signal("ETHUSDT", "SELL", 0.76, "RSI high and price turned down")
|
||||||
|
|
||||||
|
decision = apply_profit_only_exit_policy(settings, position, ticker, candidate)
|
||||||
|
|
||||||
|
assert decision.action == "HOLD"
|
||||||
|
assert decision.diagnostics["exit_policy_blocked"] is True
|
||||||
|
assert decision.diagnostics["blocked_sell_reason"] == candidate.reason
|
||||||
|
assert decision.diagnostics["expected_exit_net_percent"] < settings.min_exit_net_percent
|
||||||
|
|
||||||
|
|
||||||
|
def test_profit_only_policy_allows_exit_above_net_margin(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(
|
||||||
|
tmp_path,
|
||||||
|
profit_only_exit_enabled=True,
|
||||||
|
min_exit_net_percent=0.31,
|
||||||
|
taker_fee_rate=0.001,
|
||||||
|
slippage_rate=0.0003,
|
||||||
|
)
|
||||||
|
position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 101)
|
||||||
|
ticker = Ticker("ETHUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0)
|
||||||
|
candidate = Signal("ETHUSDT", "SELL", 0.96, "take-profit")
|
||||||
|
|
||||||
|
decision = apply_profit_only_exit_policy(settings, position, ticker, candidate)
|
||||||
|
|
||||||
|
assert decision.action == "SELL"
|
||||||
|
assert decision.diagnostics["exit_policy_blocked"] is False
|
||||||
|
assert decision.diagnostics["expected_exit_net_percent"] >= settings.min_exit_net_percent
|
||||||
|
|
||||||
|
|
||||||
|
def test_profit_only_policy_uses_adaptive_minimum(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(tmp_path, profit_only_exit_enabled=True, min_exit_net_percent=0.20)
|
||||||
|
position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 101)
|
||||||
|
ticker = Ticker("ETHUSDT", 101, 100.99, 101.01, 1_000_000, 100, 0)
|
||||||
|
candidate = Signal(
|
||||||
|
"ETHUSDT",
|
||||||
|
"SELL",
|
||||||
|
0.76,
|
||||||
|
"EMA exit",
|
||||||
|
{"adaptive_rules": {"min_exit_profit_percent": 0.80}},
|
||||||
|
)
|
||||||
|
|
||||||
|
decision = apply_profit_only_exit_policy(settings, position, ticker, candidate)
|
||||||
|
|
||||||
|
assert decision.action == "HOLD"
|
||||||
|
assert decision.diagnostics["required_exit_net_percent"] == 0.80
|
||||||
|
|
||||||
|
|
||||||
|
def test_profit_only_policy_allows_explicit_emergency_loss_exit(make_settings, tmp_path) -> None:
|
||||||
|
settings = make_settings(tmp_path, profit_only_exit_enabled=True, min_exit_net_percent=0.31)
|
||||||
|
position = Position(1, "ETHUSDT", 1, 100, 100, 0.1, 96, 103.5, 100)
|
||||||
|
ticker = Ticker("ETHUSDT", 95, 94.99, 95.01, 1_000_000, 100, 0)
|
||||||
|
candidate = Signal(
|
||||||
|
"ETHUSDT",
|
||||||
|
"SELL",
|
||||||
|
1.0,
|
||||||
|
"configured emergency",
|
||||||
|
{"emergency_exit": True, "emergency_exit_type": "configured_stop_loss"},
|
||||||
|
)
|
||||||
|
|
||||||
|
decision = apply_profit_only_exit_policy(settings, position, ticker, candidate)
|
||||||
|
|
||||||
|
assert decision.action == "SELL"
|
||||||
|
assert decision.diagnostics["exit_policy_blocked"] is False
|
||||||
|
assert decision.diagnostics["expected_exit_net_percent"] < 0
|
||||||
|
|
||||||
|
|
||||||
def _ready_candles() -> list[Candle]:
|
def _ready_candles() -> list[Candle]:
|
||||||
@@ -631,6 +707,130 @@ def test_torch_forecast_uses_trend_exit_for_fallback_position(make_settings, tmp
|
|||||||
assert "MACD" in signal.reason
|
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:
|
def test_torch_forecast_allows_explicit_manual_quality_override(make_settings, tmp_path) -> None:
|
||||||
settings = make_settings(
|
settings = make_settings(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from tools.train_torch_recurrent_forecaster import (
|
|||||||
RecurrentReturnModel,
|
RecurrentReturnModel,
|
||||||
_barrier_outcome,
|
_barrier_outcome,
|
||||||
_export_head_state,
|
_export_head_state,
|
||||||
|
_prepare_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,3 +85,38 @@ def test_multitask_head_export_matches_runtime_inference() -> None:
|
|||||||
actual = _torch_head_outputs(context[0].tolist(), entry, hidden_size=4)
|
actual = _torch_head_outputs(context[0].tolist(), entry, hidden_size=4)
|
||||||
|
|
||||||
assert actual == pytest.approx(expected, abs=2e-6)
|
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,6 +3,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
|
|||||||
job_id = requested["job"]["id"]
|
job_id = requested["job"]["id"]
|
||||||
heartbeat = coordinator.heartbeat({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
|
heartbeat = coordinator.heartbeat({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
|
||||||
claimed = coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
|
claimed = coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
|
||||||
|
lease_token = claimed["lease_token"]
|
||||||
|
|
||||||
assert requested["queued"] is True
|
assert requested["queued"] is True
|
||||||
assert heartbeat["status"]["agent_online"] is True
|
assert heartbeat["status"]["agent_online"] is True
|
||||||
@@ -25,14 +27,23 @@ def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
|
|||||||
|
|
||||||
progress = coordinator.progress(
|
progress = coordinator.progress(
|
||||||
job_id,
|
job_id,
|
||||||
{"status": "running", "phase": "training", "progress_percent": 42, "message": "epoch 1"},
|
{
|
||||||
|
"status": "running",
|
||||||
|
"phase": "training",
|
||||||
|
"progress_percent": 42,
|
||||||
|
"message": "epoch 1",
|
||||||
|
"lease_token": lease_token,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert progress["job"]["phase"] == "training"
|
assert progress["job"]["phase"] == "training"
|
||||||
assert progress["job"]["progress_percent"] == 42
|
assert progress["job"]["progress_percent"] == 42
|
||||||
assert coordinator.status()["active_job"]["message"] == "epoch 1"
|
assert coordinator.status()["active_job"]["message"] == "epoch 1"
|
||||||
|
|
||||||
completed = coordinator.complete(job_id, {"success": True, "message": "ok"})
|
completed = coordinator.complete(
|
||||||
|
job_id,
|
||||||
|
{"success": True, "message": "ok", "lease_token": lease_token},
|
||||||
|
)
|
||||||
|
|
||||||
assert completed["job"]["status"] == "completed"
|
assert completed["job"]["status"] == "completed"
|
||||||
assert coordinator.status()["active_job"] is None
|
assert coordinator.status()["active_job"] is None
|
||||||
@@ -104,7 +115,7 @@ def test_training_coordinator_reports_worker_identity_from_heartbeat(tmp_path) -
|
|||||||
def test_training_coordinator_records_rejected_candidate_as_completed_training(tmp_path) -> None:
|
def test_training_coordinator_records_rejected_candidate_as_completed_training(tmp_path) -> None:
|
||||||
coordinator = TrainingCoordinator(tmp_path)
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
job = coordinator.request_retrain({"source": "android"})["job"]
|
job = coordinator.request_retrain({"source": "android"})["job"]
|
||||||
coordinator.claim({"worker_id": "worker-1"})
|
lease_token = coordinator.claim({"worker_id": "worker-1"})["lease_token"]
|
||||||
|
|
||||||
completed = coordinator.complete(
|
completed = coordinator.complete(
|
||||||
job["id"],
|
job["id"],
|
||||||
@@ -112,6 +123,7 @@ def test_training_coordinator_records_rejected_candidate_as_completed_training(t
|
|||||||
"success": True,
|
"success": True,
|
||||||
"message": "training completed; candidate rejected by quality gate",
|
"message": "training completed; candidate rejected by quality gate",
|
||||||
"summary": {"accepted": False, "reason": "candidate_failed_honest_validation"},
|
"summary": {"accepted": False, "reason": "candidate_failed_honest_validation"},
|
||||||
|
"lease_token": lease_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,7 +136,7 @@ def test_training_coordinator_records_rejected_candidate_as_completed_training(t
|
|||||||
def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
|
def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
|
||||||
coordinator = TrainingCoordinator(tmp_path)
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
job = coordinator.request_retrain({"source": "test"})["job"]
|
job = coordinator.request_retrain({"source": "test"})["job"]
|
||||||
coordinator.claim({"worker_id": "test-worker"})
|
lease_token = coordinator.claim({"worker_id": "test-worker"})["lease_token"]
|
||||||
payload = b'{"type":"pytorch_recurrent_forecaster","symbols":{}}\n'
|
payload = b'{"type":"pytorch_recurrent_forecaster","symbols":{}}\n'
|
||||||
sha256 = hashlib.sha256(payload).hexdigest()
|
sha256 = hashlib.sha256(payload).hexdigest()
|
||||||
first = payload[:20]
|
first = payload[:20]
|
||||||
@@ -138,6 +150,7 @@ def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
|
|||||||
"total": 2,
|
"total": 2,
|
||||||
"sha256": sha256,
|
"sha256": sha256,
|
||||||
"data_base64": base64.b64encode(first).decode("ascii"),
|
"data_base64": base64.b64encode(first).decode("ascii"),
|
||||||
|
"lease_token": lease_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
part_2 = coordinator.save_artifact_chunk(
|
part_2 = coordinator.save_artifact_chunk(
|
||||||
@@ -148,6 +161,7 @@ def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
|
|||||||
"total": 2,
|
"total": 2,
|
||||||
"sha256": sha256,
|
"sha256": sha256,
|
||||||
"data_base64": base64.b64encode(second).decode("ascii"),
|
"data_base64": base64.b64encode(second).decode("ascii"),
|
||||||
|
"lease_token": lease_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -199,6 +213,35 @@ def test_running_claimed_job_keeps_agent_online_when_heartbeat_is_stale(tmp_path
|
|||||||
assert status["agent_online"] is True
|
assert status["agent_online"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_training_lease_is_requeued_and_old_lease_is_rejected(tmp_path) -> None:
|
||||||
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
|
job = coordinator.request_retrain({"source": "android"})["job"]
|
||||||
|
first_claim = coordinator.claim({"worker_id": "worker-1"})
|
||||||
|
|
||||||
|
state_path = tmp_path / "training_coordination.json"
|
||||||
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
|
state["jobs"][0]["updated_at"] = (
|
||||||
|
datetime.now(UTC) - timedelta(minutes=11)
|
||||||
|
).isoformat()
|
||||||
|
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||||
|
|
||||||
|
second_claim = coordinator.claim({"worker_id": "worker-2"})
|
||||||
|
|
||||||
|
assert second_claim["claimed"] is True
|
||||||
|
assert second_claim["job"]["id"] == job["id"]
|
||||||
|
assert second_claim["job"]["attempts"] == 2
|
||||||
|
assert second_claim["lease_token"] != first_claim["lease_token"]
|
||||||
|
with pytest.raises(ValueError, match="lease"):
|
||||||
|
coordinator.progress(
|
||||||
|
job["id"],
|
||||||
|
{
|
||||||
|
"phase": "training",
|
||||||
|
"progress_percent": 10,
|
||||||
|
"lease_token": first_claim["lease_token"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_training_upload_rejects_unknown_job(tmp_path) -> None:
|
def test_training_upload_rejects_unknown_job(tmp_path) -> None:
|
||||||
coordinator = TrainingCoordinator(tmp_path)
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
payload = b"{}"
|
payload = b"{}"
|
||||||
@@ -219,7 +262,7 @@ def test_training_upload_rejects_unknown_job(tmp_path) -> None:
|
|||||||
def test_training_bundle_promotes_only_after_successful_guard(tmp_path) -> None:
|
def test_training_bundle_promotes_only_after_successful_guard(tmp_path) -> None:
|
||||||
coordinator = TrainingCoordinator(tmp_path)
|
coordinator = TrainingCoordinator(tmp_path)
|
||||||
job = coordinator.request_retrain({"source": "test"})["job"]
|
job = coordinator.request_retrain({"source": "test"})["job"]
|
||||||
coordinator.claim({"worker_id": "worker-1"})
|
lease_token = coordinator.claim({"worker_id": "worker-1"})["lease_token"]
|
||||||
model = {
|
model = {
|
||||||
"type": "pytorch_recurrent_forecaster",
|
"type": "pytorch_recurrent_forecaster",
|
||||||
"symbols": {
|
"symbols": {
|
||||||
@@ -260,10 +303,14 @@ def test_training_bundle_promotes_only_after_successful_guard(tmp_path) -> None:
|
|||||||
"total": 1,
|
"total": 1,
|
||||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
"data_base64": base64.b64encode(payload).decode("ascii"),
|
"data_base64": base64.b64encode(payload).decode("ascii"),
|
||||||
|
"lease_token": lease_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
completed = coordinator.complete(job["id"], {"success": True})
|
completed = coordinator.complete(
|
||||||
|
job["id"],
|
||||||
|
{"success": True, "lease_token": lease_token},
|
||||||
|
)
|
||||||
|
|
||||||
assert completed["job"]["status"] == "completed"
|
assert completed["job"]["status"] == "completed"
|
||||||
assert json.loads((tmp_path / "lstm_forecaster.json").read_text())["symbols"]["BTCUSDT"]
|
assert json.loads((tmp_path / "lstm_forecaster.json").read_text())["symbols"]["BTCUSDT"]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from crypto_spot_bot.bybit import BybitClient
|
|||||||
from crypto_spot_bot.config import load_settings
|
from crypto_spot_bot.config import load_settings
|
||||||
from crypto_spot_bot.indicators import add_indicators
|
from crypto_spot_bot.indicators import add_indicators
|
||||||
from crypto_spot_bot.models import Candle
|
from crypto_spot_bot.models import Candle
|
||||||
|
from crypto_spot_bot.orderbook_features import ORDERBOOK_FEATURES, load_orderbook_feature_map
|
||||||
from crypto_spot_bot.time_series import (
|
from crypto_spot_bot.time_series import (
|
||||||
DEFAULT_TORCH_FEATURES,
|
DEFAULT_TORCH_FEATURES,
|
||||||
_barrier_outcome,
|
_barrier_outcome,
|
||||||
@@ -88,15 +89,23 @@ def main() -> None:
|
|||||||
if torch is not None and args.threads > 0:
|
if torch is not None and args.threads > 0:
|
||||||
torch.set_num_threads(args.threads)
|
torch.set_num_threads(args.threads)
|
||||||
settings = load_settings(args.env)
|
settings = load_settings(args.env)
|
||||||
client = BybitClient(settings)
|
|
||||||
symbols = _symbols(args.symbols, settings.symbols)
|
|
||||||
context_symbols = sorted(set(symbols + _symbols(args.context_symbols, ())))
|
|
||||||
artifact_path = Path(args.artifact or settings.time_series_lstm_model_path)
|
artifact_path = Path(args.artifact or settings.time_series_lstm_model_path)
|
||||||
artifact_bytes = artifact_path.read_bytes()
|
artifact_bytes = artifact_path.read_bytes()
|
||||||
artifact_sha256 = hashlib.sha256(artifact_bytes).hexdigest()
|
artifact_sha256 = hashlib.sha256(artifact_bytes).hexdigest()
|
||||||
artifact = json.loads(artifact_bytes.decode("utf-8"))
|
artifact = json.loads(artifact_bytes.decode("utf-8"))
|
||||||
|
client = BybitClient(settings)
|
||||||
|
symbols = _calibration_symbols(args.symbols, settings.symbols, artifact)
|
||||||
|
context_symbols = sorted(set(symbols + _symbols(args.context_symbols, ())))
|
||||||
horizon = args.horizon if args.horizon > 0 else settings.time_series_forecast_horizon
|
horizon = args.horizon if args.horizon > 0 else settings.time_series_forecast_horizon
|
||||||
round_trip_cost = _artifact_round_trip_cost(artifact, settings)
|
round_trip_cost = _artifact_round_trip_cost(artifact, settings)
|
||||||
|
orderbook_features: dict[str, dict[int, dict[str, float]]] = {}
|
||||||
|
if args.orderbook_db:
|
||||||
|
orderbook_features, _orderbook_manifest = load_orderbook_feature_map(
|
||||||
|
args.orderbook_db,
|
||||||
|
interval=settings.base_interval,
|
||||||
|
symbols=symbols,
|
||||||
|
min_samples_per_bucket=args.orderbook_min_samples_per_bucket,
|
||||||
|
)
|
||||||
|
|
||||||
market_candles: dict[str, list[Candle]] = {}
|
market_candles: dict[str, list[Candle]] = {}
|
||||||
for symbol in context_symbols:
|
for symbol in context_symbols:
|
||||||
@@ -120,10 +129,12 @@ def main() -> None:
|
|||||||
trend_candles=trend_candles,
|
trend_candles=trend_candles,
|
||||||
artifact=artifact,
|
artifact=artifact,
|
||||||
horizon=horizon,
|
horizon=horizon,
|
||||||
|
horizon_is_explicit=args.horizon > 0,
|
||||||
round_trip_cost=round_trip_cost,
|
round_trip_cost=round_trip_cost,
|
||||||
min_candles=max(30, settings.time_series_min_candles),
|
min_candles=max(30, settings.time_series_min_candles),
|
||||||
calibration_window=args.calibration_window,
|
calibration_window=args.calibration_window,
|
||||||
batch_size=args.batch_size,
|
batch_size=args.batch_size,
|
||||||
|
orderbook_features=orderbook_features,
|
||||||
)
|
)
|
||||||
records.extend(symbol_records)
|
records.extend(symbol_records)
|
||||||
per_symbol_counts[symbol] = len(symbol_records)
|
per_symbol_counts[symbol] = len(symbol_records)
|
||||||
@@ -277,7 +288,11 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
parser = argparse.ArgumentParser(description="Calibrate TradeBot Torch forecast entry thresholds.")
|
parser = argparse.ArgumentParser(description="Calibrate TradeBot Torch forecast entry thresholds.")
|
||||||
parser.add_argument("--env", default=None, help="Path to .env file.")
|
parser.add_argument("--env", default=None, help="Path to .env file.")
|
||||||
parser.add_argument("--artifact", default="", help="Path to lstm_forecaster.json.")
|
parser.add_argument("--artifact", default="", help="Path to lstm_forecaster.json.")
|
||||||
parser.add_argument("--symbols", default="", help="Comma-separated symbols. Defaults to configured fixed symbols.")
|
parser.add_argument(
|
||||||
|
"--symbols",
|
||||||
|
default="",
|
||||||
|
help="Comma-separated symbols. Defaults to configured fixed symbols, then artifact symbols.",
|
||||||
|
)
|
||||||
parser.add_argument("--context-symbols", default="BTCUSDT,ETHUSDT", help="Cross-asset context symbols.")
|
parser.add_argument("--context-symbols", default="BTCUSDT,ETHUSDT", help="Cross-asset context symbols.")
|
||||||
parser.add_argument("--limit", type=int, default=2000, help="Hourly candles per symbol.")
|
parser.add_argument("--limit", type=int, default=2000, help="Hourly candles per symbol.")
|
||||||
parser.add_argument("--trend-limit", type=int, default=320, help="Daily candles per symbol.")
|
parser.add_argument("--trend-limit", type=int, default=320, help="Daily candles per symbol.")
|
||||||
@@ -299,6 +314,8 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--min-oos-folds-with-trades", type=int, default=2, help="Minimum walk-forward folds that must produce trades.")
|
parser.add_argument("--min-oos-folds-with-trades", type=int, default=2, help="Minimum walk-forward folds that must produce trades.")
|
||||||
parser.add_argument("--min-oos-profit-factor", type=float, default=1.10, help="Minimum out-of-sample profit factor.")
|
parser.add_argument("--min-oos-profit-factor", type=float, default=1.10, help="Minimum out-of-sample profit factor.")
|
||||||
parser.add_argument("--min-benchmark-edge-percent", type=float, default=0.0, help="Required total-net percent advantage over the benchmark.")
|
parser.add_argument("--min-benchmark-edge-percent", type=float, default=0.0, help="Required total-net percent advantage over the benchmark.")
|
||||||
|
parser.add_argument("--orderbook-db", default="", help="SQLite cache used by an artifact with L1 features.")
|
||||||
|
parser.add_argument("--orderbook-min-samples-per-bucket", type=int, default=20)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -308,6 +325,32 @@ def _symbols(raw: str, fallback: tuple[str, ...] | list[str]) -> list[str]:
|
|||||||
return [str(item).upper() for item in fallback]
|
return [str(item).upper() for item in fallback]
|
||||||
|
|
||||||
|
|
||||||
|
def _calibration_symbols(
|
||||||
|
raw: str,
|
||||||
|
configured: tuple[str, ...] | list[str],
|
||||||
|
artifact: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
explicit = _symbols(raw, ())
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
fixed = _symbols("", configured)
|
||||||
|
if fixed:
|
||||||
|
return fixed
|
||||||
|
artifact_symbols = artifact.get("symbols")
|
||||||
|
if not isinstance(artifact_symbols, dict):
|
||||||
|
return []
|
||||||
|
return [str(symbol).strip().upper() for symbol in artifact_symbols if str(symbol).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _calibration_horizon(entry: dict[str, Any], requested: int, *, explicit: bool) -> int:
|
||||||
|
horizons = _entry_target_horizons(entry)
|
||||||
|
if explicit and requested > 0:
|
||||||
|
if horizons:
|
||||||
|
return min(horizons, key=lambda value: abs(value - requested))
|
||||||
|
return requested
|
||||||
|
return _entry_horizon(entry, requested)
|
||||||
|
|
||||||
|
|
||||||
def _forecast_records(
|
def _forecast_records(
|
||||||
*,
|
*,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -316,10 +359,12 @@ def _forecast_records(
|
|||||||
trend_candles: list[Candle],
|
trend_candles: list[Candle],
|
||||||
artifact: dict[str, Any],
|
artifact: dict[str, Any],
|
||||||
horizon: int,
|
horizon: int,
|
||||||
|
horizon_is_explicit: bool,
|
||||||
round_trip_cost: float,
|
round_trip_cost: float,
|
||||||
min_candles: int,
|
min_candles: int,
|
||||||
calibration_window: int,
|
calibration_window: int,
|
||||||
batch_size: int,
|
batch_size: int,
|
||||||
|
orderbook_features: dict[str, dict[int, dict[str, float]]] | None = None,
|
||||||
) -> list[ForecastRecord]:
|
) -> list[ForecastRecord]:
|
||||||
entry = _torch_recurrent_entry(symbol, artifact)
|
entry = _torch_recurrent_entry(symbol, artifact)
|
||||||
model = _torch_recurrent_model_name(symbol, artifact)
|
model = _torch_recurrent_model_name(symbol, artifact)
|
||||||
@@ -332,9 +377,10 @@ def _forecast_records(
|
|||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
market_candles=market_candles,
|
market_candles=market_candles,
|
||||||
trend_candles=trend_candles,
|
trend_candles=trend_candles,
|
||||||
|
orderbook_features=orderbook_features,
|
||||||
)
|
)
|
||||||
closes = [float(candle.close) for candle in candles]
|
closes = [float(candle.close) for candle in candles]
|
||||||
decision_horizon = _entry_horizon(entry, horizon)
|
decision_horizon = _calibration_horizon(entry, horizon, explicit=horizon_is_explicit)
|
||||||
start = max(min_candles, int(float(entry.get("lookback", 64))))
|
start = max(min_candles, int(float(entry.get("lookback", 64))))
|
||||||
end = len(candles) - decision_horizon - 1
|
end = len(candles) - decision_horizon - 1
|
||||||
holdout_start_timestamp = int(float(entry.get("holdout_start_timestamp", 0) or 0))
|
holdout_start_timestamp = int(float(entry.get("holdout_start_timestamp", 0) or 0))
|
||||||
@@ -344,6 +390,18 @@ def _forecast_records(
|
|||||||
start += 1
|
start += 1
|
||||||
if calibration_window > 0:
|
if calibration_window > 0:
|
||||||
start = max(start, end - calibration_window)
|
start = max(start, end - calibration_window)
|
||||||
|
lookback = max(1, int(float(entry.get("lookback", 64))))
|
||||||
|
requires_orderbook = any(name in ORDERBOOK_FEATURES for name in feature_names)
|
||||||
|
symbol_orderbook = (orderbook_features or {}).get(symbol.upper(), {})
|
||||||
|
valid_indices = {
|
||||||
|
index
|
||||||
|
for index in range(start, max(start, end))
|
||||||
|
if not requires_orderbook
|
||||||
|
or all(
|
||||||
|
candles[position].timestamp in symbol_orderbook
|
||||||
|
for position in range(index - lookback + 1, index + 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
batched_records = _batch_forecast_records(
|
batched_records = _batch_forecast_records(
|
||||||
symbol=symbol,
|
symbol=symbol,
|
||||||
candles=candles,
|
candles=candles,
|
||||||
@@ -357,6 +415,7 @@ def _forecast_records(
|
|||||||
start=start,
|
start=start,
|
||||||
end=end,
|
end=end,
|
||||||
batch_size=batch_size,
|
batch_size=batch_size,
|
||||||
|
valid_indices=valid_indices,
|
||||||
)
|
)
|
||||||
if batched_records is not None:
|
if batched_records is not None:
|
||||||
return batched_records
|
return batched_records
|
||||||
@@ -366,6 +425,8 @@ def _forecast_records(
|
|||||||
# belong exclusively to the final quality gate and cannot influence replay.
|
# belong exclusively to the final quality gate and cannot influence replay.
|
||||||
skill = _entry_validation_skill(entry)
|
skill = _entry_validation_skill(entry)
|
||||||
for index in range(start, max(start, end)):
|
for index in range(start, max(start, end)):
|
||||||
|
if index not in valid_indices:
|
||||||
|
continue
|
||||||
prediction = _torch_recurrent_predict(
|
prediction = _torch_recurrent_predict(
|
||||||
_log_returns(closes[: index + 1]),
|
_log_returns(closes[: index + 1]),
|
||||||
symbol,
|
symbol,
|
||||||
@@ -444,6 +505,7 @@ def _batch_forecast_records(
|
|||||||
start: int,
|
start: int,
|
||||||
end: int,
|
end: int,
|
||||||
batch_size: int,
|
batch_size: int,
|
||||||
|
valid_indices: set[int] | None = None,
|
||||||
) -> list[ForecastRecord] | None:
|
) -> list[ForecastRecord] | None:
|
||||||
if torch is None or RecurrentReturnModel is None:
|
if torch is None or RecurrentReturnModel is None:
|
||||||
return None
|
return None
|
||||||
@@ -462,7 +524,9 @@ def _batch_forecast_records(
|
|||||||
indices = [
|
indices = [
|
||||||
index
|
index
|
||||||
for index in range(start, max(start, end))
|
for index in range(start, max(start, end))
|
||||||
if index - lookback + 1 >= 0 and index + decision_horizon < len(closes)
|
if index - lookback + 1 >= 0
|
||||||
|
and index + decision_horizon < len(closes)
|
||||||
|
and (valid_indices is None or index in valid_indices)
|
||||||
]
|
]
|
||||||
if not indices:
|
if not indices:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ DEFAULT_RECENT_ROWS = {
|
|||||||
"equity": 5_000,
|
"equity": 5_000,
|
||||||
"events": 2_000,
|
"events": 2_000,
|
||||||
"llm_advice": 1_000,
|
"llm_advice": 1_000,
|
||||||
|
"market_observations": 100_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +115,11 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--equity", type=int, default=DEFAULT_RECENT_ROWS["equity"])
|
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("--events", type=int, default=DEFAULT_RECENT_ROWS["events"])
|
||||||
parser.add_argument("--llm-advice", type=int, default=DEFAULT_RECENT_ROWS["llm_advice"])
|
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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +133,7 @@ def main() -> None:
|
|||||||
"equity": args.equity,
|
"equity": args.equity,
|
||||||
"events": args.events,
|
"events": args.events,
|
||||||
"llm_advice": args.llm_advice,
|
"llm_advice": args.llm_advice,
|
||||||
|
"market_observations": args.market_observations,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
|
|||||||
@@ -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."
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
+45
-44
@@ -22,12 +22,10 @@ param(
|
|||||||
[int]$HoldoutWindow = 0,
|
[int]$HoldoutWindow = 0,
|
||||||
[string]$Interval = "",
|
[string]$Interval = "",
|
||||||
[string]$EnvFile = "",
|
[string]$EnvFile = "",
|
||||||
[switch]$DeployToPi,
|
[string]$OrderbookDb = "",
|
||||||
[string]$PiHost = "",
|
[int]$OrderbookMinSamplesPerBucket = 0,
|
||||||
[string]$PiUser = "",
|
[int]$OrderbookMinCoveredBuckets = 0,
|
||||||
[string]$PiRoot = "",
|
[int]$OrderbookMinSymbols = 0,
|
||||||
[string]$PiSshKeyPath = "",
|
|
||||||
[switch]$NoPiRestart,
|
|
||||||
[switch]$Pooled,
|
[switch]$Pooled,
|
||||||
[switch]$SkipGuard,
|
[switch]$SkipGuard,
|
||||||
[switch]$ResumeCandidate
|
[switch]$ResumeCandidate
|
||||||
@@ -105,44 +103,13 @@ function Test-TorchArtifactFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sync-AcceptedArtifactsToPi {
|
|
||||||
if (-not ($DeployToPi -or $env:TORCH_RETRAIN_DEPLOY_TO_PI)) {
|
|
||||||
Write-RetrainLog "Pi artifact sync disabled."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$syncScript = Join-Path $RepoRoot "tools\sync_torch_artifacts_to_pi.ps1"
|
|
||||||
if (-not (Test-Path $syncScript)) {
|
|
||||||
throw "Pi sync script not found: $syncScript"
|
|
||||||
}
|
|
||||||
|
|
||||||
$syncArgs = @(
|
|
||||||
"-NoProfile",
|
|
||||||
"-ExecutionPolicy", "Bypass",
|
|
||||||
"-File", $syncScript,
|
|
||||||
"-RepoRoot", $RepoRoot
|
|
||||||
)
|
|
||||||
if ($PiHost) { $syncArgs += @("-RemoteHost", $PiHost) }
|
|
||||||
if ($PiUser) { $syncArgs += @("-RemoteUser", $PiUser) }
|
|
||||||
if ($PiRoot) { $syncArgs += @("-RemoteRoot", $PiRoot) }
|
|
||||||
if ($PiSshKeyPath) { $syncArgs += @("-SshKeyPath", $PiSshKeyPath) }
|
|
||||||
if ($NoPiRestart) { $syncArgs += "-NoRestart" }
|
|
||||||
|
|
||||||
Write-RetrainLog "Syncing accepted Torch artifacts to Raspberry Pi."
|
|
||||||
& powershell.exe @syncArgs 2>&1 | Tee-Object -FilePath $LogFile -Append
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Pi artifact sync failed with exit code $LASTEXITCODE."
|
|
||||||
}
|
|
||||||
Write-RetrainLog "Pi artifact sync completed."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS }
|
if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS }
|
||||||
if ($Limit -le 0) {
|
if ($Limit -le 0) {
|
||||||
$Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 6000 }
|
$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 { "32,64,128" } }
|
if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "64" } }
|
||||||
if (-not $Architectures) { $Architectures = if ($env:TORCH_RETRAIN_ARCHITECTURES) { $env:TORCH_RETRAIN_ARCHITECTURES } else { "lstm,gru" } }
|
if (-not $Architectures) { $Architectures = if ($env:TORCH_RETRAIN_ARCHITECTURES) { $env:TORCH_RETRAIN_ARCHITECTURES } else { "lstm" } }
|
||||||
if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "64,96" } }
|
if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "64" } }
|
||||||
if (-not $Layers) { $Layers = if ($env:TORCH_RETRAIN_LAYERS) { $env:TORCH_RETRAIN_LAYERS } else { "2" } }
|
if (-not $Layers) { $Layers = if ($env:TORCH_RETRAIN_LAYERS) { $env:TORCH_RETRAIN_LAYERS } else { "2" } }
|
||||||
if (-not $Dropouts) { $Dropouts = if ($env:TORCH_RETRAIN_DROPOUTS) { $env:TORCH_RETRAIN_DROPOUTS } else { "0.20" } }
|
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 ($Horizon -le 0) { $Horizon = if ($env:TORCH_RETRAIN_HORIZON) { [int]$env:TORCH_RETRAIN_HORIZON } else { 12 } }
|
||||||
@@ -154,13 +121,17 @@ if (-not $EnsembleSeeds) { $EnsembleSeeds = if ($env:TORCH_RETRAIN_ENSEMBLE_SEED
|
|||||||
if ($SelectionFolds -le 0) { $SelectionFolds = if ($env:TORCH_RETRAIN_SELECTION_FOLDS) { [int]$env:TORCH_RETRAIN_SELECTION_FOLDS } else { 3 } }
|
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 ($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 ($WeightDecay -le 0) { $WeightDecay = if ($env:TORCH_RETRAIN_WEIGHT_DECAY) { [double]$env:TORCH_RETRAIN_WEIGHT_DECAY } else { 0.0005 } }
|
||||||
if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } }
|
if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 50 } }
|
||||||
if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } }
|
if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } }
|
||||||
if ($ValidationWindow -le 0) { $ValidationWindow = if ($env:TORCH_RETRAIN_VALIDATION_WINDOW) { [int]$env:TORCH_RETRAIN_VALIDATION_WINDOW } else { 720 } }
|
if ($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 ($HoldoutWindow -le 0) { $HoldoutWindow = if ($env:TORCH_RETRAIN_HOLDOUT_WINDOW) { [int]$env:TORCH_RETRAIN_HOLDOUT_WINDOW } else { 1000 } }
|
||||||
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
|
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
|
||||||
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
|
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
|
||||||
if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" }
|
if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" }
|
||||||
|
if (-not $OrderbookDb -and $env:TORCH_ORDERBOOK_DB) { $OrderbookDb = $env:TORCH_ORDERBOOK_DB }
|
||||||
|
if ($OrderbookMinSamplesPerBucket -le 0) { $OrderbookMinSamplesPerBucket = if ($env:TORCH_ORDERBOOK_MIN_SAMPLES_PER_BUCKET) { [int]$env:TORCH_ORDERBOOK_MIN_SAMPLES_PER_BUCKET } else { 20 } }
|
||||||
|
if ($OrderbookMinCoveredBuckets -le 0) { $OrderbookMinCoveredBuckets = if ($env:TORCH_ORDERBOOK_MIN_COVERED_BUCKETS) { [int]$env:TORCH_ORDERBOOK_MIN_COVERED_BUCKETS } else { 240 } }
|
||||||
|
if ($OrderbookMinSymbols -le 0) { $OrderbookMinSymbols = if ($env:TORCH_ORDERBOOK_MIN_SYMBOLS) { [int]$env:TORCH_ORDERBOOK_MIN_SYMBOLS } else { 2 } }
|
||||||
|
|
||||||
$ModelFile = if ($env:TIME_SERIES_LSTM_MODEL_PATH) { $env:TIME_SERIES_LSTM_MODEL_PATH } else { Join-Path $RuntimeDir "lstm_forecaster.json" }
|
$ModelFile = if ($env:TIME_SERIES_LSTM_MODEL_PATH) { $env:TIME_SERIES_LSTM_MODEL_PATH } else { Join-Path $RuntimeDir "lstm_forecaster.json" }
|
||||||
if (-not [System.IO.Path]::IsPathRooted($ModelFile)) { $ModelFile = Join-Path $RepoRoot $ModelFile }
|
if (-not [System.IO.Path]::IsPathRooted($ModelFile)) { $ModelFile = Join-Path $RepoRoot $ModelFile }
|
||||||
@@ -168,6 +139,10 @@ $CandidateFile = Join-Path $RuntimeDir "lstm_forecaster.candidate.json"
|
|||||||
$CurrentCalibration = Join-Path $RuntimeDir "torch_guard_current.json"
|
$CurrentCalibration = Join-Path $RuntimeDir "torch_guard_current.json"
|
||||||
$CandidateCalibration = Join-Path $RuntimeDir "torch_guard_candidate.json"
|
$CandidateCalibration = Join-Path $RuntimeDir "torch_guard_candidate.json"
|
||||||
$GuardReport = Join-Path $RuntimeDir "torch_retrain_guard.json"
|
$GuardReport = Join-Path $RuntimeDir "torch_retrain_guard.json"
|
||||||
|
$ShadowModelFile = Join-Path $RuntimeDir "lstm_forecaster.shadow.json"
|
||||||
|
$ShadowCalibration = Join-Path $RuntimeDir "torch_shadow_calibration.json"
|
||||||
|
$ShadowGuard = Join-Path $RuntimeDir "torch_shadow_guard.json"
|
||||||
|
$ShadowMode = -not [string]::IsNullOrWhiteSpace($OrderbookDb)
|
||||||
|
|
||||||
$mutex = New-Object System.Threading.Mutex($false, "TradeBotTorchRecurrentRetrainer")
|
$mutex = New-Object System.Threading.Mutex($false, "TradeBotTorchRecurrentRetrainer")
|
||||||
$hasLock = $false
|
$hasLock = $false
|
||||||
@@ -214,6 +189,14 @@ try {
|
|||||||
if ($Features) { $trainerArgs += @("--features", $Features) }
|
if ($Features) { $trainerArgs += @("--features", $Features) }
|
||||||
if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) }
|
if ($ContextSymbols) { $trainerArgs += @("--context-symbols", $ContextSymbols) }
|
||||||
if ($Seed -gt 0) { $trainerArgs += @("--seed", $Seed.ToString()) }
|
if ($Seed -gt 0) { $trainerArgs += @("--seed", $Seed.ToString()) }
|
||||||
|
if ($OrderbookDb) {
|
||||||
|
$trainerArgs += @(
|
||||||
|
"--orderbook-db", $OrderbookDb,
|
||||||
|
"--orderbook-min-samples-per-bucket", $OrderbookMinSamplesPerBucket.ToString(),
|
||||||
|
"--orderbook-min-covered-buckets", $OrderbookMinCoveredBuckets.ToString(),
|
||||||
|
"--orderbook-min-symbols", $OrderbookMinSymbols.ToString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Push-Location $RepoRoot
|
Push-Location $RepoRoot
|
||||||
$pushedLocation = $true
|
$pushedLocation = $true
|
||||||
@@ -253,6 +236,12 @@ try {
|
|||||||
)
|
)
|
||||||
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
|
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
|
||||||
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
|
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
|
||||||
|
if ($OrderbookDb) {
|
||||||
|
$calibrationBaseArgs += @(
|
||||||
|
"--orderbook-db", $OrderbookDb,
|
||||||
|
"--orderbook-min-samples-per-bucket", $OrderbookMinSamplesPerBucket.ToString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (Test-Path $ModelFile) {
|
if (Test-Path $ModelFile) {
|
||||||
Write-RetrainLog "Calibrating current artifact for guard."
|
Write-RetrainLog "Calibrating current artifact for guard."
|
||||||
@@ -280,13 +269,14 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-RetrainLog "Running retrain guard."
|
Write-RetrainLog "Running retrain guard."
|
||||||
|
$GuardTarget = if ($ShadowMode) { $ShadowModelFile } else { $ModelFile }
|
||||||
$guardArgs = @(
|
$guardArgs = @(
|
||||||
"-u",
|
"-u",
|
||||||
"tools\accept_torch_candidate.py",
|
"tools\accept_torch_candidate.py",
|
||||||
"--current-report", $CurrentCalibration,
|
"--current-report", $CurrentCalibration,
|
||||||
"--candidate-report", $CandidateCalibration,
|
"--candidate-report", $CandidateCalibration,
|
||||||
"--candidate-artifact", $CandidateFile,
|
"--candidate-artifact", $CandidateFile,
|
||||||
"--target-artifact", $ModelFile,
|
"--target-artifact", $GuardTarget,
|
||||||
"--report", $GuardReport
|
"--report", $GuardReport
|
||||||
)
|
)
|
||||||
$guardExitCode = Invoke-LoggedNativeCommand -FilePath $python -ArgumentList $guardArgs -LogPath $LogFile
|
$guardExitCode = Invoke-LoggedNativeCommand -FilePath $python -ArgumentList $guardArgs -LogPath $LogFile
|
||||||
@@ -298,11 +288,22 @@ try {
|
|||||||
throw "Retrain guard failed with exit code $guardExitCode."
|
throw "Retrain guard failed with exit code $guardExitCode."
|
||||||
}
|
}
|
||||||
if (Test-Path $CandidateCalibration) {
|
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")
|
Copy-Item -Force -LiteralPath $CandidateCalibration -Destination (Join-Path $RuntimeDir "torch_threshold_calibration.json")
|
||||||
Write-RetrainLog "Updated active threshold calibration: $(Join-Path $RuntimeDir "torch_threshold_calibration.json")"
|
Write-RetrainLog "Updated active threshold calibration: $(Join-Path $RuntimeDir "torch_threshold_calibration.json")"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if ($ShadowMode) {
|
||||||
|
Write-RetrainLog "Candidate accepted by offline guard. Active artifact was not changed: $ModelFile"
|
||||||
|
}
|
||||||
|
else {
|
||||||
Write-RetrainLog "Candidate accepted by guard. Active artifact: $ModelFile"
|
Write-RetrainLog "Candidate accepted by guard. Active artifact: $ModelFile"
|
||||||
Sync-AcceptedArtifactsToPi
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-RetrainLog "ERROR: $($_.Exception.Message)"
|
Write-RetrainLog "ERROR: $($_.Exception.Message)"
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -28,6 +28,7 @@ from crypto_spot_bot.bybit import BybitClient
|
|||||||
from crypto_spot_bot.config import load_settings
|
from crypto_spot_bot.config import load_settings
|
||||||
from crypto_spot_bot.indicators import add_indicators
|
from crypto_spot_bot.indicators import add_indicators
|
||||||
from crypto_spot_bot.models import Candle
|
from crypto_spot_bot.models import Candle
|
||||||
|
from crypto_spot_bot.orderbook_features import ORDERBOOK_FEATURES, load_orderbook_feature_map
|
||||||
from crypto_spot_bot.time_series import (
|
from crypto_spot_bot.time_series import (
|
||||||
DEFAULT_TORCH_FEATURES,
|
DEFAULT_TORCH_FEATURES,
|
||||||
_barrier_outcome,
|
_barrier_outcome,
|
||||||
@@ -41,6 +42,8 @@ EVENT_OUTPUT_NAME = "logit_tp_first"
|
|||||||
OUTPUT_LAYOUT = (*RETURN_OUTPUT_LAYOUT, EVENT_OUTPUT_NAME)
|
OUTPUT_LAYOUT = (*RETURN_OUTPUT_LAYOUT, EVENT_OUTPUT_NAME)
|
||||||
TARGET_TRANSFORM = "barrier_net_return"
|
TARGET_TRANSFORM = "barrier_net_return"
|
||||||
QUANTILES = {"q10": 0.10, "q50": 0.50, "q90": 0.90}
|
QUANTILES = {"q10": 0.10, "q50": 0.50, "q90": 0.90}
|
||||||
|
_ORDERBOOK_FEATURES_BY_SYMBOL: dict[str, dict[int, dict[str, float]]] = {}
|
||||||
|
_ORDERBOOK_MANIFEST: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -161,6 +164,7 @@ class RecurrentReturnModel(nn.Module):
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
global _ORDERBOOK_FEATURES_BY_SYMBOL, _ORDERBOOK_MANIFEST
|
||||||
args = _parse_args()
|
args = _parse_args()
|
||||||
if args.threads > 0:
|
if args.threads > 0:
|
||||||
torch.set_num_threads(args.threads)
|
torch.set_num_threads(args.threads)
|
||||||
@@ -175,6 +179,33 @@ def main() -> None:
|
|||||||
decision_horizon = args.horizon if args.horizon > 0 else max(1, settings.time_series_forecast_horizon)
|
decision_horizon = args.horizon if args.horizon > 0 else max(1, settings.time_series_forecast_horizon)
|
||||||
target_horizons = _horizons(args.horizons, decision_horizon)
|
target_horizons = _horizons(args.horizons, decision_horizon)
|
||||||
feature_names = _feature_names_arg(args.features)
|
feature_names = _feature_names_arg(args.features)
|
||||||
|
if args.orderbook_db:
|
||||||
|
_ORDERBOOK_FEATURES_BY_SYMBOL, _ORDERBOOK_MANIFEST = load_orderbook_feature_map(
|
||||||
|
args.orderbook_db,
|
||||||
|
interval=interval,
|
||||||
|
symbols=symbols,
|
||||||
|
min_samples_per_bucket=args.orderbook_min_samples_per_bucket,
|
||||||
|
)
|
||||||
|
eligible_symbols = [
|
||||||
|
symbol
|
||||||
|
for symbol in symbols
|
||||||
|
if int(_ORDERBOOK_MANIFEST.get(symbol, {}).get("covered_buckets", 0) or 0)
|
||||||
|
>= args.orderbook_min_covered_buckets
|
||||||
|
]
|
||||||
|
if len(eligible_symbols) < max(1, args.orderbook_min_symbols):
|
||||||
|
coverage = ", ".join(
|
||||||
|
f"{symbol}={int(_ORDERBOOK_MANIFEST.get(symbol, {}).get('covered_buckets', 0) or 0)}"
|
||||||
|
for symbol in symbols
|
||||||
|
)
|
||||||
|
raise SystemExit(
|
||||||
|
"Orderbook coverage is below the training minimum: "
|
||||||
|
f"need {args.orderbook_min_covered_buckets} buckets for "
|
||||||
|
f"{args.orderbook_min_symbols} symbols; got {coverage or 'no data'}"
|
||||||
|
)
|
||||||
|
symbols = eligible_symbols
|
||||||
|
for feature_name in ORDERBOOK_FEATURES:
|
||||||
|
if feature_name not in feature_names:
|
||||||
|
feature_names.append(feature_name)
|
||||||
if args.pooled:
|
if args.pooled:
|
||||||
feature_names.extend(f"symbol_is_{symbol}" for symbol in symbols)
|
feature_names.extend(f"symbol_is_{symbol}" for symbol in symbols)
|
||||||
ensemble_seeds = _ints(args.ensemble_seeds) or [args.seed]
|
ensemble_seeds = _ints(args.ensemble_seeds) or [args.seed]
|
||||||
@@ -214,6 +245,15 @@ def main() -> None:
|
|||||||
"selection_folds": args.selection_folds,
|
"selection_folds": args.selection_folds,
|
||||||
"symbols": {},
|
"symbols": {},
|
||||||
}
|
}
|
||||||
|
if args.orderbook_db:
|
||||||
|
artifact["orderbook_features"] = {
|
||||||
|
"source": "forward_collected_bybit_l1",
|
||||||
|
"interval": interval,
|
||||||
|
"min_samples_per_bucket": args.orderbook_min_samples_per_bucket,
|
||||||
|
"min_covered_buckets": args.orderbook_min_covered_buckets,
|
||||||
|
"features": list(ORDERBOOK_FEATURES),
|
||||||
|
"coverage": {symbol: _ORDERBOOK_MANIFEST.get(symbol, {}) for symbol in symbols},
|
||||||
|
}
|
||||||
|
|
||||||
if args.pooled:
|
if args.pooled:
|
||||||
artifact["version"] = 7
|
artifact["version"] = 7
|
||||||
@@ -361,6 +401,7 @@ def _train_pooled_symbols(
|
|||||||
prepared_by_symbol: dict[str, PreparedData] = {}
|
prepared_by_symbol: dict[str, PreparedData] = {}
|
||||||
for symbol in symbols:
|
for symbol in symbols:
|
||||||
prepared = _prepare_data(
|
prepared = _prepare_data(
|
||||||
|
symbol=symbol,
|
||||||
candles=market_candles[symbol],
|
candles=market_candles[symbol],
|
||||||
feature_names=feature_names,
|
feature_names=feature_names,
|
||||||
lookback=lookback,
|
lookback=lookback,
|
||||||
@@ -583,6 +624,10 @@ def _parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.")
|
parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.")
|
||||||
parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or mps.")
|
parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or mps.")
|
||||||
parser.add_argument("--output", default="", help="Output JSON path. Defaults to TIME_SERIES_LSTM_MODEL_PATH.")
|
parser.add_argument("--output", default="", help="Output JSON path. Defaults to TIME_SERIES_LSTM_MODEL_PATH.")
|
||||||
|
parser.add_argument("--orderbook-db", default="", help="SQLite cache containing forward-collected L1 observations.")
|
||||||
|
parser.add_argument("--orderbook-min-samples-per-bucket", type=int, default=20)
|
||||||
|
parser.add_argument("--orderbook-min-covered-buckets", type=int, default=240)
|
||||||
|
parser.add_argument("--orderbook-min-symbols", type=int, default=2)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -654,6 +699,7 @@ def _train_symbol(
|
|||||||
for lookback in lookbacks:
|
for lookback in lookbacks:
|
||||||
_progress(f"{symbol}: preparing lookback={lookback}")
|
_progress(f"{symbol}: preparing lookback={lookback}")
|
||||||
prepared = _prepare_data(
|
prepared = _prepare_data(
|
||||||
|
symbol=symbol,
|
||||||
candles=candles,
|
candles=candles,
|
||||||
feature_names=feature_names,
|
feature_names=feature_names,
|
||||||
lookback=lookback,
|
lookback=lookback,
|
||||||
@@ -777,6 +823,7 @@ def _train_symbol(
|
|||||||
|
|
||||||
def _prepare_data(
|
def _prepare_data(
|
||||||
*,
|
*,
|
||||||
|
symbol: str,
|
||||||
candles: list[Candle],
|
candles: list[Candle],
|
||||||
feature_names: list[str],
|
feature_names: list[str],
|
||||||
lookback: int,
|
lookback: int,
|
||||||
@@ -796,8 +843,10 @@ def _prepare_data(
|
|||||||
feature_rows = _feature_matrix(
|
feature_rows = _feature_matrix(
|
||||||
candles,
|
candles,
|
||||||
feature_names,
|
feature_names,
|
||||||
|
symbol=symbol,
|
||||||
market_candles=market_candles,
|
market_candles=market_candles,
|
||||||
trend_candles=trend_candles,
|
trend_candles=trend_candles,
|
||||||
|
orderbook_features=_ORDERBOOK_FEATURES_BY_SYMBOL,
|
||||||
)
|
)
|
||||||
max_horizon = max(target_horizons)
|
max_horizon = max(target_horizons)
|
||||||
samples: list[TrainingSample] = []
|
samples: list[TrainingSample] = []
|
||||||
@@ -808,6 +857,11 @@ def _prepare_data(
|
|||||||
window = feature_rows[end_index - lookback + 1 : end_index + 1]
|
window = feature_rows[end_index - lookback + 1 : end_index + 1]
|
||||||
if len(window) != lookback:
|
if len(window) != lookback:
|
||||||
continue
|
continue
|
||||||
|
if any(name in ORDERBOOK_FEATURES for name in feature_names):
|
||||||
|
symbol_orderbook = _ORDERBOOK_FEATURES_BY_SYMBOL.get(symbol.upper(), {})
|
||||||
|
window_candles = candles[end_index - lookback + 1 : end_index + 1]
|
||||||
|
if any(row.timestamp not in symbol_orderbook for row in window_candles):
|
||||||
|
continue
|
||||||
raw_targets: list[float] = []
|
raw_targets: list[float] = []
|
||||||
event_targets: list[float] = []
|
event_targets: list[float] = []
|
||||||
volatility_scales: list[float] = []
|
volatility_scales: list[float] = []
|
||||||
@@ -852,6 +906,8 @@ def _prepare_data(
|
|||||||
validation_window = min(max(16, validation_window), max(16, validation_end // 3))
|
validation_window = min(max(16, validation_window), max(16, validation_end // 3))
|
||||||
validation_start = validation_end - validation_window
|
validation_start = validation_end - validation_window
|
||||||
train_end = validation_start - max_horizon
|
train_end = validation_start - max_horizon
|
||||||
|
if validation_start < 0 or train_end <= 0:
|
||||||
|
return None
|
||||||
train_samples = samples[:train_end]
|
train_samples = samples[:train_end]
|
||||||
validation_samples = samples[validation_start:validation_end]
|
validation_samples = samples[validation_start:validation_end]
|
||||||
holdout_samples = samples[holdout_start:]
|
holdout_samples = samples[holdout_start:]
|
||||||
|
|||||||
+248
-17
@@ -20,12 +20,25 @@ from urllib.error import URLError
|
|||||||
from urllib.request import Request
|
from urllib.request import Request
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from crypto_spot_bot.orderbook_features import load_orderbook_feature_map
|
||||||
|
from tools.sync_orderbook_observations import sync_orderbook_observations
|
||||||
|
|
||||||
|
|
||||||
ARTIFACT_NAMES = (
|
ARTIFACT_NAMES = (
|
||||||
"lstm_forecaster.json",
|
"lstm_forecaster.json",
|
||||||
"torch_retrain_guard.json",
|
"torch_retrain_guard.json",
|
||||||
"torch_threshold_calibration.json",
|
"torch_threshold_calibration.json",
|
||||||
)
|
)
|
||||||
|
SHADOW_ARTIFACT_NAMES = (
|
||||||
|
"lstm_forecaster.shadow.json",
|
||||||
|
"torch_shadow_guard.json",
|
||||||
|
"torch_shadow_calibration.json",
|
||||||
|
)
|
||||||
|
_LAST_ORDERBOOK_AUTO_CHECK = 0.0
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -51,28 +64,76 @@ def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_
|
|||||||
api_json(args, "/api/training/heartbeat", worker)
|
api_json(args, "/api/training/heartbeat", worker)
|
||||||
claim = api_json(args, "/api/training/claim", worker)
|
claim = api_json(args, "/api/training/claim", worker)
|
||||||
if not claim.get("claimed"):
|
if not claim.get("claimed"):
|
||||||
|
maybe_auto_queue_orderbook(args, repo_root, runtime_dir, log_path)
|
||||||
return
|
return
|
||||||
job = claim.get("job") if isinstance(claim.get("job"), dict) else {}
|
job = claim.get("job") if isinstance(claim.get("job"), dict) else {}
|
||||||
job_id = str(job.get("id") or "")
|
job_id = str(job.get("id") or "")
|
||||||
|
lease_token = str(claim.get("lease_token") or "")
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return
|
return
|
||||||
|
if not lease_token:
|
||||||
|
raise RuntimeError("training server did not issue a job lease")
|
||||||
log(log_path, f"Claimed retrain job {job_id}")
|
log(log_path, f"Claimed retrain job {job_id}")
|
||||||
report_progress(args, job_id, "running", "claimed", 2, "Задание получено Windows-agent")
|
report_progress(args, job_id, lease_token, "running", "claimed", 2, "Задание получено Windows-agent")
|
||||||
success = False
|
success = False
|
||||||
message = ""
|
message = ""
|
||||||
summary: dict[str, Any] = {}
|
summary: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
run_retrain(args, job_id, job, repo_root, log_path)
|
parameters = job.get("parameters") if isinstance(job.get("parameters"), dict) else {}
|
||||||
|
use_orderbook = parameters.get("use_orderbook", True) is not False
|
||||||
|
orderbook_status: dict[str, Any] = {}
|
||||||
|
if use_orderbook:
|
||||||
|
report_progress(
|
||||||
|
args,
|
||||||
|
job_id,
|
||||||
|
lease_token,
|
||||||
|
"running",
|
||||||
|
"orderbook_sync",
|
||||||
|
4,
|
||||||
|
"Синхронизирую forward-наблюдения стакана",
|
||||||
|
)
|
||||||
|
orderbook_status = prepare_orderbook_data(args, repo_root, parameters, log_path)
|
||||||
|
if orderbook_status["state"] != "ready":
|
||||||
|
summary = orderbook_status
|
||||||
|
message = "forward orderbook coverage is still accumulating"
|
||||||
|
success = True
|
||||||
|
log(log_path, f"Job {job_id} remains in collecting state: {orderbook_status}")
|
||||||
|
return
|
||||||
|
run_retrain(
|
||||||
|
args,
|
||||||
|
job_id,
|
||||||
|
lease_token,
|
||||||
|
job,
|
||||||
|
repo_root,
|
||||||
|
log_path,
|
||||||
|
orderbook_db=(repo_root / "runtime" / "orderbook_observations.sqlite3") if use_orderbook else None,
|
||||||
|
)
|
||||||
summary = read_json(runtime_dir / "torch_retrain_guard.json")
|
summary = read_json(runtime_dir / "torch_retrain_guard.json")
|
||||||
accepted = summary.get("accepted") is True
|
accepted = summary.get("accepted") is True
|
||||||
if accepted:
|
if accepted:
|
||||||
report_progress(args, job_id, "running", "uploading", 72, "Обучение завершено, загружаю артефакты")
|
report_progress(
|
||||||
for name in ARTIFACT_NAMES:
|
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
|
path = runtime_dir / name
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
upload_artifact(args, job_id, path, log_path)
|
upload_artifact(args, job_id, lease_token, path, log_path)
|
||||||
message = "training completed; candidate accepted"
|
message = (
|
||||||
log(log_path, f"Completed retrain job {job_id}; candidate accepted")
|
"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:
|
else:
|
||||||
reason = str(summary.get("reason") or "validation failed")
|
reason = str(summary.get("reason") or "validation failed")
|
||||||
message = f"training completed; candidate rejected by quality gate: {reason}"
|
message = f"training completed; candidate rejected by quality gate: {reason}"
|
||||||
@@ -82,11 +143,24 @@ def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_
|
|||||||
message = str(exc)
|
message = str(exc)
|
||||||
log(log_path, f"Job {job_id} failed: {message}")
|
log(log_path, f"Job {job_id} failed: {message}")
|
||||||
finally:
|
finally:
|
||||||
payload = {"success": success, "message": message, "summary": summary}
|
payload = {
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
"summary": summary,
|
||||||
|
"lease_token": lease_token,
|
||||||
|
}
|
||||||
api_json(args, f"/api/training/jobs/{job_id}/complete", payload)
|
api_json(args, f"/api/training/jobs/{job_id}/complete", payload)
|
||||||
|
|
||||||
|
|
||||||
def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo_root: Path, log_path: Path) -> None:
|
def run_retrain(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
job_id: str,
|
||||||
|
lease_token: str,
|
||||||
|
job: dict[str, Any],
|
||||||
|
repo_root: Path,
|
||||||
|
log_path: Path,
|
||||||
|
orderbook_db: Path | None = None,
|
||||||
|
) -> None:
|
||||||
script = repo_root / "tools" / "run_torch_retrain.ps1"
|
script = repo_root / "tools" / "run_torch_retrain.ps1"
|
||||||
if not script.is_file():
|
if not script.is_file():
|
||||||
raise RuntimeError(f"retrain script not found: {script}")
|
raise RuntimeError(f"retrain script not found: {script}")
|
||||||
@@ -126,12 +200,28 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
|||||||
value = parameters.get(key)
|
value = parameters.get(key)
|
||||||
if value not in (None, ""):
|
if value not in (None, ""):
|
||||||
cmd.extend([ps_arg, str(value)])
|
cmd.extend([ps_arg, str(value)])
|
||||||
if parameters.get("pooled") is True:
|
if parameters.get("pooled", True) is True:
|
||||||
cmd.append("-Pooled")
|
cmd.append("-Pooled")
|
||||||
if parameters.get("resume_candidate") is True:
|
if parameters.get("resume_candidate") is True:
|
||||||
cmd.append("-ResumeCandidate")
|
cmd.append("-ResumeCandidate")
|
||||||
|
if orderbook_db is not None:
|
||||||
|
cmd.extend(["-OrderbookDb", str(orderbook_db)])
|
||||||
|
for key, ps_arg, default in (
|
||||||
|
("orderbook_min_samples_per_bucket", "-OrderbookMinSamplesPerBucket", 20),
|
||||||
|
("orderbook_min_covered_buckets", "-OrderbookMinCoveredBuckets", 240),
|
||||||
|
("orderbook_min_symbols", "-OrderbookMinSymbols", 2),
|
||||||
|
):
|
||||||
|
cmd.extend([ps_arg, str(int(parameters.get(key, default) or default))])
|
||||||
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
|
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
|
||||||
report_progress(args, job_id, "running", "training", 8, "PyTorch retrain запущен")
|
report_progress(
|
||||||
|
args,
|
||||||
|
job_id,
|
||||||
|
lease_token,
|
||||||
|
"running",
|
||||||
|
"training",
|
||||||
|
8,
|
||||||
|
"PyTorch retrain запущен",
|
||||||
|
)
|
||||||
line_count = 0
|
line_count = 0
|
||||||
output_queue: queue.Queue[str] = queue.Queue()
|
output_queue: queue.Queue[str] = queue.Queue()
|
||||||
|
|
||||||
@@ -175,7 +265,16 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
|||||||
report_message = last_message
|
report_message = last_message
|
||||||
if not got_line:
|
if not got_line:
|
||||||
report_message = training_heartbeat_message(now, started_at, last_output_at, last_message)
|
report_message = training_heartbeat_message(now, started_at, last_output_at, last_message)
|
||||||
safe_report_progress(args, job_id, "running", "training", progress, report_message, log_path)
|
safe_report_progress(
|
||||||
|
args,
|
||||||
|
job_id,
|
||||||
|
lease_token,
|
||||||
|
"running",
|
||||||
|
"training",
|
||||||
|
progress,
|
||||||
|
report_message,
|
||||||
|
log_path,
|
||||||
|
)
|
||||||
last_report_at = now
|
last_report_at = now
|
||||||
|
|
||||||
if process.poll() is not None and output_queue.empty():
|
if process.poll() is not None and output_queue.empty():
|
||||||
@@ -185,7 +284,113 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
|||||||
code = process.wait()
|
code = process.wait()
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise RuntimeError(f"retrain failed with exit code {code}")
|
raise RuntimeError(f"retrain failed with exit code {code}")
|
||||||
report_progress(args, job_id, "running", "guard", 70, "Guard завершён, подготавливаю артефакты")
|
report_progress(
|
||||||
|
args,
|
||||||
|
job_id,
|
||||||
|
lease_token,
|
||||||
|
"running",
|
||||||
|
"guard",
|
||||||
|
70,
|
||||||
|
"Guard завершён, подготавливаю артефакты",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_orderbook_data(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
repo_root: Path,
|
||||||
|
parameters: dict[str, Any],
|
||||||
|
log_path: Path,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
database_path = repo_root / "runtime" / "orderbook_observations.sqlite3"
|
||||||
|
token = args.api_auth or os.environ.get("TRADEBOT_API_AUTH", "")
|
||||||
|
sync_result = sync_orderbook_observations(
|
||||||
|
api_base_url=args.api_base_url,
|
||||||
|
token=token,
|
||||||
|
database_path=database_path,
|
||||||
|
)
|
||||||
|
interval = str(parameters.get("interval") or os.environ.get("TORCH_RETRAIN_INTERVAL") or "60")
|
||||||
|
minimum_samples = int(parameters.get("orderbook_min_samples_per_bucket", 20) or 20)
|
||||||
|
minimum_buckets = int(parameters.get("orderbook_min_covered_buckets", 240) or 240)
|
||||||
|
minimum_symbols = int(parameters.get("orderbook_min_symbols", 2) or 2)
|
||||||
|
requested_symbols = {
|
||||||
|
item.strip().upper()
|
||||||
|
for item in str(parameters.get("symbols") or "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
}
|
||||||
|
_features, manifest = load_orderbook_feature_map(
|
||||||
|
database_path,
|
||||||
|
interval=interval,
|
||||||
|
symbols=sorted(requested_symbols) if requested_symbols else None,
|
||||||
|
min_samples_per_bucket=minimum_samples,
|
||||||
|
)
|
||||||
|
eligible = sorted(
|
||||||
|
symbol
|
||||||
|
for symbol, row in manifest.items()
|
||||||
|
if int(row.get("covered_buckets", 0) or 0) >= minimum_buckets
|
||||||
|
)
|
||||||
|
state = "ready" if len(eligible) >= minimum_symbols else "collecting_orderbook"
|
||||||
|
coverage = {
|
||||||
|
symbol: int(row.get("covered_buckets", 0) or 0)
|
||||||
|
for symbol, row in sorted(manifest.items())
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
"accepted": False,
|
||||||
|
"state": state,
|
||||||
|
"reason": (
|
||||||
|
"orderbook coverage ready for training"
|
||||||
|
if state == "ready"
|
||||||
|
else "forward orderbook coverage is below the configured minimum"
|
||||||
|
),
|
||||||
|
"eligible_symbols": eligible,
|
||||||
|
"eligible_symbol_count": len(eligible),
|
||||||
|
"minimum_symbols": minimum_symbols,
|
||||||
|
"minimum_covered_buckets": minimum_buckets,
|
||||||
|
"minimum_samples_per_bucket": minimum_samples,
|
||||||
|
"covered_buckets_by_symbol": coverage,
|
||||||
|
"local_samples": int(sync_result.get("local_samples", 0) or 0),
|
||||||
|
"downloaded_samples": int(sync_result.get("downloaded", 0) or 0),
|
||||||
|
}
|
||||||
|
log(log_path, "Orderbook preparation: " + json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_auto_queue_orderbook(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
repo_root: Path,
|
||||||
|
runtime_dir: Path,
|
||||||
|
log_path: Path,
|
||||||
|
) -> None:
|
||||||
|
global _LAST_ORDERBOOK_AUTO_CHECK
|
||||||
|
try:
|
||||||
|
interval_seconds = max(
|
||||||
|
300,
|
||||||
|
int(os.environ.get("TORCH_ORDERBOOK_AUTO_CHECK_SECONDS", "3600") or 3600),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
interval_seconds = 3600
|
||||||
|
now = time.monotonic()
|
||||||
|
if _LAST_ORDERBOOK_AUTO_CHECK and now - _LAST_ORDERBOOK_AUTO_CHECK < interval_seconds:
|
||||||
|
return
|
||||||
|
_LAST_ORDERBOOK_AUTO_CHECK = now
|
||||||
|
marker_path = runtime_dir / "orderbook_auto_queue.json"
|
||||||
|
if marker_path.is_file() or (runtime_dir / "lstm_forecaster.shadow.json").is_file():
|
||||||
|
return
|
||||||
|
status = prepare_orderbook_data(args, repo_root, {}, log_path)
|
||||||
|
if status.get("state") != "ready":
|
||||||
|
return
|
||||||
|
response = api_json(args, "/api/training/retrain/auto", {})
|
||||||
|
if not response.get("queued"):
|
||||||
|
log(log_path, f"Automatic orderbook retrain was not queued: {response.get('reason', 'unknown')}")
|
||||||
|
return
|
||||||
|
marker = {
|
||||||
|
"queued_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"job_id": (response.get("job") or {}).get("id"),
|
||||||
|
"coverage": status,
|
||||||
|
}
|
||||||
|
marker_tmp = marker_path.with_suffix(".tmp")
|
||||||
|
marker_tmp.write_text(json.dumps(marker, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
marker_tmp.replace(marker_path)
|
||||||
|
log(log_path, f"Automatically queued orderbook retrain job {marker['job_id']}")
|
||||||
|
|
||||||
|
|
||||||
def friendly_training_message(message: str) -> str:
|
def friendly_training_message(message: str) -> str:
|
||||||
@@ -282,7 +487,13 @@ def format_duration(seconds: float) -> str:
|
|||||||
return f"{seconds_part}с"
|
return f"{seconds_part}с"
|
||||||
|
|
||||||
|
|
||||||
def upload_artifact(args: argparse.Namespace, job_id: str, path: Path, log_path: Path) -> None:
|
def upload_artifact(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
job_id: str,
|
||||||
|
lease_token: str,
|
||||||
|
path: Path,
|
||||||
|
log_path: Path,
|
||||||
|
) -> None:
|
||||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
size = path.stat().st_size
|
size = path.stat().st_size
|
||||||
chunk_size = max(64 * 1024, args.chunk_size)
|
chunk_size = max(64 * 1024, args.chunk_size)
|
||||||
@@ -297,16 +508,26 @@ def upload_artifact(args: argparse.Namespace, job_id: str, path: Path, log_path:
|
|||||||
"total": total,
|
"total": total,
|
||||||
"sha256": digest,
|
"sha256": digest,
|
||||||
"data_base64": base64.b64encode(data).decode("ascii"),
|
"data_base64": base64.b64encode(data).decode("ascii"),
|
||||||
|
"lease_token": lease_token,
|
||||||
}
|
}
|
||||||
api_json(args, f"/api/training/jobs/{job_id}/artifacts/chunk", payload, timeout=120)
|
api_json(args, f"/api/training/jobs/{job_id}/artifacts/chunk", payload, timeout=120)
|
||||||
if index == 0 or index == total - 1 or index % 10 == 0:
|
if index == 0 or index == total - 1 or index % 10 == 0:
|
||||||
progress = 72 + int(((index + 1) / total) * 23)
|
progress = 72 + int(((index + 1) / total) * 23)
|
||||||
report_progress(args, job_id, "running", "uploading", progress, f"Загружаю {path.name}: {index + 1}/{total}")
|
report_progress(
|
||||||
|
args,
|
||||||
|
job_id,
|
||||||
|
lease_token,
|
||||||
|
"running",
|
||||||
|
"uploading",
|
||||||
|
progress,
|
||||||
|
f"Загружаю {path.name}: {index + 1}/{total}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def report_progress(
|
def report_progress(
|
||||||
args: argparse.Namespace,
|
args: argparse.Namespace,
|
||||||
job_id: str,
|
job_id: str,
|
||||||
|
lease_token: str,
|
||||||
status: str,
|
status: str,
|
||||||
phase: str,
|
phase: str,
|
||||||
progress_percent: int,
|
progress_percent: int,
|
||||||
@@ -321,6 +542,7 @@ def report_progress(
|
|||||||
"progress_percent": progress_percent,
|
"progress_percent": progress_percent,
|
||||||
"message": message,
|
"message": message,
|
||||||
"worker": worker_payload(args, Path(args.repo_root).resolve()),
|
"worker": worker_payload(args, Path(args.repo_root).resolve()),
|
||||||
|
"lease_token": lease_token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -328,6 +550,7 @@ def report_progress(
|
|||||||
def safe_report_progress(
|
def safe_report_progress(
|
||||||
args: argparse.Namespace,
|
args: argparse.Namespace,
|
||||||
job_id: str,
|
job_id: str,
|
||||||
|
lease_token: str,
|
||||||
status: str,
|
status: str,
|
||||||
phase: str,
|
phase: str,
|
||||||
progress_percent: int,
|
progress_percent: int,
|
||||||
@@ -337,7 +560,15 @@ def safe_report_progress(
|
|||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
for attempt in range(1, 4):
|
for attempt in range(1, 4):
|
||||||
try:
|
try:
|
||||||
report_progress(args, job_id, status, phase, progress_percent, message)
|
report_progress(
|
||||||
|
args,
|
||||||
|
job_id,
|
||||||
|
lease_token,
|
||||||
|
status,
|
||||||
|
phase,
|
||||||
|
progress_percent,
|
||||||
|
message,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001 - keep the local training process alive.
|
except Exception as exc: # noqa: BLE001 - keep the local training process alive.
|
||||||
last_error = exc
|
last_error = exc
|
||||||
@@ -385,7 +616,7 @@ def worker_payload(args: argparse.Namespace, repo_root: Path) -> dict[str, Any]:
|
|||||||
"worker_id": args.worker_id or f"{name}:{repo_root}",
|
"worker_id": args.worker_id or f"{name}:{repo_root}",
|
||||||
"name": name,
|
"name": name,
|
||||||
"path": str(repo_root),
|
"path": str(repo_root),
|
||||||
"version": "1",
|
"version": "3",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user