Compare commits

...

30 Commits

Author SHA1 Message Date
Codex 7f1ac694e4 Improve Windows training agent progress 2026-07-03 20:44:09 +03:00
Codex 33c3831bf2 Require minimum net profit for forecast exits 2026-07-02 15:35:29 +03:00
Codex f0c70e2e1f Run Windows training agent without console 2026-07-01 13:43:41 +03:00
Codex 4a4039c03d Improve Android retrain start feedback 2026-06-30 21:37:08 +03:00
Codex 2e2978d7d9 Fix Android retrain status label 2026-06-30 20:07:00 +03:00
Codex 6c92c6e955 Avoid fee-negative ATR exits 2026-06-30 14:40:26 +03:00
Codex 56701d02f1 Prevent torch forecast fee churn exits 2026-06-30 14:37:00 +03:00
Codex 0cfd89980e Clarify transient training progress upload errors 2026-06-29 21:21:09 +03:00
Codex 9f47c04235 Show realized trade PnL in Android app 2026-06-29 21:06:51 +03:00
Codex fe276d95ff Keep Windows training agent alive 2026-06-29 20:05:58 +03:00
Codex 710312ba8a Set paper exposure limit to 100 USDT 2026-06-29 16:49:34 +03:00
Codex 4bc8a4c4a0 Allow disabling stop loss exits 2026-06-29 16:39:36 +03:00
Codex d58e20aa4d Improve training agent progress reporting 2026-06-27 17:52:49 +03:00
Codex 57d9514eec Make symbol risk guard configurable 2026-06-27 17:28:52 +03:00
Codex 7d84bf38cc Allow exchange-minimum Kelly layers 2026-06-27 16:00:31 +03:00
Codex 07d132632d Fix exchange minimum sizing for buys 2026-06-27 15:41:08 +03:00
Codex baf307041a Clarify retrain gate status in Android app 2026-06-27 12:42:38 +03:00
Codex 83bffadc0a Fix training agent heartbeat status 2026-06-27 11:02:41 +03:00
Codex b6d616ec2a Show retrain progress in Android 2026-06-27 09:18:18 +03:00
Codex f8611a1c3f Add global Windows training agent queue 2026-06-27 08:56:54 +03:00
Codex 98d6277d65 Fix Android refresh flicker and pin training host 2026-06-27 08:38:45 +03:00
Codex f1f1cbb1e0 Handle Android API authorization setup 2026-06-27 08:12:54 +03:00
Codex ab2557ea26 Ship Android AI console and remove web UI 2026-06-26 22:56:42 +03:00
Codex 4e811daaf2 Fix Android monitor scroll stability 2026-06-26 22:21:24 +03:00
Codex 9c983ac0bb Point Android monitor to public bot API 2026-06-26 21:52:17 +03:00
Codex 2706242fc7 Add Android TradeBot monitor app 2026-06-26 21:09:44 +03:00
Codex 93c51070c5 Redesign Russian trading dashboard 2026-06-26 20:42:01 +03:00
Codex 61a063cda7 Use Kelly allocation for Torch position scaling 2026-06-26 20:20:23 +03:00
Курнат Андрей 87cb7e8fe3 Train Torch model for 12 spot pairs 2026-06-25 22:39:25 +03:00
Codex 27205af73e Accept Torch candidate passing honest gate 2026-06-25 08:13:40 +03:00
54 changed files with 2331104 additions and 672251 deletions
+17 -14
View File
@@ -8,8 +8,8 @@ BYBIT_API_SECRET=
STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=4
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT
TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60
@@ -25,36 +25,37 @@ WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64
MAX_SPREAD_PERCENT=0.18
MIN_24H_TURNOVER_USDT=1000000
PATTERN_ANALYSIS_ENABLED=false
PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=false
LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12
LEARNING_MAX_POSITION_MULTIPLIER=1.6
MIN_POSITION_USDT=1
MAX_POSITION_USDT=25
MAX_POSITION_USDT=8
MAX_SYMBOL_EXPOSURE_USDT=25
MAX_TOTAL_EXPOSURE_USDT=75
MAX_OPEN_POSITIONS=4
MAX_POSITIONS_PER_SYMBOL=1
MAX_TOTAL_EXPOSURE_USDT=100
MAX_OPEN_POSITIONS=24
MAX_POSITIONS_PER_SYMBOL=6
GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=false
REBOUND_ENTRY_CONFIDENCE=0.58
REBOUND_MIN_PROBABILITY=0.58
REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=false
KELLY_SIZING_ENABLED=true
KELLY_FRACTION=0.25
KELLY_MAX_FRACTION=0.20
RISK_PER_TRADE_PERCENT=0.01
RISK_GUARD_ENABLED=true
RISK_SYMBOL_GUARD_ENABLED=false
RISK_RECENT_TRADE_WINDOW=20
RISK_MAX_CONSECUTIVE_LOSSES=4
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
RISK_REDUCE_MULTIPLIER=0.50
RISK_REDUCE_MULTIPLIER=1.0
ATR_TRAILING_MULTIPLIER=2.2
TREND_RSI_MIN=45
TREND_RSI_MAX=65
@@ -62,7 +63,7 @@ TIME_SERIES_FORECAST_ENABLED=true
TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.10
TIME_SERIES_MIN_PROBABILITY_UP=0.56
TIME_SERIES_MIN_PROBABILITY_UP=0.47
TIME_SERIES_MIN_CONFIDENCE=0.4
TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true
@@ -71,7 +72,9 @@ TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04
STOP_LOSS_EXIT_ENABLED=false
TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015
MIN_HOLD_SECONDS=180
+14 -13
View File
@@ -8,8 +8,8 @@ BYBIT_API_SECRET=
STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=4
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT
TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60
@@ -25,28 +25,28 @@ WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64
MAX_SPREAD_PERCENT=0.18
MIN_24H_TURNOVER_USDT=1000000
PATTERN_ANALYSIS_ENABLED=false
PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=false
LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12
LEARNING_MAX_POSITION_MULTIPLIER=1.6
MIN_POSITION_USDT=1
MAX_POSITION_USDT=25
MAX_POSITION_USDT=8
MAX_SYMBOL_EXPOSURE_USDT=25
MAX_TOTAL_EXPOSURE_USDT=75
MAX_OPEN_POSITIONS=4
MAX_POSITIONS_PER_SYMBOL=1
MAX_OPEN_POSITIONS=24
MAX_POSITIONS_PER_SYMBOL=6
GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=false
REBOUND_ENTRY_CONFIDENCE=0.58
REBOUND_MIN_PROBABILITY=0.58
REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=false
KELLY_SIZING_ENABLED=true
KELLY_FRACTION=0.25
KELLY_MAX_FRACTION=0.20
RISK_PER_TRADE_PERCENT=0.01
@@ -61,8 +61,8 @@ TREND_RSI_MAX=65
TIME_SERIES_FORECAST_ENABLED=true
TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.08
TIME_SERIES_MIN_PROBABILITY_UP=0.58
TIME_SERIES_MIN_EDGE_PERCENT=0.10
TIME_SERIES_MIN_PROBABILITY_UP=0.47
TIME_SERIES_MIN_CONFIDENCE=0.4
TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true
@@ -71,6 +71,7 @@ TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015
+4
View File
@@ -7,3 +7,7 @@ venv/
.env
runtime/
*.log
android/**/.gradle/
android/**/build/
android/**/*.apk
android/**/*.aab
+26 -15
View File
@@ -5,7 +5,7 @@ Spot-бот для демо-торговли криптовалютой на р
## Что реализовано
- Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления.
- Фиксированный набор USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `SOLUSDT`, `LTCUSDT`.
- Фиксированный набор 12 USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `HYPEUSDT`, `SOLUSDT`, `XRPUSDT`, `XPLUSDT`, `WLDUSDT`, `MNTUSDT`, `HUSDT`, `XAUTUSDT`, `IPUSDT`, `AAVEUSDT`.
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
- Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`.
@@ -18,6 +18,7 @@ Spot-бот для демо-торговли криптовалютой на р
- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`.
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для просмотра 12 пар, свечей, Torch/Kelly параметров, расписания удалённого retrain и live-чеклиста.
- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`.
- Health endpoint `/api/health` и Prometheus-compatible `/metrics`.
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост.
@@ -88,7 +89,15 @@ powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1
powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1
```
По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на парах `BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
Для удалённого запуска с телефона или с бота используется Windows training agent. Бот на `tb.kusoft.xyz` хранит очередь заданий, а Windows-машина сама подключается к интернету, забирает задания, обучает модель и загружает артефакты обратно:
```powershell
powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps1 -ApiAuth "login:password" -StartNow
```
Установщик регистрирует Scheduled Task `TradeBot Windows Training Agent` при входе в Windows и удаляет старые локальные retrain-задачи, чтобы обучение запускалось через очередь, а не двумя независимыми механизмами.
По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на 12 spot-парах из `SYMBOLS`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
Если retrain запускается с `-DeployToPi`, после успешного guard он синхронизирует `runtime/lstm_forecaster.json`, `runtime/torch_retrain_guard.json` и `runtime/torch_threshold_calibration.json` на Raspberry Pi через SSH-ключ и перезапускает сервис `tradebot`. Отдельный запуск sync:
@@ -116,8 +125,8 @@ Dashboard: `http://<host>:8787/`
TRADING_MODE=paper
STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=4
SYMBOLS=BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT
TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60
TREND_INTERVAL=D
@@ -129,32 +138,33 @@ FAST_ENTRY_COOLDOWN_SECONDS=20
MAX_ENTRIES_PER_MINUTE=12
WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64
PATTERN_ANALYSIS_ENABLED=false
PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=false
LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12
LEARNING_MAX_POSITION_MULTIPLIER=1.6
MIN_POSITION_USDT=1
MAX_POSITION_USDT=25
MAX_POSITION_USDT=8
MAX_SYMBOL_EXPOSURE_USDT=25
MAX_TOTAL_EXPOSURE_USDT=75
MAX_OPEN_POSITIONS=4
MAX_POSITIONS_PER_SYMBOL=1
MAX_OPEN_POSITIONS=24
MAX_POSITIONS_PER_SYMBOL=6
GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=false
REBOUND_ENTRY_CONFIDENCE=0.58
REBOUND_MIN_PROBABILITY=0.58
REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=false
KELLY_SIZING_ENABLED=true
KELLY_FRACTION=0.25
KELLY_MAX_FRACTION=0.20
RISK_PER_TRADE_PERCENT=0.01
RISK_GUARD_ENABLED=true
RISK_SYMBOL_GUARD_ENABLED=false
RISK_RECENT_TRADE_WINDOW=20
RISK_MAX_CONSECUTIVE_LOSSES=4
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
@@ -165,8 +175,8 @@ TREND_RSI_MAX=65
TIME_SERIES_FORECAST_ENABLED=true
TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.08
TIME_SERIES_MIN_PROBABILITY_UP=0.58
TIME_SERIES_MIN_EDGE_PERCENT=0.10
TIME_SERIES_MIN_PROBABILITY_UP=0.47
TIME_SERIES_MIN_CONFIDENCE=0.4
TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true
@@ -175,6 +185,7 @@ TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015
+63
View File
@@ -0,0 +1,63 @@
# TradeBot Monitor для Android
Нативное Android-приложение для наблюдения за ботом и удалённого управления переобучением.
## Что есть в приложении
- Русский интерфейс без bubble/pill-оформления.
- Современная биржевая компоновка: список пар, один выбранный график, компактные параметры ниже.
- Свечной график 1h: тела свечей, фитили, объём, EMA50, EMA200, последняя цена.
- Параметры Torch: edge, P(up), confidence, skill, quantiles, gate, причина решения.
- Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill.
- Обзор equity/cash/exposure/PnL и последних решений.
- Удалённый запуск retrain через очередь заданий на боте и закреплённый Windows-компьютер обучения.
- Расписание retrain на телефоне: Android отправляет команду по расписанию, но обучение идёт на Windows-машине.
- Настройки API, токена команд, тёмной/светлой темы.
- Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой.
## Сборка
```powershell
cd C:\Repos\TradeBot\android\TradeBotMonitor
gradle :app:assembleDebug
```
APK появится в:
```text
android/TradeBotMonitor/app/build/outputs/apk/debug/app-debug.apk
```
## Подключение к боту
В настройках приложения укажи адрес API, например:
```text
https://tb.kusoft.xyz
```
Этот адрес установлен в приложении по умолчанию. Если в настройках ввести просто `tb.kusoft.xyz`, приложение само добавит `https://`.
Если домен защищён авторизацией, в поле `API auth` можно указать:
- `login:password` — приложение отправит HTTP Basic;
- `Basic ...` — готовый Basic header;
- `Bearer ...` или просто токен — приложение отправит Bearer.
## Переобучение
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `DESKTOP-TMFDL0H` сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети.
## Live-торговля
Вкладка `Настройки` показывает live-чеклист:
- бот остановлен перед переключением;
- на сервере выставлен `TRADING_MODE=live`;
- используется mainnet, не testnet;
- задано `ENABLE_LIVE_TRADING=true`;
- задано `LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK`;
- Bybit API key/secret настроены на сервере;
- лимиты live-ордера, risk и Kelly проверены.
Приложение может отправить команды остановки/запуска цикла, но не включает реальные ордера без серверного `.env` и подтверждения риска.
@@ -0,0 +1,16 @@
plugins {
id("com.android.application")
}
android {
namespace = "xyz.kusoft.tradebotmonitor"
compileSdk = 36
defaultConfig {
applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26
targetSdk = 36
versionCode = 12
versionName = "0.2.9"
}
}
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="TradeBot AI"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".RetrainAlarmReceiver"
android:exported="false" />
<receiver
android:name=".BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,53 @@
package xyz.kusoft.tradebotmonitor
import android.graphics.Color
data class AppPalette(
val isDark: Boolean,
val page: Int,
val panel: Int,
val panelAlt: Int,
val line: Int,
val text: Int,
val muted: Int,
val green: Int,
val red: Int,
val amber: Int,
val blue: Int,
val chartBg: Int,
val chartGrid: Int,
) {
companion object {
fun dark() = AppPalette(
isDark = true,
page = Color.rgb(7, 8, 12),
panel = Color.rgb(17, 20, 26),
panelAlt = Color.rgb(21, 25, 34),
line = Color.rgb(36, 42, 54),
text = Color.rgb(244, 246, 251),
muted = Color.rgb(119, 127, 142),
green = Color.rgb(22, 199, 132),
red = Color.rgb(255, 80, 102),
amber = Color.rgb(245, 166, 35),
blue = Color.rgb(109, 131, 255),
chartBg = Color.rgb(8, 10, 15),
chartGrid = Color.rgb(27, 32, 41),
)
fun light() = AppPalette(
isDark = false,
page = Color.rgb(243, 245, 247),
panel = Color.WHITE,
panelAlt = Color.rgb(247, 249, 251),
line = Color.rgb(216, 224, 232),
text = Color.rgb(20, 27, 36),
muted = Color.rgb(92, 106, 122),
green = Color.rgb(18, 134, 88),
red = Color.rgb(194, 65, 63),
amber = Color.rgb(173, 116, 24),
blue = Color.rgb(37, 99, 235),
chartBg = Color.rgb(13, 18, 25),
chartGrid = Color.rgb(44, 56, 70),
)
}
}
@@ -0,0 +1,75 @@
package xyz.kusoft.tradebotmonitor
import android.content.Context
class AppPrefs(context: Context) {
private val prefs = context.getSharedPreferences("tradebot_monitor", Context.MODE_PRIVATE)
init {
val saved = prefs.getString("api_base_url", null)?.trim()
if (saved.isNullOrBlank() || saved == LEGACY_PI_API_BASE_URL) {
prefs.edit().putString("api_base_url", DEFAULT_API_BASE_URL).apply()
}
if (prefs.getString("training_computer_name", null).isNullOrBlank()) {
pinDefaultTrainingComputer()
}
}
var apiBaseUrl: String
get() = normalizeBaseUrl(prefs.getString("api_base_url", DEFAULT_API_BASE_URL) ?: DEFAULT_API_BASE_URL)
set(value) = prefs.edit().putString("api_base_url", normalizeBaseUrl(value)).apply()
var commandToken: String
get() = prefs.getString("command_token", "") ?: ""
set(value) = prefs.edit().putString("command_token", value.trim()).apply()
var selectedSymbol: String
get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT"
set(value) = prefs.edit().putString("selected_symbol", value).apply()
var themeMode: String
get() = prefs.getString("theme_mode", "dark") ?: "dark"
set(value) = prefs.edit().putString("theme_mode", value).apply()
var retrainScheduleEnabled: Boolean
get() = prefs.getBoolean("retrain_schedule_enabled", false)
set(value) = prefs.edit().putBoolean("retrain_schedule_enabled", value).apply()
var retrainIntervalHours: Int
get() = prefs.getInt("retrain_interval_hours", 6)
set(value) = prefs.edit().putInt("retrain_interval_hours", value.coerceAtLeast(1)).apply()
val trainingComputerName: String
get() = prefs.getString("training_computer_name", DEFAULT_TRAINING_COMPUTER_NAME) ?: DEFAULT_TRAINING_COMPUTER_NAME
val trainingComputerPath: String
get() = prefs.getString("training_computer_path", DEFAULT_TRAINING_COMPUTER_PATH) ?: DEFAULT_TRAINING_COMPUTER_PATH
val trainingComputerPinned: Boolean
get() = prefs.getBoolean("training_computer_pinned", true)
fun pinDefaultTrainingComputer() {
prefs.edit()
.putString("training_computer_name", DEFAULT_TRAINING_COMPUTER_NAME)
.putString("training_computer_path", DEFAULT_TRAINING_COMPUTER_PATH)
.putBoolean("training_computer_pinned", true)
.apply()
}
private fun normalizeBaseUrl(value: String): String {
val trimmed = value.trim().trimEnd('/')
if (trimmed.isBlank()) return DEFAULT_API_BASE_URL
return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"https://$trimmed"
}
}
private companion object {
const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz"
const val LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787"
const val DEFAULT_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
const val DEFAULT_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
}
}
@@ -0,0 +1,230 @@
package xyz.kusoft.tradebotmonitor
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
class CandleChartView(context: Context) : View(context) {
var candles: List<Candle> = emptyList()
set(value) {
field = value.takeLast(90)
invalidate()
}
var palette: AppPalette = AppPalette.dark()
set(value) {
field = value
invalidate()
}
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 11f * resources.displayMetrics.scaledDensity
typeface = android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)
}
private val dateFormat = SimpleDateFormat("dd.MM HH:mm", Locale("ru", "RU"))
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(palette.chartBg)
val rows = candles.filter { it.high > 0.0 && it.low > 0.0 }
if (rows.size < 2) {
drawEmpty(canvas)
return
}
val left = dp(8).toFloat()
val right = width - dp(62).toFloat()
val top = dp(12).toFloat()
val bottom = height - dp(24).toFloat()
val volumeHeight = max(dp(44).toFloat(), height * 0.17f)
val chartBottom = bottom - volumeHeight - dp(8)
val chartHeight = max(dp(120).toFloat(), chartBottom - top)
val plotWidth = right - left
val values = mutableListOf<Double>()
rows.forEach {
values += it.high
values += it.low
it.ema50?.let(values::add)
it.ema200?.let(values::add)
}
var minPrice = values.minOrNull() ?: 0.0
var maxPrice = values.maxOrNull() ?: 1.0
val rawRange = max(maxPrice - minPrice, maxPrice * 0.0001)
minPrice -= rawRange * 0.08
maxPrice += rawRange * 0.08
val range = max(maxPrice - minPrice, 1e-9)
fun y(value: Double): Float = (top + ((maxPrice - value) / range).toFloat() * chartHeight)
fun x(index: Int): Float {
val step = plotWidth / rows.size
return left + index * step + step / 2f
}
drawGrid(canvas, left, right, top, chartHeight, minPrice, maxPrice)
drawVolumes(canvas, rows, left, plotWidth, chartBottom + dp(8), volumeHeight - dp(4))
val bodyWidth = (plotWidth / rows.size * 0.62f).coerceIn(dp(3).toFloat(), dp(10).toFloat())
rows.forEachIndexed { index, candle -> drawCandle(canvas, candle, x(index), bodyWidth, ::y) }
drawAverageLine(canvas, rows, { it.ema50 }, ::x, ::y, android.graphics.Color.rgb(147, 197, 253))
drawAverageLine(canvas, rows, { it.ema200 }, ::x, ::y, android.graphics.Color.rgb(245, 158, 11))
drawLastPrice(canvas, rows.last(), left, right, ::y)
drawTimeAxis(canvas, rows, left, plotWidth, height - dp(7).toFloat())
}
private fun drawEmpty(canvas: Canvas) {
textPaint.color = android.graphics.Color.rgb(148, 163, 184)
textPaint.textAlign = Paint.Align.LEFT
canvas.drawText("Нет свечей для графика", dp(14).toFloat(), dp(30).toFloat(), textPaint)
}
private fun drawGrid(
canvas: Canvas,
left: Float,
right: Float,
top: Float,
chartHeight: Float,
minPrice: Double,
maxPrice: Double,
) {
paint.style = Paint.Style.STROKE
paint.strokeWidth = 1f
paint.color = palette.chartGrid
textPaint.color = android.graphics.Color.rgb(154, 167, 183)
textPaint.textAlign = Paint.Align.RIGHT
for (i in 0..5) {
val ratio = i / 5f
val yy = top + chartHeight * ratio
canvas.drawLine(left, yy, right + dp(4), yy, paint)
val price = maxPrice - (maxPrice - minPrice) * ratio
canvas.drawText(compactPrice(price), width - dp(5).toFloat(), yy + dp(4), textPaint)
}
}
private fun drawVolumes(
canvas: Canvas,
rows: List<Candle>,
left: Float,
plotWidth: Float,
top: Float,
height: Float,
) {
val maxVolume = max(rows.maxOf { it.volume }, 1.0)
val step = plotWidth / rows.size
val barWidth = (step * 0.56f).coerceIn(dp(2).toFloat(), dp(8).toFloat())
paint.style = Paint.Style.FILL
rows.forEachIndexed { index, candle ->
val up = candle.close >= candle.open
paint.color = if (up) withAlpha(palette.green, 90) else withAlpha(palette.red, 90)
val barHeight = max(1f, (candle.volume / maxVolume).toFloat() * height)
val x = left + index * step + step / 2f - barWidth / 2f
canvas.drawRect(x, top + height - barHeight, x + barWidth, top + height, paint)
}
}
private fun drawCandle(
canvas: Canvas,
candle: Candle,
x: Float,
bodyWidth: Float,
y: (Double) -> Float,
) {
val up = candle.close >= candle.open
val color = if (up) palette.green else palette.red
paint.color = color
paint.strokeWidth = 1.1f
paint.style = Paint.Style.STROKE
canvas.drawLine(x, y(candle.high), x, y(candle.low), paint)
val top = y(max(candle.open, candle.close))
val bottom = y(min(candle.open, candle.close))
paint.style = Paint.Style.FILL
val bodyHeight = max(1.5f, bottom - top)
canvas.drawRect(RectF(x - bodyWidth / 2f, top, x + bodyWidth / 2f, top + bodyHeight), paint)
}
private fun drawAverageLine(
canvas: Canvas,
rows: List<Candle>,
getter: (Candle) -> Double?,
x: (Int) -> Float,
y: (Double) -> Float,
color: Int,
) {
paint.color = color
paint.strokeWidth = 1.35f
paint.style = Paint.Style.STROKE
var started = false
rows.forEachIndexed { index, candle ->
val value = getter(candle) ?: return@forEachIndexed
if (value <= 0.0) return@forEachIndexed
if (!started) {
canvas.save()
paint.pathEffect = null
canvas.restore()
started = true
val xx = x(index)
val yy = y(value)
canvas.drawPoint(xx, yy, paint)
paint.strokeWidth = 1.35f
android.graphics.Path().also { path ->
path.moveTo(xx, yy)
for (next in index + 1 until rows.size) {
val nextValue = getter(rows[next]) ?: continue
if (nextValue > 0.0) path.lineTo(x(next), y(nextValue))
}
canvas.drawPath(path, paint)
}
return
}
}
}
private fun drawLastPrice(canvas: Canvas, candle: Candle, left: Float, right: Float, y: (Double) -> Float) {
val yy = y(candle.close)
paint.style = Paint.Style.STROKE
paint.strokeWidth = 1f
paint.color = android.graphics.Color.rgb(229, 231, 235)
paint.pathEffect = android.graphics.DashPathEffect(floatArrayOf(8f, 6f), 0f)
canvas.drawLine(left, yy, right + dp(4), yy, paint)
paint.pathEffect = null
textPaint.color = android.graphics.Color.rgb(229, 231, 235)
textPaint.textAlign = Paint.Align.RIGHT
canvas.drawText(compactPrice(candle.close), width - dp(5).toFloat(), yy - dp(3), textPaint)
}
private fun drawTimeAxis(canvas: Canvas, rows: List<Candle>, left: Float, plotWidth: Float, baseline: Float) {
textPaint.color = android.graphics.Color.rgb(154, 167, 183)
textPaint.textAlign = Paint.Align.CENTER
val ticks = min(5, rows.size)
for (i in 0 until ticks) {
val index = ((rows.size - 1) * (i / max(1f, (ticks - 1).toFloat()))).toInt().coerceIn(0, rows.lastIndex)
val step = plotWidth / rows.size
val xx = left + index * step + step / 2f
val timestamp = rows[index].timestamp
val date = if (timestamp > 100_000_000_000L) Date(timestamp) else Date(timestamp * 1000L)
canvas.drawText(dateFormat.format(date), xx, baseline, textPaint)
}
}
private fun compactPrice(value: Double): String =
when {
value >= 1000.0 -> "%,.0f".format(Locale.US, value)
value >= 1.0 -> "%,.3f".format(Locale.US, value)
else -> "%.8f".format(Locale.US, value).trimEnd('0').trimEnd('.')
}
private fun withAlpha(color: Int, alpha: Int): Int =
android.graphics.Color.argb(alpha, android.graphics.Color.red(color), android.graphics.Color.green(color), android.graphics.Color.blue(color))
private fun dp(value: Int): Int =
(value * resources.displayMetrics.density).toInt()
}
@@ -0,0 +1,157 @@
package xyz.kusoft.tradebotmonitor
import org.json.JSONObject
data class Candle(
val timestamp: Long,
val open: Double,
val high: Double,
val low: Double,
val close: Double,
val volume: Double,
val ema50: Double?,
val ema200: Double?,
val rsi14: Double?,
val atr14: Double?,
val macd: Double?,
val macdSignal: Double?,
)
data class TickerData(
val symbol: String,
val lastPrice: Double,
val bid: Double,
val ask: Double,
val turnover24h: Double,
val volume24h: Double,
val change24h: Double,
val spreadPercent: Double,
)
data class FeatureItem(
val group: String,
val label: String,
val rawDisplay: String,
val modelDisplay: String,
val interpretation: String,
)
data class ForecastData(
val model: String,
val expectedReturnPercent: Double,
val probabilityUp: Double,
val skill: Double,
val volatilityPercent: Double,
val horizon: Int,
val q10Percent: Double,
val q50Percent: Double,
val q90Percent: Double,
val qualityGatePassed: Boolean?,
val reason: String,
val features: List<FeatureItem>,
)
data class MarketItem(
val symbol: String,
val ticker: TickerData?,
val candles: List<Candle>,
val forecast: ForecastData?,
val qualityStatus: String,
val qualityScore: Double,
)
data class SignalData(
val symbol: String,
val action: String,
val confidence: Double,
val reason: String,
val diagnostics: JSONObject,
) {
val expectedReturnPercent: Double
get() = diagnostics.optDoubleOrNull("expected_return_percent")
?: diagnostics.optJSONObject("forecast")?.optDoubleOrNull("expected_return_percent")
?: 0.0
val probabilityUp: Double
get() = diagnostics.optDoubleOrNull("probability_up")
?: diagnostics.optJSONObject("forecast")?.optDoubleOrNull("probability_up")
?: 0.0
val positionNotionalUsdt: Double
get() = diagnostics.optDoubleOrNull("position_notional_usdt")
?: diagnostics.optJSONObject("position_sizing")?.optDoubleOrNull("notional_usdt")
?: 0.0
}
data class PositionData(
val symbol: String,
val qty: Double,
val entryPrice: Double,
val markPrice: Double,
val notionalUsdt: Double,
val marketValue: Double,
val unrealizedPnl: Double,
val unrealizedPnlPercent: Double,
val stopLoss: Double?,
val takeProfit: Double?,
val highestPrice: Double?,
val trailingStop: Double?,
val atrTrailingStop: Double?,
val exitAction: String,
val exitReason: String,
val stopLossExitEnabled: Boolean,
)
data class AccountData(
val equity: Double,
val cash: Double,
val exposure: Double,
)
data class ClosedTradeData(
val symbol: String,
val qty: Double,
val entryPrice: Double?,
val exitPrice: Double?,
val grossPnl: Double,
val feeUsdt: Double,
val netPnl: Double,
val reason: String,
val openedAt: String,
val closedAt: String,
)
data class ClosedTradesSummary(
val trades: Int,
val netPnl: Double,
val grossPnl: Double,
val feeUsdt: Double,
val wins: Int,
val losses: Int,
val winRate: Double,
)
data class BotSnapshot(
val ok: Boolean,
val running: Boolean,
val mode: String,
val account: AccountData,
val positions: List<PositionData>,
val closedTrades: List<ClosedTradeData>,
val closedTradesSummary: ClosedTradesSummary,
val markets: List<MarketItem>,
val signalsBySymbol: Map<String, SignalData>,
val config: JSONObject,
val retrain: JSONObject,
val backtest: JSONObject,
val fetchedAtMillis: Long,
)
fun JSONObject.optStringClean(name: String): String =
if (isNull(name)) "" else optString(name, "")
fun JSONObject.optDoubleOrNull(name: String): Double? =
if (has(name) && !isNull(name)) optDouble(name) else null
fun JSONObject.optBooleanOrNull(name: String): Boolean? =
if (has(name) && !isNull(name)) optBoolean(name) else null
@@ -0,0 +1,63 @@
package xyz.kusoft.tradebotmonitor
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.util.concurrent.Executors
object RetrainScheduler {
private const val ACTION_RETRAIN = "xyz.kusoft.tradebotmonitor.RETRAIN"
private const val REQUEST_CODE = 6406
fun schedule(context: Context, hours: Int) {
val interval = hours.coerceAtLeast(1) * 60L * 60L * 1000L
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
manager.setInexactRepeating(
AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + interval,
interval,
pendingIntent(context),
)
}
fun cancel(context: Context) {
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
manager.cancel(pendingIntent(context))
}
private fun pendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
REQUEST_CODE,
Intent(context, RetrainAlarmReceiver::class.java).setAction(ACTION_RETRAIN),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
class RetrainAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
Executors.newSingleThreadExecutor().execute {
try {
val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled) {
TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
}
} finally {
pending.finish()
}
}
}
}
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled) {
RetrainScheduler.schedule(context, prefs.retrainIntervalHours)
}
}
}
@@ -0,0 +1,287 @@
package xyz.kusoft.tradebotmonitor
import android.util.Base64
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
class TradeBotApi(
private val baseUrl: String,
private val token: String,
) {
fun fetchSnapshot(): BotSnapshot {
val health = getJson("/api/health")
val status = getJson("/api/status")
val markets = getJson("/api/markets")
val signals = getJson("/api/signals?limit=220")
val config = getJson("/api/config")
val trades = getJson("/api/trades?limit=10")
val retrain = getJson("/api/retrain")
val backtest = getJson("/api/backtest")
val accountJson = status.optJSONObject("account") ?: JSONObject()
val account = AccountData(
equity = accountJson.optDouble("equity", 0.0),
cash = accountJson.optDouble("cash", 0.0),
exposure = accountJson.optDouble("exposure", 0.0),
)
return BotSnapshot(
ok = health.optBoolean("ok", false),
running = health.optBoolean("running", false),
mode = health.optStringClean("mode"),
account = account,
positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()),
closedTrades = parseClosedTrades(trades.optJSONArray("closed_items") ?: JSONArray()),
closedTradesSummary = parseClosedTradesSummary(trades.optJSONObject("closed_summary") ?: JSONObject()),
markets = parseMarkets(markets.optJSONArray("markets") ?: JSONArray()),
signalsBySymbol = parseLatestSignals(signals.optJSONArray("items") ?: JSONArray()),
config = config,
retrain = retrain,
backtest = backtest,
fetchedAtMillis = System.currentTimeMillis(),
)
}
fun startBot() {
postJson("/api/control/start")
}
fun stopBot() {
postJson("/api/control/stop")
}
fun requestRetrain(): String {
val response = postJson("/api/training/retrain", "{\"source\":\"android\"}")
if (response.optBoolean("queued", false)) {
return "Задание переобучения отправлено на закрепленный компьютер"
}
return when (response.optStringClean("reason")) {
"active_job_exists" -> "Задание переобучения уже выполняется или ждет агента"
else -> "Задание переобучения принято ботом"
}
}
private fun getJson(path: String): JSONObject = request(path, "GET")
private fun postJson(path: String, body: String = "{}"): JSONObject = request(path, "POST", body)
private fun request(path: String, method: String, body: String = "{}"): JSONObject {
val root = baseUrl.trim().trimEnd('/')
val url = URL(root + path)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 6000
readTimeout = 9000
setRequestProperty("Accept", "application/json")
applyAuthHeaders(this, token)
if (method == "POST") {
doOutput = true
setRequestProperty("Content-Type", "application/json")
outputStream.use { stream -> stream.write(body.toByteArray(StandardCharsets.UTF_8)) }
}
}
val code = connection.responseCode
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader(StandardCharsets.UTF_8)?.use(BufferedReader::readText).orEmpty()
connection.disconnect()
if (code !in 200..299) {
if (code == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw IllegalStateException("HTTP 401: сервер требует логин и пароль")
}
throw IllegalStateException("HTTP $code: ${text.take(240)}")
}
return if (text.isBlank()) JSONObject() else JSONObject(text)
}
private fun parsePositions(items: JSONArray): List<PositionData> {
val output = mutableListOf<PositionData>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject()
output += PositionData(
symbol = row.optStringClean("symbol"),
qty = row.optDouble("qty", 0.0),
entryPrice = row.optDouble("entry_price", 0.0),
markPrice = row.optDouble("mark_price", 0.0),
notionalUsdt = row.optDouble("notional_usdt", 0.0),
marketValue = row.optDouble("market_value", 0.0),
unrealizedPnl = row.optDouble("unrealized_pnl", 0.0),
unrealizedPnlPercent = row.optDouble("unrealized_pnl_percent", 0.0),
stopLoss = exitPlan.optDoubleOrNull("stop_loss") ?: row.optDoubleOrNull("stop_loss"),
takeProfit = exitPlan.optDoubleOrNull("take_profit") ?: row.optDoubleOrNull("take_profit"),
highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"),
trailingStop = exitPlan.optDoubleOrNull("trailing_stop"),
atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"),
exitAction = exitPlan.optStringClean("action"),
exitReason = exitPlan.optStringClean("reason"),
stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true,
)
}
return output
}
private fun parseClosedTrades(items: JSONArray): List<ClosedTradeData> {
val output = mutableListOf<ClosedTradeData>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
output += ClosedTradeData(
symbol = row.optStringClean("symbol"),
qty = row.optDouble("qty", 0.0),
entryPrice = row.optDoubleOrNull("entry_price"),
exitPrice = row.optDoubleOrNull("exit_price"),
grossPnl = row.optDouble("gross_pnl", 0.0),
feeUsdt = row.optDouble("fee_usdt", 0.0),
netPnl = row.optDouble("net_pnl", 0.0),
reason = row.optStringClean("reason"),
openedAt = row.optStringClean("opened_at"),
closedAt = row.optStringClean("closed_at"),
)
}
return output
}
private fun parseClosedTradesSummary(row: JSONObject): ClosedTradesSummary =
ClosedTradesSummary(
trades = row.optInt("trades", 0),
netPnl = row.optDouble("net_pnl", 0.0),
grossPnl = row.optDouble("gross_pnl", 0.0),
feeUsdt = row.optDouble("fee_usdt", 0.0),
wins = row.optInt("wins", 0),
losses = row.optInt("losses", 0),
winRate = row.optDouble("win_rate", 0.0),
)
private fun parseMarkets(items: JSONArray): List<MarketItem> {
val output = mutableListOf<MarketItem>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
val tickerJson = row.optJSONObject("ticker")
val instrument = row.optJSONObject("instrument") ?: JSONObject()
val symbol = tickerJson?.optStringClean("symbol").orEmpty().ifBlank {
instrument.optStringClean("symbol")
}
val quality = row.optJSONObject("quality") ?: JSONObject()
output += MarketItem(
symbol = symbol,
ticker = tickerJson?.let(::parseTicker),
candles = parseCandles(row.optJSONArray("candles") ?: JSONArray()),
forecast = row.optJSONObject("forecast")?.let(::parseForecast),
qualityStatus = quality.optStringClean("status"),
qualityScore = quality.optDouble("score", 0.0),
)
}
return output.sortedBy { it.symbol }
}
private fun parseTicker(row: JSONObject): TickerData =
TickerData(
symbol = row.optStringClean("symbol"),
lastPrice = row.optDouble("last_price", 0.0),
bid = row.optDouble("bid", 0.0),
ask = row.optDouble("ask", 0.0),
turnover24h = row.optDouble("turnover_24h", 0.0),
volume24h = row.optDouble("volume_24h", 0.0),
change24h = row.optDouble("change_24h", 0.0),
spreadPercent = row.optDouble("spread_percent", 0.0),
)
private fun parseCandles(items: JSONArray): List<Candle> {
val output = mutableListOf<Candle>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
output += Candle(
timestamp = row.optLong("timestamp", 0L),
open = row.optDouble("open", 0.0),
high = row.optDouble("high", 0.0),
low = row.optDouble("low", 0.0),
close = row.optDouble("close", 0.0),
volume = row.optDouble("volume", 0.0),
ema50 = row.optDoubleOrNull("ema_50"),
ema200 = row.optDoubleOrNull("ema_200"),
rsi14 = row.optDoubleOrNull("rsi_14"),
atr14 = row.optDoubleOrNull("atr_14"),
macd = row.optDoubleOrNull("macd"),
macdSignal = row.optDoubleOrNull("macd_signal"),
)
}
return output
}
private fun parseForecast(row: JSONObject): ForecastData {
val featureItems = row.optJSONArray("feature_snapshot") ?: JSONArray()
val features = mutableListOf<FeatureItem>()
for (index in 0 until featureItems.length()) {
val feature = featureItems.optJSONObject(index) ?: continue
features += FeatureItem(
group = feature.optStringClean("group"),
label = feature.optStringClean("label").ifBlank { feature.optStringClean("name") },
rawDisplay = feature.optStringClean("raw_display").ifBlank {
feature.optStringClean("raw_value")
},
modelDisplay = feature.optStringClean("model_display").ifBlank {
feature.optStringClean("model_value")
},
interpretation = feature.optStringClean("interpretation").ifBlank {
feature.optStringClean("description")
},
)
}
return ForecastData(
model = row.optStringClean("model"),
expectedReturnPercent = row.optDouble("expected_return_percent", 0.0),
probabilityUp = row.optDouble("probability_up", 0.0),
skill = row.optDouble("skill", 0.0),
volatilityPercent = row.optDouble("volatility_percent", 0.0),
horizon = row.optInt("horizon", 0),
q10Percent = row.optDouble("quantile_10_percent", 0.0),
q50Percent = row.optDouble("quantile_50_percent", 0.0),
q90Percent = row.optDouble("quantile_90_percent", 0.0),
qualityGatePassed = row.optBooleanOrNull("quality_gate_passed"),
reason = row.optStringClean("reason"),
features = features,
)
}
private fun parseLatestSignals(items: JSONArray): Map<String, SignalData> {
val output = linkedMapOf<String, SignalData>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
val symbol = row.optStringClean("symbol")
if (symbol.isBlank() || output.containsKey(symbol)) continue
val diagnostics = try {
JSONObject(row.optStringClean("diagnostics_json"))
} catch (_: Exception) {
JSONObject()
}
output[symbol] = SignalData(
symbol = symbol,
action = row.optStringClean("action"),
confidence = row.optDouble("confidence", 0.0),
reason = row.optStringClean("reason"),
diagnostics = diagnostics,
)
}
return output
}
}
private fun applyAuthHeaders(connection: HttpURLConnection, token: String) {
val value = token.trim()
if (value.isBlank()) return
connection.setRequestProperty("X-TradeBot-Token", value)
val authorization = when {
value.startsWith("Basic ", ignoreCase = true) -> value
value.startsWith("Bearer ", ignoreCase = true) -> value
":" in value -> {
val encoded = Base64.encodeToString(value.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP)
"Basic $encoded"
}
else -> "Bearer $value"
}
connection.setRequestProperty("Authorization", authorization)
}
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#07080B"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#111722"
android:pathData="M20,20h68a10,10 0,0 1,10 10v48a10,10 0,0 1,-10 10h-68a10,10 0,0 1,-10 -10v-48a10,10 0,0 1,10 -10z" />
<path
android:strokeColor="#28303D"
android:strokeWidth="2"
android:fillColor="@android:color/transparent"
android:pathData="M20,20h68a10,10 0,0 1,10 10v48a10,10 0,0 1,-10 10h-68a10,10 0,0 1,-10 -10v-48a10,10 0,0 1,10 -10z" />
<path
android:strokeColor="#16C784"
android:strokeWidth="5"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:fillColor="@android:color/transparent"
android:pathData="M22,66 L35,58 L46,62 L58,42 L72,49 L86,31" />
<path
android:strokeColor="#F5A623"
android:strokeWidth="3"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent"
android:pathData="M24,78h60" />
<path
android:fillColor="#16C784"
android:pathData="M28,31h8v22h-8z M50,36h8v17h-8z M72,29h8v24h-8z" />
<path
android:fillColor="#FF5066"
android:pathData="M39,38h8v15h-8z M61,33h8v20h-8z" />
<path
android:fillColor="#F5A623"
android:pathData="M82,26a6,6 0,1 0,0.1 0z" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.2.1" apply false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

@@ -0,0 +1,236 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="980" viewBox="0 0 1600 980">
<defs>
<style>
.canvas { fill: #07080b; }
.phone { fill: #050609; stroke: #232833; stroke-width: 2; }
.screen { fill: #07080c; }
.panel { fill: #11141a; stroke: #222833; stroke-width: 1; }
.panel2 { fill: #151922; stroke: #28303d; stroke-width: 1; }
.soft { fill: #0d1016; stroke: #1d2430; stroke-width: 1; }
.line { stroke: #242a36; stroke-width: 1; }
.grid { stroke: #1b2029; stroke-width: 1; }
.text { fill: #f4f6fb; font-family: Inter, Arial, sans-serif; }
.muted { fill: #777f8e; font-family: Inter, Arial, sans-serif; }
.dim { fill: #4f5664; font-family: Inter, Arial, sans-serif; }
.green { fill: #16c784; font-family: Inter, Arial, sans-serif; }
.red { fill: #ff5066; font-family: Inter, Arial, sans-serif; }
.amber { fill: #f5a623; font-family: Inter, Arial, sans-serif; }
.blue { fill: #6d83ff; font-family: Inter, Arial, sans-serif; }
.stroke-green { stroke: #16c784; }
.stroke-red { stroke: #ff5066; }
.stroke-amber { stroke: #f5a623; }
.stroke-blue { stroke: #6d83ff; }
.label { font-size: 13px; font-weight: 600; letter-spacing: 0; }
.tiny { font-size: 11px; letter-spacing: 0; }
.small { font-size: 13px; letter-spacing: 0; }
.base { font-size: 15px; letter-spacing: 0; }
.h2 { font-size: 18px; font-weight: 700; letter-spacing: 0; }
.h1 { font-size: 30px; font-weight: 800; letter-spacing: 0; }
.tab { font-size: 15px; font-weight: 700; letter-spacing: 0; }
.nav { font-size: 11px; font-weight: 600; letter-spacing: 0; }
.mono { font-family: "JetBrains Mono", Consolas, monospace; letter-spacing: 0; }
.shadow { filter: drop-shadow(0 24px 38px rgba(0,0,0,.42)); }
</style>
<linearGradient id="heroLine" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#16c784"/>
<stop offset="1" stop-color="#f5a623"/>
</linearGradient>
<linearGradient id="riskLine" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#16c784"/>
<stop offset=".62" stop-color="#16c784"/>
<stop offset=".62" stop-color="#28303d"/>
<stop offset="1" stop-color="#28303d"/>
</linearGradient>
</defs>
<rect class="canvas" width="1600" height="980"/>
<text x="48" y="54" class="text h2">Вариант 2: AI Trading Console</text>
<text x="48" y="79" class="muted small">Фокус не на ручной торговле, а на решении Torch, риске Kelly и контроле бота. Стиль: строгий биржевой интерфейс без пузырей.</text>
<!-- Screen 1 -->
<g class="shadow" transform="translate(54 116)">
<rect class="phone" x="0" y="0" width="420" height="820" rx="36"/>
<rect class="screen" x="16" y="16" width="388" height="788" rx="26"/>
<text x="34" y="48" class="text small">22:18</text>
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
<text x="338" y="48" class="text small">91%</text>
<text x="34" y="88" class="text h2">AI Торговля</text>
<rect x="280" y="68" width="94" height="28" rx="7" fill="#14251f" stroke="#16c784"/>
<text x="298" y="87" class="green label">PAPER</text>
<text x="34" y="130" class="text h2">BTC/USDT</text>
<text x="34" y="170" class="green h1">67 240.5</text>
<text x="205" y="169" class="green base">+0.84%</text>
<text x="34" y="194" class="muted small">Edge +0.82% · P(up) 64% · 1h</text>
<text x="276" y="128" class="muted small">Equity</text>
<text x="276" y="152" class="text h2">108.42 USDT</text>
<line x1="34" y1="220" x2="374" y2="220" class="line"/>
<text x="34" y="248" class="text tab">График</text>
<text x="114" y="248" class="muted tab">Torch</text>
<text x="190" y="248" class="muted tab">Риск</text>
<text x="250" y="248" class="muted tab">История</text>
<line x1="34" y1="258" x2="88" y2="258" stroke="#f5a623" stroke-width="3"/>
<g transform="translate(34 280)">
<rect x="0" y="0" width="340" height="242" fill="#080a0f"/>
<line x1="0" y1="48" x2="340" y2="48" class="grid"/>
<line x1="0" y1="96" x2="340" y2="96" class="grid"/>
<line x1="0" y1="144" x2="340" y2="144" class="grid"/>
<line x1="0" y1="192" x2="340" y2="192" class="grid"/>
<line x1="68" y1="0" x2="68" y2="242" class="grid"/>
<line x1="136" y1="0" x2="136" y2="242" class="grid"/>
<line x1="204" y1="0" x2="204" y2="242" class="grid"/>
<line x1="272" y1="0" x2="272" y2="242" class="grid"/>
<polyline points="2,153 32,140 62,151 92,112 122,124 152,93 182,105 212,74 242,88 272,63 302,92 338,80" fill="none" stroke="#6d83ff" stroke-width="1.5" opacity=".7"/>
<polyline points="2,166 32,155 62,160 92,140 122,128 152,119 182,113 212,104 242,94 272,82 302,88 338,84" fill="none" stroke="#f5a623" stroke-width="1.5" opacity=".85"/>
<g stroke-width="1.4">
<line x1="16" y1="125" x2="16" y2="172" class="stroke-red"/><rect x="11" y="135" width="10" height="27" fill="#ff5066"/>
<line x1="34" y1="143" x2="34" y2="181" class="stroke-green"/><rect x="29" y="151" width="10" height="20" fill="#16c784"/>
<line x1="52" y1="136" x2="52" y2="186" class="stroke-red"/><rect x="47" y="148" width="10" height="26" fill="#ff5066"/>
<line x1="70" y1="129" x2="70" y2="158" class="stroke-green"/><rect x="65" y="137" width="10" height="13" fill="#16c784"/>
<line x1="88" y1="105" x2="88" y2="150" class="stroke-green"/><rect x="83" y="118" width="10" height="24" fill="#16c784"/>
<line x1="106" y1="112" x2="106" y2="164" class="stroke-red"/><rect x="101" y="124" width="10" height="31" fill="#ff5066"/>
<line x1="124" y1="99" x2="124" y2="139" class="stroke-green"/><rect x="119" y="109" width="10" height="20" fill="#16c784"/>
<line x1="142" y1="86" x2="142" y2="132" class="stroke-green"/><rect x="137" y="98" width="10" height="22" fill="#16c784"/>
<line x1="160" y1="74" x2="160" y2="127" class="stroke-red"/><rect x="155" y="88" width="10" height="30" fill="#ff5066"/>
<line x1="178" y1="91" x2="178" y2="135" class="stroke-green"/><rect x="173" y="103" width="10" height="23" fill="#16c784"/>
<line x1="196" y1="80" x2="196" y2="115" class="stroke-green"/><rect x="191" y="91" width="10" height="15" fill="#16c784"/>
<line x1="214" y1="62" x2="214" y2="111" class="stroke-red"/><rect x="209" y="74" width="10" height="27" fill="#ff5066"/>
<line x1="232" y1="67" x2="232" y2="102" class="stroke-green"/><rect x="227" y="78" width="10" height="15" fill="#16c784"/>
<line x1="250" y1="55" x2="250" y2="91" class="stroke-green"/><rect x="245" y="66" width="10" height="17" fill="#16c784"/>
<line x1="268" y1="66" x2="268" y2="119" class="stroke-red"/><rect x="263" y="78" width="10" height="30" fill="#ff5066"/>
<line x1="286" y1="77" x2="286" y2="116" class="stroke-green"/><rect x="281" y="87" width="10" height="20" fill="#16c784"/>
<line x1="304" y1="71" x2="304" y2="121" class="stroke-red"/><rect x="299" y="84" width="10" height="27" fill="#ff5066"/>
<line x1="322" y1="69" x2="322" y2="105" class="stroke-green"/><rect x="317" y="78" width="10" height="18" fill="#16c784"/>
</g>
<line x1="0" y1="83" x2="340" y2="83" stroke="#d8dde7" stroke-width="1" stroke-dasharray="6 6" opacity=".65"/>
<rect x="274" y="66" width="66" height="34" rx="5" fill="#11141a" stroke="#d8dde7"/>
<text x="332" y="87" text-anchor="end" class="text small mono">67 240</text>
<text x="0" y="264" class="muted tiny">EMA50</text>
<text x="54" y="264" class="amber tiny">EMA200</text>
<text x="264" y="264" class="muted tiny">26.06 22:00</text>
</g>
<g transform="translate(34 574)">
<rect class="panel2" x="0" y="0" width="340" height="124" rx="8"/>
<text x="16" y="29" class="muted small">Решение Torch</text>
<text x="16" y="62" class="green h2">Покупка разрешена</text>
<text x="194" y="31" class="muted small">Kelly</text>
<text x="194" y="62" class="text h2">18.4 USDT</text>
<line x1="16" y1="78" x2="324" y2="78" class="line"/>
<text x="16" y="103" class="muted small">Причина</text>
<text x="82" y="103" class="text small">Тренд 1D выше EMA200, MACD вверх</text>
</g>
<g transform="translate(34 716)">
<rect x="0" y="0" width="340" height="48" rx="8" fill="#10141c" stroke="#28303d"/>
<text x="26" y="30" class="muted nav">Рынки</text>
<text x="98" y="30" class="amber nav">AI</text>
<text x="154" y="30" class="muted nav">Риск</text>
<text x="218" y="30" class="muted nav">Активы</text>
<text x="286" y="30" class="muted nav">Настройки</text>
</g>
</g>
<!-- Screen 2 -->
<g class="shadow" transform="translate(590 116)">
<rect class="phone" x="0" y="0" width="420" height="820" rx="36"/>
<rect class="screen" x="16" y="16" width="388" height="788" rx="26"/>
<text x="34" y="48" class="text small">22:18</text>
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
<text x="338" y="48" class="text small">91%</text>
<text x="34" y="88" class="text h2">Рынки</text>
<text x="34" y="114" class="muted small">12 фиксированных spot-пар</text>
<rect x="34" y="136" width="340" height="42" rx="7" fill="#11141a" stroke="#242a36"/>
<text x="52" y="162" class="dim small">Поиск пары или сигнала</text>
<text x="34" y="209" class="text tab">AI-рейтинг</text>
<text x="141" y="209" class="muted tab">Цена</text>
<text x="226" y="209" class="muted tab">Edge</text>
<text x="304" y="209" class="muted tab">Статус</text>
<line x1="34" y1="221" x2="374" y2="221" class="line"/>
<g transform="translate(34 240)">
<rect x="0" y="0" width="340" height="46" fill="#151922"/>
<text x="0" y="19" class="text base">BTCUSDT</text><text x="0" y="38" class="muted tiny">P(up) 64% · Kelly 18.4</text>
<text x="154" y="28" text-anchor="end" class="text base mono">67 240</text>
<text x="230" y="28" text-anchor="end" class="green base">+0.82%</text>
<text x="336" y="28" text-anchor="end" class="green small">BUY</text>
<g transform="translate(0 56)"><text x="0" y="19" class="text base">ETHUSDT</text><text x="0" y="38" class="muted tiny">P(up) 57% · Kelly 6.1</text><text x="154" y="28" text-anchor="end" class="text base mono">3 524</text><text x="230" y="28" text-anchor="end" class="green base">+0.31%</text><text x="336" y="28" text-anchor="end" class="amber small">WAIT</text></g>
<g transform="translate(0 112)"><text x="0" y="19" class="text base">HYPEUSDT</text><text x="0" y="38" class="muted tiny">P(up) 49% · Kelly 0.0</text><text x="154" y="28" text-anchor="end" class="text base mono">39.18</text><text x="230" y="28" text-anchor="end" class="red base">-0.14%</text><text x="336" y="28" text-anchor="end" class="dim small">BLOCK</text></g>
<g transform="translate(0 168)"><text x="0" y="19" class="text base">SOLUSDT</text><text x="0" y="38" class="muted tiny">P(up) 61% · Kelly 12.7</text><text x="154" y="28" text-anchor="end" class="text base mono">144.62</text><text x="230" y="28" text-anchor="end" class="green base">+0.54%</text><text x="336" y="28" text-anchor="end" class="green small">BUY</text></g>
<g transform="translate(0 224)"><text x="0" y="19" class="text base">XRPUSDT</text><text x="0" y="38" class="muted tiny">P(up) 53% · Kelly 2.2</text><text x="154" y="28" text-anchor="end" class="text base mono">2.18</text><text x="230" y="28" text-anchor="end" class="green base">+0.08%</text><text x="336" y="28" text-anchor="end" class="amber small">WAIT</text></g>
<g transform="translate(0 280)"><text x="0" y="19" class="text base">XPLUSDT</text><text x="0" y="38" class="muted tiny">P(up) 46% · Kelly 0.0</text><text x="154" y="28" text-anchor="end" class="text base mono">0.923</text><text x="230" y="28" text-anchor="end" class="red base">-0.22%</text><text x="336" y="28" text-anchor="end" class="dim small">SKIP</text></g>
<g transform="translate(0 336)"><text x="0" y="19" class="text base">WLDUSDT</text><text x="0" y="38" class="muted tiny">P(up) 58% · Kelly 5.4</text><text x="154" y="28" text-anchor="end" class="text base mono">1.12</text><text x="230" y="28" text-anchor="end" class="green base">+0.27%</text><text x="336" y="28" text-anchor="end" class="amber small">WAIT</text></g>
<g transform="translate(0 392)"><text x="0" y="19" class="text base">MNTUSDT</text><text x="0" y="38" class="muted tiny">P(up) 52% · Kelly 1.4</text><text x="154" y="28" text-anchor="end" class="text base mono">1.05</text><text x="230" y="28" text-anchor="end" class="green base">+0.05%</text><text x="336" y="28" text-anchor="end" class="amber small">WAIT</text></g>
<g transform="translate(0 448)"><text x="0" y="19" class="text base">HUSDT</text><text x="0" y="38" class="muted tiny">P(up) 44% · Kelly 0.0</text><text x="154" y="28" text-anchor="end" class="text base mono">0.073</text><text x="230" y="28" text-anchor="end" class="red base">-0.35%</text><text x="336" y="28" text-anchor="end" class="dim small">SKIP</text></g>
</g>
<g transform="translate(34 716)">
<rect x="0" y="0" width="340" height="48" rx="8" fill="#10141c" stroke="#28303d"/>
<text x="26" y="30" class="amber nav">Рынки</text>
<text x="98" y="30" class="muted nav">AI</text>
<text x="154" y="30" class="muted nav">Риск</text>
<text x="218" y="30" class="muted nav">Активы</text>
<text x="286" y="30" class="muted nav">Настройки</text>
</g>
</g>
<!-- Screen 3 -->
<g class="shadow" transform="translate(1126 116)">
<rect class="phone" x="0" y="0" width="420" height="820" rx="36"/>
<rect class="screen" x="16" y="16" width="388" height="788" rx="26"/>
<text x="34" y="48" class="text small">22:18</text>
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
<text x="338" y="48" class="text small">91%</text>
<text x="34" y="88" class="text h2">Сделка и риск</text>
<text x="34" y="114" class="muted small">BTCUSDT · решение Torch</text>
<rect x="34" y="138" width="340" height="92" rx="8" fill="#121821" stroke="#16c784"/>
<text x="52" y="168" class="muted small">Действие</text>
<text x="52" y="204" class="green h1">BUY</text>
<text x="184" y="168" class="muted small">Размер сейчас</text>
<text x="184" y="204" class="text h2">18.4 USDT</text>
<text x="300" y="168" class="muted small">Риск</text>
<text x="300" y="204" class="amber h2">0.7%</text>
<g transform="translate(34 256)">
<text x="0" y="0" class="text h2">Kelly распределение</text>
<rect x="0" y="22" width="340" height="14" rx="3" fill="url(#riskLine)"/>
<text x="0" y="59" class="muted small">Цель по паре</text><text x="330" y="59" text-anchor="end" class="text small">31.0 USDT</text>
<text x="0" y="88" class="muted small">Уже занято</text><text x="330" y="88" text-anchor="end" class="text small">12.6 USDT</text>
<text x="0" y="117" class="muted small">Можно добавить</text><text x="330" y="117" text-anchor="end" class="green small">18.4 USDT</text>
</g>
<g transform="translate(34 402)">
<rect class="panel" x="0" y="0" width="340" height="164" rx="8"/>
<text x="16" y="30" class="text h2">Почему модель так решила</text>
<text x="16" y="66" class="green small">Edge: +0.82%</text>
<text x="170" y="66" class="green small">P(up): 64%</text>
<text x="16" y="96" class="text small">EMA50 выше EMA200 на 1D</text>
<text x="16" y="124" class="text small">MACD пересёк signal вверх на 1h</text>
<text x="16" y="152" class="muted small">RSI 58, ATR в норме, спред 0.03%</text>
</g>
<g transform="translate(34 592)">
<rect class="soft" x="0" y="0" width="340" height="72" rx="8"/>
<text x="16" y="28" class="muted small">Live готовность</text>
<text x="16" y="55" class="amber base">2 шага не пройдены</text>
<text x="188" y="55" class="muted small">API ключи · лимит ордера</text>
</g>
<g transform="translate(34 684)">
<rect x="0" y="0" width="162" height="48" rx="7" fill="#161b24" stroke="#ff5066"/>
<text x="81" y="30" text-anchor="middle" class="red base">Стоп бот</text>
<rect x="178" y="0" width="162" height="48" rx="7" fill="#16251f" stroke="#16c784"/>
<text x="259" y="30" text-anchor="middle" class="green base">Запустить цикл</text>
</g>
<g transform="translate(34 748)">
<text x="0" y="0" class="dim tiny">Все действия требуют серверного подтверждения. Приложение показывает риск до сделки.</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,2 @@
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
@@ -0,0 +1,25 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
resolutionStrategy {
eachPlugin {
if (requested.id.id == "org.jetbrains.kotlin.android") {
useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:${requested.version ?: "2.2.10"}")
}
}
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TradeBotMonitor"
include(":app")
+14 -7
View File
@@ -112,6 +112,9 @@ def risk_guard_snapshot(
"enabled": False,
"block_new_entries": False,
"position_size_multiplier": 1.0,
"symbol_guard_enabled": settings.risk_symbol_guard_enabled,
"blocked_symbols": [],
"symbols": [],
"reasons": [],
}
active_trades = _active_universe_trades(settings, closed_trades)
@@ -145,6 +148,7 @@ def risk_guard_snapshot(
"reasons": all_reasons,
"global_reasons": reasons,
"degraded_reasons": degraded_reasons,
"symbol_guard_enabled": settings.risk_symbol_guard_enabled,
"blocked_symbols": blocked_symbols,
"symbols": symbol_stats,
"consecutive_losses": consecutive_losses,
@@ -202,6 +206,7 @@ def _active_universe_trades(settings: Settings, trades: list[dict[str, Any]]) ->
def _symbol_guard_stats(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
expectancy_min_samples = max(6, min(settings.risk_recent_trade_window, 10))
loss_streak_min_samples = max(3, settings.risk_max_consecutive_losses)
symbol_guard_enabled = settings.risk_symbol_guard_enabled
rows: list[dict[str, Any]] = []
for symbol in settings.symbols:
symbol_trades = [trade for trade in trades if str(trade.get("symbol", "")).upper() == symbol.upper()]
@@ -209,17 +214,19 @@ def _symbol_guard_stats(settings: Settings, trades: list[dict[str, Any]]) -> lis
stats = _trade_stats(recent)
losses = _consecutive_losses(recent)
reasons: list[str] = []
if stats["trades"] >= expectancy_min_samples:
if stats["profit_factor"] < settings.risk_min_recent_profit_factor and stats["avg_net_percent"] <= 0:
reasons.append("symbol_expectancy_negative")
if stats["trades"] >= loss_streak_min_samples:
if losses >= settings.risk_max_consecutive_losses:
reasons.append("symbol_consecutive_losses")
if symbol_guard_enabled:
if stats["trades"] >= expectancy_min_samples:
if stats["profit_factor"] < settings.risk_min_recent_profit_factor and stats["avg_net_percent"] <= 0:
reasons.append("symbol_expectancy_negative")
if stats["trades"] >= loss_streak_min_samples:
if losses >= settings.risk_max_consecutive_losses:
reasons.append("symbol_consecutive_losses")
rows.append(
{
"symbol": symbol.upper(),
"block_new_entries": bool(reasons),
"block_new_entries": bool(reasons) if symbol_guard_enabled else False,
"reasons": reasons,
"symbol_guard_enabled": symbol_guard_enabled,
"consecutive_losses": losses,
**stats,
}
+45 -8
View File
@@ -140,7 +140,7 @@ class CryptoSpotBot:
symbol,
"HOLD",
0.0,
"пауза после закрытия позиции",
"пауза между входами по паре",
{"cooldown_remaining_seconds": cooldown_seconds - age},
)
)
@@ -150,12 +150,17 @@ class CryptoSpotBot:
candles = self.market.candles.get(symbol, [])
trend_candles = self.market.trend_candles.get(symbol, [])
open_count = len(self.broker.positions_for_symbol(symbol))
instrument = self.market.instruments.get(symbol)
pattern = self.market.patterns.get(symbol, {})
forecast = self.market.forecasts.get(symbol, {})
learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict()
learning["adaptive_rules"] = self._with_exposure_context(learning.get("adaptive_rules") or {})
account = self.broker.account_state(prices)
account["risk_guard"] = risk_guard
account["symbol"] = symbol
account["symbol_exposure_usdt"] = self.broker.symbol_exposure(symbol)
account["open_positions_for_symbol"] = open_count
account["exchange_min_entry_usdt"] = self.broker.minimum_entry_budget(instrument, ticker)
if risk_guard.get("block_new_entries"):
self.storage.insert_signal(
Signal(
@@ -221,12 +226,14 @@ class CryptoSpotBot:
)
self.storage.insert_signal(signal)
if signal.action == "BUY" and ticker is not None:
self.broker.buy(
position = self.broker.buy(
signal,
ticker,
self.market.instruments.get(symbol),
instrument,
prices,
)
if position is not None:
self._entry_cooldown_until[symbol] = utc_now()
@staticmethod
def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict:
@@ -292,7 +299,12 @@ class CryptoSpotBot:
return worst.id
def _update_patterns(self) -> None:
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} or not self.settings.pattern_analysis_enabled:
patterns_needed = (
self.settings.pattern_analysis_enabled
or self.settings.grid_trading_enabled
or self.settings.rebound_trading_enabled
)
if self.settings.strategy_mode == "trend_macd" or not patterns_needed:
self.market.patterns = {}
return
patterns: dict[str, dict] = {}
@@ -346,10 +358,35 @@ class CryptoSpotBot:
def positions_snapshot(self) -> list[dict]:
prices = self.market.prices()
return [
position.as_dict(mark_price=prices.get(position.symbol, position.entry_price))
for position in self.broker.open_positions()
]
items: list[dict] = []
for position in self.broker.open_positions():
mark_price = prices.get(position.symbol, position.entry_price)
item = position.as_dict(mark_price=mark_price)
exit_signal = self.strategy.exit_signal(
position=position,
candles=self.market.candles.get(position.symbol, []),
ticker=self.market.tickers.get(position.symbol),
learning=self.learner.state.as_dict(),
forecast=self.market.forecasts.get(position.symbol, {}),
)
diagnostics = exit_signal.diagnostics or {}
fallback_stop_loss = position.stop_loss if self.settings.stop_loss_exit_enabled else None
item["exit_plan"] = {
"action": exit_signal.action,
"reason": exit_signal.reason,
"confidence": exit_signal.confidence,
"stop_loss": diagnostics.get("stop_loss", fallback_stop_loss),
"take_profit": diagnostics.get("take_profit", position.take_profit),
"trailing_stop": diagnostics.get("trailing_stop"),
"atr_trailing_stop": diagnostics.get("atr_trailing_stop"),
"highest_price": diagnostics.get("highest_price", position.highest_price),
"stop_loss_exit_enabled": diagnostics.get(
"stop_loss_exit_enabled",
self.settings.stop_loss_exit_enabled,
),
}
items.append(item)
return items
def learning_snapshot(self) -> dict:
snapshot = self.learner.state.as_dict()
+24 -6
View File
@@ -5,7 +5,20 @@ from dataclasses import dataclass
from pathlib import Path
FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT", "LTCUSDT")
FIXED_SPOT_SYMBOLS = (
"BTCUSDT",
"ETHUSDT",
"HYPEUSDT",
"SOLUSDT",
"XRPUSDT",
"XPLUSDT",
"WLDUSDT",
"MNTUSDT",
"HUSDT",
"XAUTUSDT",
"IPUSDT",
"AAVEUSDT",
)
STRATEGY_MODES = {"legacy", "trend_macd", "torch_forecast"}
@@ -102,6 +115,7 @@ class Settings:
kelly_max_fraction: float
risk_per_trade_percent: float
risk_guard_enabled: bool
risk_symbol_guard_enabled: bool
risk_recent_trade_window: int
risk_max_consecutive_losses: int
risk_min_recent_profit_factor: float
@@ -122,10 +136,13 @@ class Settings:
time_series_probe_min_edge_percent: float
time_series_probe_min_probability_up: float
time_series_probe_size_multiplier: float
time_series_rebound_fallback_enabled: bool
stop_loss_percent: float
stop_loss_exit_enabled: bool
take_profit_percent: float
trailing_stop_percent: float
min_hold_seconds: int
min_exit_net_percent: float
entry_cooldown_seconds: int
max_daily_drawdown_usdt: float
min_cash_reserve_usdt: float
@@ -190,11 +207,8 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
raise ValueError("STRATEGY_MODE must be legacy, trend_macd or torch_forecast")
auto_select_symbols = _bool_env("AUTO_SELECT_SYMBOLS", False)
top_symbols_count = _int_env("TOP_SYMBOLS_COUNT", len(FIXED_SPOT_SYMBOLS))
symbols = _symbols_env("SYMBOLS") or FIXED_SPOT_SYMBOLS
if strategy_mode == "torch_forecast":
auto_select_symbols = False
top_symbols_count = len(FIXED_SPOT_SYMBOLS)
symbols = FIXED_SPOT_SYMBOLS
requested_symbols = _symbols_env("SYMBOLS")
symbols = requested_symbols if requested_symbols else (() if auto_select_symbols else FIXED_SPOT_SYMBOLS)
forecast_enabled_default = strategy_mode == "torch_forecast"
min_signal_confidence = _float_env("MIN_SIGNAL_CONFIDENCE", 0.64)
settings = Settings(
@@ -254,6 +268,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
kelly_max_fraction=_float_env("KELLY_MAX_FRACTION", 0.20),
risk_per_trade_percent=_float_env("RISK_PER_TRADE_PERCENT", 0.01),
risk_guard_enabled=_bool_env("RISK_GUARD_ENABLED", True),
risk_symbol_guard_enabled=_bool_env("RISK_SYMBOL_GUARD_ENABLED", True),
risk_recent_trade_window=_int_env("RISK_RECENT_TRADE_WINDOW", 20),
risk_max_consecutive_losses=_int_env("RISK_MAX_CONSECUTIVE_LOSSES", 4),
risk_min_recent_profit_factor=_float_env("RISK_MIN_RECENT_PROFIT_FACTOR", 0.85),
@@ -274,10 +289,13 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
time_series_probe_min_edge_percent=_float_env("TIME_SERIES_PROBE_MIN_EDGE_PERCENT", 0.02),
time_series_probe_min_probability_up=_float_env("TIME_SERIES_PROBE_MIN_PROBABILITY_UP", 0.55),
time_series_probe_size_multiplier=_float_env("TIME_SERIES_PROBE_SIZE_MULTIPLIER", 0.40),
time_series_rebound_fallback_enabled=_bool_env("TIME_SERIES_REBOUND_FALLBACK_ENABLED", True),
stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.04),
stop_loss_exit_enabled=_bool_env("STOP_LOSS_EXIT_ENABLED", True),
take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035),
trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015),
min_hold_seconds=_int_env("MIN_HOLD_SECONDS", 180),
min_exit_net_percent=_float_env("MIN_EXIT_NET_PERCENT", 0.20),
entry_cooldown_seconds=_int_env("ENTRY_COOLDOWN_SECONDS", 180),
max_daily_drawdown_usdt=_float_env("MAX_DAILY_DRAWDOWN_USDT", 6.0),
min_cash_reserve_usdt=_float_env("MIN_CASH_RESERVE_USDT", 5.0),
+60 -714
View File
@@ -4,8 +4,8 @@ import json
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, Response
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import JSONResponse, PlainTextResponse
from crypto_spot_bot.analytics import analytics_snapshot
from crypto_spot_bot.bot import CryptoSpotBot
@@ -19,6 +19,10 @@ from crypto_spot_bot.reconciliation import reconciliation_snapshot
from crypto_spot_bot.storage import Storage
from crypto_spot_bot.strategy import SpotStrategy
from crypto_spot_bot.time_series import TimeSeriesForecaster
from crypto_spot_bot.training_coordination import TrainingCoordinator
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
def create_app(settings: Settings | None = None) -> FastAPI:
@@ -39,6 +43,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
learner = TradeLearner(settings, storage)
forecaster = TimeSeriesForecaster(settings)
bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster)
training = TrainingCoordinator(settings.time_series_lstm_model_path.parent)
@asynccontextmanager
async def lifespan(_: FastAPI):
@@ -53,10 +58,11 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.storage = storage
app.state.bot = bot
app.state.market = market
app.state.training = training
@app.get("/", response_class=HTMLResponse)
@app.get("/", response_class=PlainTextResponse, status_code=410)
async def index() -> str:
return HTML
return WEB_UI_REMOVED_MESSAGE
@app.get("/api/health")
async def health() -> dict[str, Any]:
@@ -78,7 +84,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/api/trades")
async def trades(limit: int = 80) -> dict[str, Any]:
return {"items": storage.recent_trades(_limit(limit))}
row_limit = _limit(limit)
return {
"items": storage.recent_trades(row_limit),
"closed_items": storage.closed_trades(row_limit),
"closed_summary": storage.closed_trade_summary(),
}
@app.get("/api/signals")
async def signals(limit: int = 120) -> dict[str, Any]:
@@ -111,7 +122,46 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/api/retrain")
async def retrain() -> dict[str, Any]:
return _runtime_json(settings, "torch_retrain_guard.json")
data = _runtime_json(settings, "torch_retrain_guard.json")
data["coordination"] = training.status()
return data
@app.get("/api/training/status")
async def training_status() -> dict[str, Any]:
return training.status()
@app.post("/api/training/retrain")
async def training_retrain(payload: dict[str, Any] | None = None) -> dict[str, Any]:
return training.request_retrain(payload)
@app.post("/api/training/heartbeat")
async def training_heartbeat(payload: dict[str, Any] | None = None) -> dict[str, Any]:
return training.heartbeat(payload)
@app.post("/api/training/claim")
async def training_claim(payload: dict[str, Any] | None = None) -> dict[str, Any]:
return training.claim(payload)
@app.post("/api/training/jobs/{job_id}/artifacts/chunk")
async def training_artifact_chunk(job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
try:
return training.save_artifact_chunk(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/training/jobs/{job_id}/progress")
async def training_progress(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
return training.progress(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/training/jobs/{job_id}/complete")
async def training_complete(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
return training.complete(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/api/config")
async def config() -> dict[str, Any]:
@@ -245,6 +295,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
"kelly_max_fraction": settings.kelly_max_fraction,
"risk_per_trade_percent": settings.risk_per_trade_percent,
"risk_guard_enabled": settings.risk_guard_enabled,
"risk_symbol_guard_enabled": settings.risk_symbol_guard_enabled,
"risk_recent_trade_window": settings.risk_recent_trade_window,
"risk_max_consecutive_losses": settings.risk_max_consecutive_losses,
"risk_min_recent_profit_factor": settings.risk_min_recent_profit_factor,
@@ -265,11 +316,14 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
"time_series_probe_min_edge_percent": settings.time_series_probe_min_edge_percent,
"time_series_probe_min_probability_up": settings.time_series_probe_min_probability_up,
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
"time_series_model_artifact": _time_series_model_artifact(settings),
"stop_loss_percent": settings.stop_loss_percent,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"take_profit_percent": settings.take_profit_percent,
"trailing_stop_percent": settings.trailing_stop_percent,
"min_hold_seconds": settings.min_hold_seconds,
"min_exit_net_percent": settings.min_exit_net_percent,
"entry_cooldown_seconds": settings.entry_cooldown_seconds,
"max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt,
"min_cash_reserve_usdt": settings.min_cash_reserve_usdt,
@@ -389,711 +443,3 @@ def _forecast_model_label(model: str, *, torch_artifact: bool = False) -> str:
if normalized == "gru":
return "устаревший артефакт"
return model
HTML = r"""
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TradeBot Control</title>
<style>
:root {
--bg: #f4f6f8;
--surface: #ffffff;
--surface-2: #eef2f5;
--line: #d7dee6;
--text: #17202a;
--muted: #607080;
--strong: #0f766e;
--blue: #2563eb;
--amber: #b7791f;
--red: #c24141;
--green: #138a55;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
header {
position: sticky;
top: 0;
z-index: 20;
background: rgba(244, 246, 248, 0.96);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(10px);
}
.topbar {
max-width: 1440px;
margin: 0 auto;
padding: 14px 18px;
display: grid;
grid-template-columns: minmax(180px, 1fr) auto;
gap: 14px;
align-items: center;
}
h1 { margin: 0; font-size: 20px; line-height: 1.2; }
.sub { color: var(--muted); font-size: 13px; margin-top: 3px; }
.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
button, .toggle {
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
height: 34px;
padding: 0 12px;
border-radius: 6px;
cursor: pointer;
font-weight: 650;
font-size: 13px;
}
button.primary { background: var(--strong); color: white; border-color: var(--strong); }
button.danger { color: var(--red); }
.toggle { display: inline-flex; align-items: center; gap: 8px; }
.toggle input { margin: 0; }
nav {
max-width: 1440px;
margin: 0 auto;
padding: 0 18px 12px;
display: flex;
gap: 6px;
overflow-x: auto;
}
.tab {
min-width: max-content;
height: 32px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
}
.tab.active { background: var(--surface); color: var(--text); border-color: var(--line); }
main {
max-width: 1440px;
margin: 0 auto;
padding: 18px;
}
.grid { display: grid; gap: 14px; }
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
section.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.panel-head {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.panel-head h2 { margin: 0; font-size: 15px; }
.panel-body { padding: 14px; }
.metric {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
min-height: 82px;
}
.metric .label { color: var(--muted); font-size: 12px; }
.metric .value { font-size: 22px; font-weight: 750; margin-top: 6px; overflow-wrap: anywhere; }
.metric .note { color: var(--muted); font-size: 12px; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 9px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: var(--muted); font-weight: 700; background: var(--surface-2); }
tr:last-child td { border-bottom: 0; }
.mono { font-variant-numeric: tabular-nums; }
.positive { color: var(--green); font-weight: 700; }
.negative { color: var(--red); font-weight: 700; }
.muted { color: var(--muted); }
.pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--text);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.pill.ok { color: var(--green); border-color: rgba(19, 138, 85, .35); background: #edf8f2; }
.pill.warn { color: var(--amber); border-color: rgba(183, 121, 31, .35); background: #fff7e6; }
.pill.error { color: var(--red); border-color: rgba(194, 65, 65, .35); background: #fff0f0; }
.stack { display: flex; flex-direction: column; gap: 8px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.kv { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 8px; font-size: 13px; padding: 6px 0; border-bottom: 1px solid var(--line); }
.kv:last-child { border-bottom: 0; }
.kv span:first-child { color: var(--muted); }
.feature-list { display: grid; gap: 6px; }
.feature {
display: grid;
grid-template-columns: minmax(130px, 1fr) 90px 90px;
gap: 8px;
align-items: center;
padding: 7px 8px;
border: 1px solid var(--line);
border-radius: 6px;
background: #fbfcfd;
font-size: 12px;
}
.hidden { display: none; }
.empty { color: var(--muted); padding: 18px; text-align: center; }
@media (max-width: 980px) {
.topbar { grid-template-columns: 1fr; }
.actions { justify-content: flex-start; }
.cols-2, .cols-3, .cols-4 { grid-template-columns: 1fr; }
.kv { grid-template-columns: 1fr; }
.feature { grid-template-columns: 1fr 80px 80px; }
th, td { padding: 8px 6px; }
}
</style>
</head>
<body>
<header>
<div class="topbar">
<div>
<h1>TradeBot</h1>
<div class="sub" id="headline">Загрузка</div>
</div>
<div class="actions">
<span id="healthPill" class="pill">health</span>
<label class="toggle"><input id="fastToggle" type="checkbox" /> Fast</label>
<button id="startBtn" class="primary">Start</button>
<button id="stopBtn" class="danger">Stop</button>
</div>
</div>
<nav id="tabs"></nav>
</header>
<main id="app"></main>
<script>
const TABS = [
['overview', 'Overview'],
['markets', 'Markets / Torch'],
['risk', 'Risk / Quality'],
['pnl', 'PnL'],
['backtest', 'Backtest / Retrain'],
['logs', 'Logs']
];
const state = { tab: 'overview', data: {} };
function initTabs() {
const nav = document.getElementById('tabs');
nav.innerHTML = TABS.map(([id, label]) => `<button class="tab ${id === state.tab ? 'active' : ''}" data-tab="${id}">${label}</button>`).join('');
nav.querySelectorAll('.tab').forEach(button => {
button.addEventListener('click', () => {
state.tab = button.dataset.tab;
initTabs();
render();
});
});
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`${url}: ${response.status}`);
return response.json();
}
async function refresh() {
const [health, status, markets, trades, signals, events, config, analytics, reconciliation, backtest, retrain] = await Promise.all([
fetchJson('/api/health'),
fetchJson('/api/status'),
fetchJson('/api/markets'),
fetchJson('/api/trades?limit=120'),
fetchJson('/api/signals?limit=160'),
fetchJson('/api/events?limit=120'),
fetchJson('/api/config'),
fetchJson('/api/analytics'),
fetchJson('/api/reconciliation'),
fetchJson('/api/backtest'),
fetchJson('/api/retrain')
]);
state.data = { health, status, markets, trades, signals, events, config, analytics, reconciliation, backtest, retrain };
renderChrome();
render();
}
function renderChrome() {
const { health, status, config } = state.data;
const botStatus = status?.status || {};
const account = status?.account || {};
document.getElementById('headline').textContent =
`${botStatus.mode || health?.mode || 'paper'} · ${botStatus.running ? 'running' : 'stopped'} · equity ${money(account.equity)} · ${config?.symbols?.join(', ') || ''}`;
const pill = document.getElementById('healthPill');
pill.textContent = health?.ok ? 'OK' : 'ERROR';
pill.className = `pill ${health?.ok ? 'ok' : 'error'}`;
document.getElementById('fastToggle').checked = Boolean(config?.fast_trading_enabled);
}
function render() {
const root = document.getElementById('app');
const tab = state.tab;
if (tab === 'overview') root.innerHTML = overviewHtml();
if (tab === 'markets') root.innerHTML = marketsHtml();
if (tab === 'risk') root.innerHTML = riskHtml();
if (tab === 'pnl') root.innerHTML = pnlHtml();
if (tab === 'backtest') root.innerHTML = backtestHtml();
if (tab === 'logs') root.innerHTML = logsHtml();
drawCanvases();
}
function overviewHtml() {
const { status, config, analytics } = state.data;
const account = status?.account || {};
const bot = status?.status || {};
const risk = analytics?.risk_guard || {};
const artifact = config?.time_series_model_artifact || {};
return `
<div class="grid cols-4">
${metric('Equity', money(account.equity), signed(account.net_pnl_percent, 3) + '% net')}
${metric('Cash', money(account.cash), `exposure ${money(account.exposure)}`)}
${metric('Open positions', String(status?.positions?.length || 0), `drawdown ${money(account.drawdown)}`)}
${metric('Risk guard', risk.enabled ? (risk.block_new_entries ? 'BLOCK' : `${num((risk.position_size_multiplier ?? 1) * 100, 0)}% size`) : 'off', (risk.reasons || []).join(', ') || 'normal')}
</div>
<div class="grid cols-2" style="margin-top:14px">
${panel('Runtime', kvTable([
['Mode', bot.mode || ''],
['Running', bot.running ? 'yes' : 'no'],
['Message', bot.message || ''],
['Last loop', dt(bot.last_loop_at)],
['Fast loop', config?.fast_trading_enabled ? `${num(config.effective_loop_interval_seconds, 2)}s` : 'off'],
['Strategy', config?.strategy_mode || '']
]))}
${panel('Torch model', kvTable([
['Artifact', artifact.label || artifact.type || ''],
['Created', dt(artifact.created_at)],
['Models', (artifact.models || []).join(', ')],
['Symbols', String(artifact.symbol_count ?? 0)],
['Features', String(artifact.feature_count ?? '')],
['Horizon', String(artifact.target_horizon ?? '')],
['Min edge', `${num(config?.time_series_min_edge_percent, 3)}%`],
['Min P(up)', `${num((config?.time_series_min_probability_up || 0) * 100, 1)}%`],
['Min confidence', num(config?.time_series_min_confidence, 3)],
['Probe entry', config?.time_series_probe_enabled ? `${num(config?.time_series_probe_min_edge_percent, 3)}% / P ${num((config?.time_series_probe_min_probability_up || 0) * 100, 1)}% / size ${num((config?.time_series_probe_size_multiplier || 0) * 100, 0)}%` : 'off']
]))}
</div>
${positionsPanel()}
`;
}
function marketsHtml() {
const markets = state.data.markets?.markets || [];
const latestSignals = latestSignalBySymbol();
if (!markets.length) return empty('Нет market data');
return `<div class="grid">${markets.map(market => marketPanel(market, latestSignals)).join('')}</div>`;
}
function marketPanel(market, latestSignals) {
const ticker = market.ticker || {};
const symbol = ticker.symbol || market.instrument?.symbol || '';
const forecast = market.forecast || {};
const quality = market.quality || {};
const signal = latestSignals[symbol] || {};
const minEdge = state.data.config?.time_series_min_edge_percent ?? 0;
const probeEnabled = Boolean(state.data.config?.time_series_probe_enabled);
const probeEdge = state.data.config?.time_series_probe_min_edge_percent ?? 0;
const probeProbability = state.data.config?.time_series_probe_min_probability_up ?? 0;
const minConfidence = state.data.config?.time_series_min_confidence ?? 0;
const diagnostics = parseDiagnostics(signal);
const failed = Object.entries(diagnostics.checks || {}).filter(([, ok]) => !ok).map(([key]) => key);
return panel(
`<span>${escapeHtml(symbol)}</span><span class="pill ${qualityClass(quality.status)}">${quality.status || 'n/a'} ${num((quality.score || 0) * 100, 0)}%</span>`,
`<div class="grid cols-3">
${kvTable([
['Price', money(ticker.last_price, 6)],
['24h', signed(ticker.change_24h, 2) + '%'],
['Spread', num(ticker.spread_percent, 4) + '%'],
['Signal', `${signal.action || ''} ${num(signal.confidence, 4)}`],
['Reason', signal.reason || '']
])}
${kvTable([
['Model', modelName(forecast.model)],
['Edge', `${signed(forecast.expected_return_percent, 4)}% / min ${num(minEdge, 3)}%`],
['P(up)', num((forecast.probability_up || 0) * 100, 2) + '%'],
['Probe', probeEnabled ? `${num(probeEdge, 3)}% / P ${num(probeProbability * 100, 1)}% / ${diagnostics.edge_mode || 'n/a'}` : 'off'],
['Confidence', `${num(signal.confidence, 4)} / min ${num(minConfidence, 2)}`],
['Q10/Q50/Q90', `${signed(forecast.quantile_10_percent, 2)} / ${signed(forecast.quantile_50_percent, 2)} / ${signed(forecast.quantile_90_percent, 2)}`],
['Blocked', forecast.block_entry ? 'yes' : 'no']
])}
<div>
<canvas id="chart-${escapeAttr(symbol)}" height="120"></canvas>
<div class="row" style="margin-top:8px">${failed.map(item => `<span class="pill warn">${escapeHtml(item)}</span>`).join('') || '<span class="pill ok">checks ok</span>'}</div>
</div>
</div>
<div style="margin-top:12px">${featuresHtml(forecast)}</div>`
);
}
function riskHtml() {
const { analytics, markets, reconciliation } = state.data;
const risk = analytics?.risk_guard || {};
const drift = analytics?.drift || {};
const quality = markets?.quality || {};
return `
<div class="grid cols-3">
${metric('Risk guard', risk.enabled ? (risk.block_new_entries ? 'BLOCK' : 'ACTIVE') : 'OFF', (risk.reasons || []).join(', ') || 'normal')}
${metric('Drift', drift.status || 'n/a', (drift.warnings || []).join(', ') || 'normal')}
${metric('Data quality', `${num((quality.score || 0) * 100, 0)}%`, quality.status || '')}
</div>
<div class="grid cols-2" style="margin-top:14px">
${panel('Risk guard', kvTable([
['Block entries', risk.block_new_entries ? 'yes' : 'no'],
['Size multiplier', num(risk.position_size_multiplier, 4)],
['Blocked symbols', (risk.blocked_symbols || []).join(', ') || 'none'],
['Consecutive losses', String(risk.consecutive_losses ?? 0)],
['Today PnL', money(risk.today_pnl)],
['Recent PF', num(risk.recent?.profit_factor, 3)],
['Recent avg', signed(risk.recent?.avg_net_percent, 4) + '%']
]))}
${panel('Reconciliation', kvTable([
['Status', reconciliation?.status || ''],
['Live ready', reconciliation?.live_ready ? 'yes' : 'no'],
['Discrepancies', String(reconciliation?.discrepancies?.length || 0)],
['Remote equity', money(reconciliation?.account?.total_equity)]
]) + discrepanciesHtml(reconciliation?.discrepancies || []))}
</div>
${panel('Risk by symbol', symbolRiskTable(risk.symbols || []))}
${panel('Data quality by symbol', qualityTable(quality.symbols || []))}
${panel('Probability calibration', calibrationTable(analytics?.probability_calibration?.buckets || []))}
${panel('Failed checks', simpleTable(Object.entries(drift.failed_checks || {}).map(([key, value]) => ({ check: key, count: value })), ['check', 'count']))}
`;
}
function pnlHtml() {
const pnl = state.data.analytics?.pnl || {};
return `
<div class="grid cols-4">
${metric('Closed trades', String(pnl.total?.trades || 0), `${num((pnl.total?.win_rate || 0) * 100, 1)}% win`)}
${metric('Net PnL', money(pnl.total?.net_pnl), `${signed(pnl.total?.avg_net_percent, 4)}% avg`)}
${metric('Fees', money(pnl.total?.fees), `PF ${num(pnl.total?.profit_factor, 3)}`)}
${metric('Worst / Best', `${money(pnl.total?.worst)} / ${money(pnl.total?.best)}`, '')}
</div>
<div class="grid cols-3" style="margin-top:14px">
${panel('By symbol', statsTable(pnl.by_symbol || []))}
${panel('By exit', statsTable(pnl.by_exit || []))}
${panel('By model', statsTable(pnl.by_model || []))}
</div>
${panel('Recent closed trades', simpleTable((pnl.recent || []).map(row => ({
id: row.id,
symbol: row.symbol,
net: money(row.net_pnl),
net_percent: signed(row.net_percent, 3) + '%',
p_up: row.entry_probability == null ? '' : num(row.entry_probability * 100, 2) + '%',
expected: row.entry_expected_percent == null ? '' : signed(row.entry_expected_percent, 3) + '%',
exit: row.exit_category,
closed: dt(row.closed_at)
})), ['id', 'symbol', 'net', 'net_percent', 'p_up', 'expected', 'exit', 'closed']))}
`;
}
function backtestHtml() {
const backtest = state.data.backtest || {};
const retrain = state.data.retrain || {};
const rec = backtest.recommended || {};
const replay = backtest.full_replay || {};
const walk = backtest.walk_forward?.summary || {};
const validation = backtest.validation || {};
const benchmark = backtest.benchmark?.summary || {};
return `
<div class="grid cols-4">
${metric('Recommended edge', `${num(rec.edge, 4)}%`, `P(up) ${num((rec.probability || 0) * 100, 1)}%`)}
${metric('Entry replay', `${replay.trades || 0} trades`, `${signed(replay.avg_net_percent, 4)}% avg · PF ${num(replay.profit_factor, 3)}`)}
${metric('Walk-forward', `${walk.trades || 0} trades`, `${signed(walk.avg_net_percent, 4)}% avg · ${walk.status || ''}`)}
${metric('Quality gate', validation.status || 'missing', validation.passed ? 'Torch allowed' : 'Torch blocked')}
</div>
<div class="grid cols-2" style="margin-top:14px">
${panel('Threshold calibration', kvTable([
['Edge', num(rec.edge, 4) + '%'],
['P(up)', num((rec.probability || 0) * 100, 2) + '%'],
['Confidence', num(rec.confidence, 4)],
['Trades', String(rec.trades || 0)],
['Win rate', num((rec.win_rate || 0) * 100, 2) + '%'],
['Avg net', signed(rec.average_net_percent, 4) + '%'],
['Profit factor', num(rec.profit_factor, 4)]
]))}
${panel('Full replay', kvTable([
['Trades', String(replay.trades || 0)],
['Win rate', num((replay.win_rate || 0) * 100, 2) + '%'],
['Avg net', signed(replay.avg_net_percent, 4) + '%'],
['Total net', signed(replay.total_net_percent, 4) + '%'],
['Drawdown', num(replay.max_drawdown_percent, 4) + '%'],
['Profit factor', num(replay.profit_factor, 4)]
]))}
</div>
<div class="grid cols-2" style="margin-top:14px">
${panel('Honest validation', kvTable([
['Status', validation.status || 'missing'],
['OOS trades', String(validation.oos_summary?.trades || 0)],
['OOS avg net', signed(validation.oos_summary?.avg_net_percent, 4) + '%'],
['OOS PF', num(validation.oos_summary?.profit_factor, 4)],
['Folds with trades', String(backtest.walk_forward?.folds_with_trades || 0)],
['Benchmark total', signed(benchmark.total_net_percent, 4) + '%'],
['Benchmark trades', String(benchmark.trades || 0)],
['Retrain guard', retrain.available ? (retrain.accepted ? 'accepted' : 'rejected') : 'no report']
]))}
${panel('Gate checks', simpleTable((validation.checks || []).map(row => ({
check: row.name,
value: row.value,
required: row.required,
passed: row.passed ? 'yes' : 'no'
})), ['check', 'value', 'required', 'passed']))}
</div>
${panel('OOS by symbol', simpleTable((backtest.walk_forward?.symbol_breakdown || []).map(row => ({
symbol: row.symbol,
trades: row.trades,
share: num((row.trade_share || 0) * 100, 1) + '%',
avg: signed(row.avg_net_percent, 4) + '%',
total: signed(row.total_net_percent, 4) + '%',
pf: num(row.profit_factor, 3)
})), ['symbol', 'trades', 'share', 'avg', 'total', 'pf']))}
${panel('Walk-forward folds', simpleTable((backtest.walk_forward?.folds || []).map(row => ({
fold: row.fold,
train: row.train_records,
test: row.test_records,
edge: num(row.thresholds?.edge, 3),
p_up: num((row.thresholds?.probability || 0) * 100, 1) + '%',
trades: row.test?.trades || 0,
avg: signed(row.test?.avg_net_percent, 4) + '%',
pf: num(row.test?.profit_factor, 3)
})), ['fold', 'train', 'test', 'edge', 'p_up', 'trades', 'avg', 'pf']))}
`;
}
function logsHtml() {
const signals = state.data.signals?.items || [];
const events = state.data.events?.items || [];
return `
${panel('Signals', simpleTable(signals.slice(0, 80).map(signal => ({
id: signal.id,
symbol: signal.symbol,
action: signal.action,
confidence: num(signal.confidence, 4),
reason: signal.reason,
created: dt(signal.created_at)
})), ['id', 'symbol', 'action', 'confidence', 'reason', 'created']))}
${panel('Events', simpleTable(events.slice(0, 80).map(event => ({
id: event.id,
level: event.level,
message: event.message,
created: dt(event.created_at)
})), ['id', 'level', 'message', 'created']))}
`;
}
function positionsPanel() {
const positions = state.data.status?.positions || [];
return panel('Open positions', simpleTable(positions.map(position => ({
id: position.id,
symbol: position.symbol,
qty: num(position.qty, 8),
entry: money(position.entry_price, 6),
mark: money(position.mark_price, 6),
pnl: money(position.unrealized_pnl),
pnl_percent: signed(position.unrealized_pnl_percent, 3) + '%',
opened: dt(position.opened_at)
})), ['id', 'symbol', 'qty', 'entry', 'mark', 'pnl', 'pnl_percent', 'opened']));
}
function qualityTable(rows) {
return simpleTable(rows.map(row => ({
symbol: row.symbol,
status: row.status,
score: num((row.score || 0) * 100, 1) + '%',
candles: row.candle_count,
issues: (row.issues || []).map(issue => issue.code).join(', ') || 'none'
})), ['symbol', 'status', 'score', 'candles', 'issues']);
}
function symbolRiskTable(rows) {
return simpleTable(rows.map(row => ({
symbol: row.symbol,
block: row.block_new_entries ? 'yes' : 'no',
trades: row.trades,
win: num((row.win_rate || 0) * 100, 1) + '%',
avg: signed(row.avg_net_percent, 3) + '%',
pf: num(row.profit_factor, 3),
losses: row.consecutive_losses,
reasons: (row.reasons || []).join(', ') || 'none'
})), ['symbol', 'block', 'trades', 'win', 'avg', 'pf', 'losses', 'reasons']);
}
function calibrationTable(rows) {
return simpleTable(rows.map(row => ({
bucket: row.bucket,
trades: row.trades ?? row.samples,
predicted: num((row.avg_probability || 0) * 100, 2) + '%',
actual: num((row.actual_win_rate || 0) * 100, 2) + '%',
error: signed((row.calibration_error || 0) * 100, 2) + '%',
avg: row.avg_net_percent == null ? signed(row.avg_future_net_percent, 4) + '%' : signed(row.avg_net_percent, 4) + '%'
})), ['bucket', 'trades', 'predicted', 'actual', 'error', 'avg']);
}
function statsTable(rows) {
return simpleTable(rows.map(row => ({
key: row.key,
trades: row.trades,
win: num((row.win_rate || 0) * 100, 1) + '%',
net: money(row.net_pnl),
avg: signed(row.avg_net_percent, 3) + '%',
pf: num(row.profit_factor, 3)
})), ['key', 'trades', 'win', 'net', 'avg', 'pf']);
}
function discrepanciesHtml(rows) {
if (!rows.length) return '<div class="row"><span class="pill ok">no discrepancies</span></div>';
return `<div class="row">${rows.map(row => `<span class="pill ${qualityClass(row.severity)}">${escapeHtml(row.code)} ${escapeHtml(row.coin || '')}</span>`).join('')}</div>`;
}
function featuresHtml(forecast) {
const items = (forecast.feature_snapshot || [])
.slice()
.sort((a, b) => Math.abs(Number(b.model_value || 0)) - Math.abs(Number(a.model_value || 0)))
.slice(0, 8);
if (!items.length) return '<div class="empty">Нет feature snapshot</div>';
return `<div class="feature-list">${items.map(item => `
<div class="feature">
<div><b>${escapeHtml(item.label || item.name)}</b><div class="muted">${escapeHtml(item.group || '')}</div></div>
<div class="mono">${escapeHtml(item.raw_display ?? item.raw_value ?? '')}</div>
<div class="mono ${Number(item.model_value || 0) >= 0 ? 'positive' : 'negative'}">${escapeHtml(item.model_display ?? item.model_value ?? '')}</div>
</div>`).join('')}</div>`;
}
function panel(title, body) {
return `<section class="panel"><div class="panel-head"><h2>${title}</h2></div><div class="panel-body">${body}</div></section>`;
}
function metric(label, value, note) {
return `<div class="metric"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value ?? '')}</div><div class="note">${escapeHtml(note ?? '')}</div></div>`;
}
function kvTable(rows) {
return `<div>${rows.map(([key, value]) => `<div class="kv"><span>${escapeHtml(key)}</span><span>${escapeHtml(value ?? '')}</span></div>`).join('')}</div>`;
}
function simpleTable(rows, columns) {
if (!rows.length) return '<div class="empty">Нет данных</div>';
return `<div style="overflow:auto"><table><thead><tr>${columns.map(column => `<th>${escapeHtml(column)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${columns.map(column => `<td>${escapeHtml(row[column] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
}
function latestSignalBySymbol() {
const output = {};
for (const signal of state.data.signals?.items || []) {
if (signal.symbol && !output[signal.symbol]) output[signal.symbol] = signal;
}
return output;
}
function parseDiagnostics(signal) {
if (!signal?.diagnostics_json) return {};
try { return JSON.parse(signal.diagnostics_json); } catch { return {}; }
}
function drawCanvases() {
for (const market of state.data.markets?.markets || []) {
const symbol = market.ticker?.symbol || market.instrument?.symbol || '';
const canvas = document.getElementById(`chart-${symbol}`);
if (!canvas) continue;
drawChart(canvas, market.candles || []);
}
}
function drawChart(canvas, candles) {
const ctx = canvas.getContext('2d');
const width = canvas.clientWidth || 320;
const height = canvas.height;
canvas.width = width * devicePixelRatio;
canvas.height = height * devicePixelRatio;
ctx.scale(devicePixelRatio, devicePixelRatio);
ctx.clearRect(0, 0, width, height);
const rows = candles.slice(-80);
if (rows.length < 2) return;
const highs = rows.map(row => Number(row.high));
const lows = rows.map(row => Number(row.low));
const min = Math.min(...lows);
const max = Math.max(...highs);
const range = Math.max(1e-9, max - min);
ctx.lineWidth = 1.4;
rows.forEach((row, index) => {
const x = 6 + index * ((width - 12) / Math.max(1, rows.length - 1));
const yOpen = height - 6 - ((row.open - min) / range) * (height - 12);
const yClose = height - 6 - ((row.close - min) / range) * (height - 12);
const yHigh = height - 6 - ((row.high - min) / range) * (height - 12);
const yLow = height - 6 - ((row.low - min) / range) * (height - 12);
ctx.strokeStyle = Number(row.close) >= Number(row.open) ? '#138a55' : '#c24141';
ctx.beginPath();
ctx.moveTo(x, yHigh);
ctx.lineTo(x, yLow);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x - 2, yOpen);
ctx.lineTo(x + 2, yClose);
ctx.stroke();
});
}
function modelName(value) {
if (value === 'torch_lstm') return 'PyTorch LSTM';
if (value === 'torch_gru') return 'PyTorch GRU';
return value || '';
}
function qualityClass(value) {
if (value === 'ok') return 'ok';
if (value === 'error') return 'error';
return 'warn';
}
function money(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${n.toFixed(digits)} USDT`;
}
function num(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return n.toFixed(digits);
}
function signed(value, digits = 2) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return `${n >= 0 ? '+' : ''}${n.toFixed(digits)}`;
}
function dt(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString('ru-RU', { hour12: false });
}
function empty(text) { return `<div class="empty">${escapeHtml(text)}</div>`; }
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
}
function escapeAttr(value) { return escapeHtml(value).replace(/[^A-Za-z0-9_-]/g, ''); }
document.getElementById('startBtn').addEventListener('click', async () => { await fetchJson('/api/control/start', { method: 'POST' }); await refresh(); });
document.getElementById('stopBtn').addEventListener('click', async () => { await fetchJson('/api/control/stop', { method: 'POST' }); await refresh(); });
document.getElementById('fastToggle').addEventListener('change', async event => {
await fetchJson('/api/config/fast-trading', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: event.target.checked }) });
await refresh();
});
initTabs();
refresh().catch(error => { document.getElementById('headline').textContent = error.message; });
setInterval(() => refresh().catch(error => { document.getElementById('headline').textContent = error.message; }), 5000);
</script>
</body>
</html>
"""
+94 -14
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections import deque
from datetime import timedelta
from decimal import Decimal, ROUND_DOWN
from decimal import Decimal, ROUND_DOWN, ROUND_UP
from typing import Iterable
from uuid import uuid4
@@ -25,6 +25,15 @@ def _round_step(value: float, step: float) -> float:
return float(rounded * step_decimal)
def _round_step_up(value: float, step: float) -> float:
if step <= 0:
return value
value_decimal = Decimal(str(value))
step_decimal = Decimal(str(step))
rounded = (value_decimal / step_decimal).to_integral_value(rounding=ROUND_UP)
return float(rounded * step_decimal)
class PaperBroker:
def __init__(self, settings: Settings, storage: Storage):
self.settings = settings
@@ -92,12 +101,9 @@ class PaperBroker:
return False, "достигнут лимит новых входов в минуту"
if len(self.positions) >= self.settings.max_open_positions:
return False, "достигнут общий лимит открытых позиций"
if self.settings.strategy_mode in {"trend_macd", "torch_forecast"} and len(self.positions_for_symbol(symbol)) >= 1:
if self.settings.strategy_mode == "trend_macd" and len(self.positions_for_symbol(symbol)) >= 1:
return False, "DCA/усреднение отключено: позиция по паре уже открыта"
dynamic_pair_limit = max(
self.settings.max_positions_per_symbol,
int(self.settings.max_symbol_exposure_usdt // max(self.settings.min_position_usdt, 0.01)),
)
dynamic_pair_limit = _symbol_position_limit(self.settings)
if len(self.positions_for_symbol(symbol)) >= dynamic_pair_limit:
return False, "достигнут лимит позиций по паре"
requested = requested_notional if requested_notional is not None else self.settings.min_position_usdt
@@ -136,12 +142,15 @@ class PaperBroker:
) -> Position | None:
fill_price = self._buy_price(ticker)
notional = self._entry_budget(signal, ticker)
if notional < self.settings.min_position_usdt:
minimum_budget = self._minimum_entry_budget(instrument, fill_price)
budget = self._entry_budget(signal, ticker, minimum_notional=minimum_budget)
if budget < max(self.settings.min_position_usdt, minimum_budget):
self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN")
return None
notional = notional / (1 + self.settings.taker_fee_rate)
notional = budget / (1 + self.settings.taker_fee_rate)
qty = _round_step(notional / fill_price, instrument.qty_step if instrument else 0)
if instrument:
qty = self._raise_qty_to_exchange_minimum(qty, fill_price, instrument, budget)
if instrument and qty < instrument.min_order_qty:
self.storage.event(f"{ticker.symbol}: количество ниже minOrderQty Bybit", "WARN")
return None
@@ -269,14 +278,66 @@ class PaperBroker:
value = default
return max(low, min(high, value))
def _entry_budget(self, signal: Signal, ticker: Ticker, extra_cap: float | None = None) -> float:
def minimum_entry_budget(self, instrument: Instrument | None, ticker: Ticker | None = None) -> float:
fill_price = self._buy_price(ticker) if ticker is not None else None
return self._minimum_entry_budget(instrument, fill_price)
def _minimum_entry_budget(self, instrument: Instrument | None, fill_price: float | None = None) -> float:
minimum = max(0.0, self.settings.min_position_usdt)
if instrument:
exchange_notional = max(0.0, instrument.min_notional_value)
if fill_price and fill_price > 0:
minimum_qty = max(0.0, instrument.min_order_qty)
if exchange_notional > 0:
minimum_qty = max(
minimum_qty,
_round_step_up(exchange_notional / fill_price, instrument.qty_step),
)
if minimum_qty > 0:
exchange_notional = max(exchange_notional, minimum_qty * fill_price)
if exchange_notional > 0:
exchange_minimum = exchange_notional * (1 + self.settings.taker_fee_rate) * 1.002 + 0.01
minimum = max(minimum, exchange_minimum)
return minimum
def _raise_qty_to_exchange_minimum(
self,
qty: float,
fill_price: float,
instrument: Instrument,
budget: float,
) -> float:
minimum_qty = max(0.0, instrument.min_order_qty)
if instrument.min_notional_value > 0 and fill_price > 0:
minimum_qty = max(
minimum_qty,
_round_step_up(instrument.min_notional_value / fill_price, instrument.qty_step),
)
if minimum_qty <= qty:
return qty
minimum_cost = minimum_qty * fill_price * (1 + self.settings.taker_fee_rate)
if minimum_cost <= budget + 1e-9:
return minimum_qty
return qty
def _entry_budget(
self,
signal: Signal,
ticker: Ticker,
extra_cap: float | None = None,
minimum_notional: float = 0.0,
) -> float:
available = max(0.0, self.cash - self.settings.min_cash_reserve_usdt)
rules = signal.diagnostics.get("adaptive_rules") or {}
target_total = self._adaptive_cap(rules, "target_total_exposure_usdt", self.settings.max_total_exposure_usdt)
target_symbol = self._adaptive_cap(rules, "target_symbol_exposure_usdt", self.settings.max_symbol_exposure_usdt)
exposure_room = max(0.0, target_total - self.exposure())
symbol_room = max(0.0, target_symbol - self.symbol_exposure(ticker.symbol))
caps = [self._signal_notional(signal), available, exposure_room, symbol_room]
requested = min(
max(self._signal_notional(signal), minimum_notional),
max(0.0, self.settings.max_position_usdt),
)
caps = [requested, available, exposure_room, symbol_room]
if extra_cap is not None:
caps.append(max(0.0, extra_cap))
return max(0.0, min(caps))
@@ -320,13 +381,23 @@ class LiveBroker(PaperBroker):
instrument: Instrument | None,
prices: dict[str, float],
) -> Position | None:
requested_notional = min(self._signal_notional(signal), self.settings.live_order_max_usdt)
fill_price = self._buy_price(ticker)
minimum_budget = self._minimum_entry_budget(instrument, fill_price)
requested_notional = min(
max(self._signal_notional(signal), minimum_budget),
self.settings.live_order_max_usdt,
)
allowed, reason = self.can_open(ticker.symbol, prices, requested_notional)
if not allowed:
self.storage.event(f"{ticker.symbol}: live BUY пропущен, {reason}", "WARN")
return None
budget = self._entry_budget(signal, ticker, self.settings.live_order_max_usdt)
if budget < self.settings.min_position_usdt:
budget = self._entry_budget(
signal,
ticker,
self.settings.live_order_max_usdt,
minimum_notional=minimum_budget,
)
if budget < max(self.settings.min_position_usdt, minimum_budget):
self.storage.event(f"{ticker.symbol}: live BUY skipped, adjusted budget below minimum", "WARN")
return None
signal.diagnostics["position_notional_usdt"] = budget
@@ -357,3 +428,12 @@ class LiveBroker(PaperBroker):
def prices_from_tickers(tickers: Iterable[Ticker]) -> dict[str, float]:
return {ticker.symbol: ticker.last_price for ticker in tickers}
def _symbol_position_limit(settings: Settings) -> int:
configured_limit = max(1, settings.max_positions_per_symbol)
exposure_based_limit = max(
1,
int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01)),
)
return min(configured_limit, exposure_based_limit)
+1
View File
@@ -299,6 +299,7 @@ def _neutral_rules(settings: Settings, reason: str | None = None) -> dict[str, A
"ema_exit_mode": "normal",
"rsi_exit_mode": "normal",
"stop_loss_percent": settings.stop_loss_percent,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"take_profit_percent": settings.take_profit_percent,
"trailing_stop_percent": settings.trailing_stop_percent,
"symbol_threshold_adjustments": {},
+14 -1
View File
@@ -16,7 +16,20 @@ from crypto_spot_bot.models import Candle, Ticker, utc_now
from crypto_spot_bot.storage import Storage
POPULAR_FALLBACK = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "LTCUSDT"]
POPULAR_FALLBACK = [
"BTCUSDT",
"ETHUSDT",
"HYPEUSDT",
"SOLUSDT",
"XRPUSDT",
"XPLUSDT",
"WLDUSDT",
"MNTUSDT",
"HUSDT",
"XAUTUSDT",
"IPUSDT",
"AAVEUSDT",
]
def _float(value: Any, default: float = 0.0) -> float:
+28
View File
@@ -242,6 +242,34 @@ class Storage:
).fetchall()
return [dict(row) for row in rows]
def closed_trade_summary(self) -> dict[str, Any]:
with self.connect() as conn:
row = conn.execute(
"""
SELECT
COUNT(*) AS trades,
COALESCE(SUM(net_pnl), 0) AS net_pnl,
COALESCE(SUM(gross_pnl), 0) AS gross_pnl,
COALESCE(SUM(fee_usdt), 0) AS fee_usdt,
COALESCE(SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END), 0) AS wins,
COALESCE(SUM(CASE WHEN net_pnl < 0 THEN 1 ELSE 0 END), 0) AS losses
FROM trades
WHERE side='SELL' AND closed_at IS NOT NULL
"""
).fetchone()
trades = int(row["trades"] if row else 0)
wins = int(row["wins"] if row else 0)
losses = int(row["losses"] if row else 0)
return {
"trades": trades,
"net_pnl": round(float(row["net_pnl"] if row else 0.0), 6),
"gross_pnl": round(float(row["gross_pnl"] if row else 0.0), 6),
"fee_usdt": round(float(row["fee_usdt"] if row else 0.0), 6),
"wins": wins,
"losses": losses,
"win_rate": round(wins / trades, 4) if trades else 0.0,
}
def insert_signal(self, signal: Signal) -> None:
with self.connect() as conn:
conn.execute(
+377 -44
View File
@@ -28,8 +28,11 @@ class SpotStrategy:
return _torch_forecast_entry_signal(
settings=self.settings,
symbol=symbol,
candles=candles,
ticker=ticker,
open_positions_for_symbol=open_positions_for_symbol,
pattern=pattern or {},
llm=llm or {},
forecast=forecast or {},
account=account,
)
@@ -317,7 +320,8 @@ class SpotStrategy:
diagnostics = {
"price": price,
"entry_price": position.entry_price,
"stop_loss": position.stop_loss,
"stop_loss": position.stop_loss if self.settings.stop_loss_exit_enabled else None,
"stop_loss_exit_enabled": self.settings.stop_loss_exit_enabled,
"take_profit": position.take_profit,
"highest_price": position.highest_price,
"trailing_stop": trailing,
@@ -325,7 +329,7 @@ class SpotStrategy:
"ema_20": latest.ema_20,
"ema_50": latest.ema_50,
}
if price <= position.stop_loss:
if self.settings.stop_loss_exit_enabled and price <= position.stop_loss:
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
if price >= position.take_profit:
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
@@ -386,14 +390,16 @@ class SpotStrategy:
trailing_percent = _adaptive_percent(
adaptive, "trailing_stop_percent", self.settings.trailing_stop_percent, 0.003, 0.08
)
effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent))
effective_stop_loss = _effective_stop_loss(self.settings, position, stop_loss_percent)
effective_take_profit = position.entry_price * (1 + take_profit_percent)
trailing = position.trailing_stop(trailing_percent)
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings)
min_exit_net_percent = _min_exit_net_percent(self.settings)
diagnostics = {
"price": price,
"entry_price": position.entry_price,
"stop_loss": effective_stop_loss,
"stop_loss_exit_enabled": self.settings.stop_loss_exit_enabled,
"take_profit": effective_take_profit,
"highest_price": position.highest_price,
"trailing_stop": trailing,
@@ -403,9 +409,10 @@ class SpotStrategy:
"adaptive_rules": adaptive,
"forecast": forecast,
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4),
"min_exit_net_percent": min_exit_net_percent,
"min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0),
}
if price <= effective_stop_loss:
if effective_stop_loss is not None and price <= effective_stop_loss:
return Signal(position.symbol, "SELL", 1.0, "сработал стоп-лосс", diagnostics)
if price >= effective_take_profit:
return Signal(position.symbol, "SELL", 0.96, "сработал тейк-профит", diagnostics)
@@ -433,6 +440,7 @@ class SpotStrategy:
estimated_exit_net_percent=estimated_exit_net_percent,
stop_loss_percent=stop_loss_percent,
min_edge_percent=self.settings.time_series_min_edge_percent,
min_exit_net_percent=min_exit_net_percent,
)
if forecast_exit is not None:
action, confidence, reason = forecast_exit
@@ -577,11 +585,9 @@ def _trend_macd_exit_signal(
previous = candles[-2]
price = ticker.last_price
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent))
effective_stop_loss = _effective_stop_loss(settings, position, stop_loss_percent)
atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0)
atr_trailing_stop = None
if latest.atr_14 is not None and position.highest_price > position.entry_price:
atr_trailing_stop = max(effective_stop_loss, position.highest_price - latest.atr_14 * atr_multiplier)
atr_trailing_stop = _atr_trailing_stop(settings, position, latest.atr_14, atr_multiplier, effective_stop_loss)
macd_cross_down = _macd_crossed_down(previous, latest)
close_below_ema50 = latest.ema_50 is not None and latest.close < latest.ema_50
diagnostics = {
@@ -589,6 +595,7 @@ def _trend_macd_exit_signal(
"price": price,
"entry_price": position.entry_price,
"stop_loss": effective_stop_loss,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"atr_trailing_stop": atr_trailing_stop,
"atr_trailing_multiplier": atr_multiplier,
"highest_price": position.highest_price,
@@ -600,7 +607,7 @@ def _trend_macd_exit_signal(
"macd_cross_down": macd_cross_down,
"close_below_ema50": close_below_ema50,
}
if price <= effective_stop_loss:
if effective_stop_loss is not None and price <= effective_stop_loss:
return Signal(position.symbol, "SELL", 1.0, "trend_macd: сработал стоп-лосс", diagnostics)
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)
@@ -615,18 +622,24 @@ def _torch_forecast_entry_signal(
*,
settings: Settings,
symbol: str,
candles: list[Candle] | None,
ticker: Ticker | None,
open_positions_for_symbol: int,
pattern: dict,
llm: dict,
forecast: dict,
account: dict | None,
) -> Signal:
if ticker is None:
return Signal(symbol, "HOLD", 0.0, "torch_forecast: no ticker data")
if open_positions_for_symbol > 0:
return Signal(symbol, "HOLD", 0.0, "torch_forecast: position for symbol is already open")
if open_positions_for_symbol >= _dynamic_symbol_position_limit(settings):
return Signal(symbol, "HOLD", 0.0, "torch_forecast: symbol position limit reached")
account_context = dict(account or {})
account_context.setdefault("symbol", symbol)
account_context.setdefault("open_positions_for_symbol", open_positions_for_symbol)
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast)
sizing = _torch_forecast_position_sizing(settings, account_context, stop_loss_percent, forecast, symbol)
position_notional = float(sizing["notional_usdt"])
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
@@ -666,6 +679,57 @@ def _torch_forecast_entry_signal(
liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
model_ok = _is_torch_forecast(forecast)
quality_gate_ok = forecast.get("quality_gate_passed") is not False
rebound = _torch_rebound_overlay(
settings=settings,
candles=candles or [],
ticker=ticker,
pattern=pattern,
llm=llm,
spread_ok=spread_ok,
liquidity_ok=liquidity_ok,
)
rebound_model_probability_min = round(
_clamp(settings.time_series_probe_min_probability_up, 0.50, 0.75),
4,
)
missing_torch_model = _missing_torch_model(forecast)
model_rebound_entry_ok = bool(
rebound.get("active")
and model_ok
and quality_gate_ok
and bool(forecast.get("usable", False))
and not bool(forecast.get("block_entry", False))
and expected_return >= 0.0
and probability_up >= rebound_model_probability_min
and skill > 0.0
and confidence >= settings.time_series_min_confidence
)
fallback_rebound_entry_ok = bool(
settings.time_series_rebound_fallback_enabled
and rebound.get("active")
and missing_torch_model
and quality_gate_ok
and not bool(forecast.get("block_entry", False))
and confidence >= settings.time_series_min_confidence
)
rebound_entry_ok = model_rebound_entry_ok or fallback_rebound_entry_ok
if rebound_entry_ok and position_notional > 0:
rebound_cap = max(settings.min_position_usdt, settings.rebound_max_position_usdt)
position_notional = round(
min(settings.max_position_usdt, rebound_cap, max(settings.min_position_usdt, position_notional)),
2,
)
sizing_method = "torch_forecast_rebound_fallback" if fallback_rebound_entry_ok else "torch_forecast_rebound"
sizing = {
**sizing,
"method": sizing_method,
"notional_usdt": position_notional,
"edge_mode": "rebound_fallback" if fallback_rebound_entry_ok else "rebound",
"rebound_probability": rebound.get("probability", 0.0),
}
edge_mode = "rebound_fallback" if fallback_rebound_entry_ok else "rebound"
risk_size_ok = position_notional >= settings.min_position_usdt
rebound_entry_sized_ok = rebound_entry_ok and risk_size_ok
checks = {
"torch_model_ok": model_ok,
"quality_gate_ok": quality_gate_ok,
@@ -677,7 +741,7 @@ def _torch_forecast_entry_signal(
"confidence_ok": confidence >= settings.time_series_min_confidence,
"spread_ok": spread_ok,
"liquidity_ok": liquidity_ok,
"risk_size_ok": position_notional >= settings.min_position_usdt,
"risk_size_ok": risk_size_ok,
}
diagnostics = {
"strategy_mode": "torch_forecast",
@@ -695,6 +759,13 @@ def _torch_forecast_entry_signal(
"edge_mode": edge_mode,
"probability_up": probability_up,
"min_probability_up": min_probability,
"rebound_model_probability_min": rebound_model_probability_min,
"missing_torch_model": missing_torch_model,
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
"model_rebound_entry_ok": model_rebound_entry_ok,
"fallback_rebound_entry_ok": fallback_rebound_entry_ok,
"rebound_entry_ok": rebound_entry_ok,
"rebound_entry_sized_ok": rebound_entry_sized_ok,
"min_confidence": settings.time_series_min_confidence,
"skill": skill,
"quality_gate": forecast.get("quality_gate", {}),
@@ -703,27 +774,82 @@ def _torch_forecast_entry_signal(
"turnover_24h": ticker.turnover_24h,
"checks": checks,
"grid": {"enabled": False, "active": False},
"rebound": {"enabled": False, "active": False},
"rebound": rebound,
"learning": {},
"llm": {},
}
if all(checks.values()):
return Signal(
symbol,
"BUY",
confidence,
(
base_entry_ok = all(checks.values())
if base_entry_ok or rebound_entry_sized_ok:
buy_confidence = max(confidence, float(rebound.get("probability", 0.0) or 0.0)) if rebound_entry_sized_ok else confidence
entry_path = edge_mode if rebound_entry_ok and not base_entry_ok else edge_mode
diagnostics["entry_path"] = entry_path
if fallback_rebound_entry_ok and not base_entry_ok:
reason = (
"torch_forecast: rebound fallback confirmed without PyTorch model; "
f"rebound_probability={float(rebound.get('probability', 0.0) or 0.0):.3f}, "
f"size={position_notional:.2f} USDT"
)
elif rebound_entry_ok and not base_entry_ok:
reason = (
"torch_forecast: rebound overlay confirmed; "
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
f"expected={expected_return:.4f}%, rebound_probability={float(rebound.get('probability', 0.0) or 0.0):.3f}, "
f"size={position_notional:.2f} USDT"
)
else:
reason = (
"torch_forecast: PyTorch edge confirmed; "
f"model={forecast.get('model')}, p_up={probability_up:.3f}, "
f"expected={expected_return:.4f}%, edge_mode={edge_mode}, "
f"size={position_notional:.2f} USDT"
),
)
return Signal(
symbol,
"BUY",
round(_clamp(buy_confidence, 0.0, 0.96), 4),
reason,
diagnostics,
)
failed = ", ".join(name for name, ok in checks.items() if not ok)
return Signal(symbol, "HOLD", confidence, f"torch_forecast: entry blocked ({failed})", diagnostics)
def _torch_rebound_overlay(
*,
settings: Settings,
candles: list[Candle],
ticker: Ticker,
pattern: dict,
llm: dict,
spread_ok: bool,
liquidity_ok: bool,
) -> dict:
if not settings.rebound_trading_enabled:
return {"enabled": False, "active": False, "reason": "rebound trading disabled"}
if len(candles) < 21:
return {"enabled": True, "active": False, "reason": "not enough candles for rebound"}
latest = candles[-1]
previous = candles[-2] if len(candles) >= 2 else latest
if not _has_entry_indicators(latest):
return {"enabled": True, "active": False, "reason": "entry indicators are not ready"}
volume_ok = latest.volume_ma_20 is not None and latest.volume >= latest.volume_ma_20 * 0.75
atr_percent = (latest.atr_14 / latest.close) * 100 if latest.close and latest.atr_14 is not None else 0.0
volatility_ok = 0.04 <= atr_percent <= 6.0
return _rebound_state(
settings=settings,
candles=candles,
latest=latest,
previous=previous,
pattern=pattern,
llm=llm,
spread_ok=spread_ok,
liquidity_ok=liquidity_ok,
volume_ok=volume_ok,
volatility_ok=volatility_ok,
atr_percent=atr_percent,
)
def _torch_forecast_exit_signal(
settings: Settings,
position: Position,
@@ -737,11 +863,15 @@ def _torch_forecast_exit_signal(
latest = candles[-1] if candles else None
price = ticker.last_price
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
effective_stop_loss = max(position.stop_loss, position.entry_price * (1 - stop_loss_percent))
effective_stop_loss = _effective_stop_loss(settings, position, stop_loss_percent)
atr_multiplier = _clamp(settings.atr_trailing_multiplier, 0.5, 10.0)
atr_trailing_stop = None
if latest and latest.atr_14 is not None and position.highest_price > position.entry_price:
atr_trailing_stop = max(effective_stop_loss, position.highest_price - latest.atr_14 * atr_multiplier)
atr_trailing_stop = _atr_trailing_stop(
settings,
position,
latest.atr_14 if latest else None,
atr_multiplier,
effective_stop_loss,
)
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
@@ -749,14 +879,23 @@ def _torch_forecast_exit_signal(
min_edge = max(0.0, settings.time_series_min_edge_percent)
min_probability = _torch_min_probability(settings)
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, settings)
min_exit_net_percent = _min_exit_net_percent(settings)
entry_path = str(position.entry_diagnostics.get("entry_path", ""))
entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", ""))
rebound_fallback_position = entry_path == "rebound_fallback" or entry_edge_mode == "rebound_fallback"
diagnostics = {
"strategy_mode": "torch_forecast",
"price": price,
"entry_price": position.entry_price,
"stop_loss": effective_stop_loss,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
"take_profit": position.take_profit,
"atr_trailing_stop": atr_trailing_stop,
"atr_trailing_multiplier": atr_multiplier,
"highest_price": position.highest_price,
"entry_path": entry_path,
"entry_edge_mode": entry_edge_mode,
"rebound_fallback_position": rebound_fallback_position,
"forecast": forecast,
"expected_return_percent": expected_return,
"min_edge_percent": min_edge,
@@ -764,27 +903,86 @@ def _torch_forecast_exit_signal(
"min_probability_up": min_probability,
"skill": skill,
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4),
"min_exit_net_percent": min_exit_net_percent,
"atr_14": latest.atr_14 if latest else None,
}
if price <= effective_stop_loss:
hold_seconds = (utc_now() - position.opened_at).total_seconds()
diagnostics["hold_seconds"] = hold_seconds
diagnostics["min_hold_seconds"] = settings.min_hold_seconds
if effective_stop_loss is not None and price <= effective_stop_loss:
return Signal(position.symbol, "SELL", 1.0, "torch_forecast: stop-loss hit", diagnostics)
if price >= position.take_profit:
return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics)
if atr_trailing_stop is not None and price <= atr_trailing_stop:
if estimated_exit_net_percent < min_exit_net_percent:
diagnostics["atr_exit_blocked_by_min_profit"] = True
if estimated_exit_net_percent < 0:
diagnostics["atr_exit_blocked_by_cost"] = True
return Signal(
position.symbol,
"HOLD",
0.45,
"torch_forecast: ATR trailing touched, but exit profit is below minimum",
diagnostics,
)
return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics)
if not _is_torch_forecast(forecast):
if rebound_fallback_position:
hold_seconds = (utc_now() - position.opened_at).total_seconds()
diagnostics["hold_seconds"] = hold_seconds
if hold_seconds < settings.min_hold_seconds:
return Signal(
position.symbol,
"HOLD",
0.45,
"torch_forecast: rebound fallback minimum hold",
diagnostics,
)
return Signal(
position.symbol,
"HOLD",
0.42,
"torch_forecast: rebound fallback hold without PyTorch model",
diagnostics,
)
return Signal(position.symbol, "SELL", 0.78, "torch_forecast: no valid PyTorch forecast to hold", diagnostics)
if bool(forecast.get("block_entry", False)) or expected_return <= 0.0 or probability_up <= 0.50:
if hold_seconds < settings.min_hold_seconds:
diagnostics["forecast_exit_blocked_by_min_hold"] = True
return Signal(
position.symbol,
"HOLD",
0.46,
"torch_forecast: minimum hold protects against fee churn",
diagnostics,
)
forecast_exit = _forecast_exit_signal(
forecast=forecast,
position=position,
price=price,
estimated_exit_net_percent=estimated_exit_net_percent,
stop_loss_percent=stop_loss_percent,
min_edge_percent=min_edge,
min_exit_net_percent=min_exit_net_percent,
)
if forecast_exit is not None:
action, confidence, reason = forecast_exit
return Signal(position.symbol, action, confidence, reason, diagnostics)
diagnostics["forecast_exit_blocked_by_min_profit"] = True
if estimated_exit_net_percent < 0:
diagnostics["forecast_exit_blocked_by_cost"] = True
return Signal(
position.symbol,
"SELL",
0.86,
"HOLD",
0.44,
(
"torch_forecast: PyTorch forecast turned negative; "
"torch_forecast: forecast weakened, but exit profit is below minimum; "
f"p_up={probability_up:.3f}, expected={expected_return:.4f}%"
),
diagnostics,
)
weak_hold = expected_return < min_edge or probability_up < min_probability or skill <= 0.0
if weak_hold and estimated_exit_net_percent >= 0:
if weak_hold and estimated_exit_net_percent >= min_exit_net_percent:
return Signal(
position.symbol,
"SELL",
@@ -795,6 +993,8 @@ def _torch_forecast_exit_signal(
),
diagnostics,
)
if weak_hold and estimated_exit_net_percent >= 0:
diagnostics["weak_exit_blocked_by_min_profit"] = True
return Signal(position.symbol, "HOLD", 0.35, "torch_forecast: PyTorch hold confirmed", diagnostics)
@@ -803,10 +1003,29 @@ def _is_torch_forecast(forecast: dict) -> bool:
return bool(forecast.get("usable", False)) and model in {"torch_lstm", "torch_gru"}
def _missing_torch_model(forecast: dict) -> bool:
model = str(forecast.get("model", "")).strip().lower()
reason = str(forecast.get("reason", "")).lower()
return (
not bool(forecast.get("usable", False))
and model in {"", "none"}
and "no valid pytorch" in reason
)
def _torch_min_probability(settings: Settings) -> float:
return round(_clamp(settings.time_series_min_probability_up, 0.45, 0.75), 4)
def _dynamic_symbol_position_limit(settings: Settings) -> int:
configured_limit = max(1, settings.max_positions_per_symbol)
exposure_based_limit = max(
1,
int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01)),
)
return min(configured_limit, exposure_based_limit)
def _torch_forecast_confidence(settings: Settings, forecast: dict) -> float:
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
@@ -824,30 +1043,43 @@ def _torch_forecast_position_sizing(
account: dict | None,
stop_loss_percent: float,
forecast: dict,
symbol: str | None = None,
) -> dict[str, float | str]:
base = _trend_position_sizing(settings, account, stop_loss_percent)
base_notional = float(base["notional_usdt"])
if base_notional <= 0:
kelly = _kelly_position(
settings=settings,
final_score=_torch_forecast_confidence(settings, forecast),
forecast=forecast,
adaptive={},
account=account,
symbol=symbol,
)
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
min_edge = max(0.01, settings.time_series_min_edge_percent)
edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15)
probability_multiplier = _clamp(0.75 + (probability_up - 0.55) * 3.0, 0.50, 1.20)
skill_multiplier = _clamp(0.85 + skill * 0.60, 0.60, 1.15)
if settings.kelly_sizing_enabled:
raw = float(kelly["kelly_remaining_notional_usdt"]) * _risk_guard_multiplier(account)
notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt)
elif base_notional <= 0:
notional = 0.0
edge_multiplier = probability_multiplier = skill_multiplier = 0.0
else:
expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0))
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
skill = max(0.0, _safe_float(forecast.get("skill"), 0.0))
min_edge = max(0.01, settings.time_series_min_edge_percent)
edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15)
probability_multiplier = _clamp(0.75 + (probability_up - 0.55) * 3.0, 0.50, 1.20)
skill_multiplier = _clamp(0.85 + skill * 0.60, 0.60, 1.15)
raw = base_notional * edge_multiplier * probability_multiplier * skill_multiplier
notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt)
return {
**base,
"method": "torch_forecast_risk",
"method": "torch_forecast_fractional_kelly" if settings.kelly_sizing_enabled else "torch_forecast_risk",
"enabled": bool(settings.kelly_sizing_enabled),
"notional_usdt": round(notional, 2),
"base_notional_usdt": base["notional_usdt"],
"torch_edge_multiplier": round(edge_multiplier, 4),
"torch_probability_multiplier": round(probability_multiplier, 4),
"torch_skill_multiplier": round(skill_multiplier, 4),
**kelly,
}
@@ -1010,6 +1242,7 @@ def _kelly_position(
forecast: dict,
adaptive: dict,
account: dict | None,
symbol: str | None = None,
) -> dict[str, float | bool | str]:
confidence_probability = _confidence_probability(final_score, settings.min_signal_confidence)
probability_source = "confidence"
@@ -1022,8 +1255,13 @@ def _kelly_position(
stop_loss = _adaptive_percent(adaptive, "stop_loss_percent", settings.stop_loss_percent, 0.003, 0.08)
take_profit = _adaptive_percent(adaptive, "take_profit_percent", settings.take_profit_percent, 0.003, 0.20)
round_trip_cost = max(0.0, 2.0 * (settings.taker_fee_rate + settings.slippage_rate))
win_return = max(0.0, take_profit - round_trip_cost)
base_win_return = max(0.0, take_profit - round_trip_cost)
loss_return = max(0.0001, stop_loss + round_trip_cost)
expected_net_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0) / 100.0)
implied_win_return = 0.0
if probability > 0:
implied_win_return = max(0.0, (expected_net_return + (1.0 - probability) * loss_return) / probability)
win_return = max(base_win_return, implied_win_return)
reward_loss_ratio = win_return / loss_return if loss_return > 0 else 0.0
full_kelly = probability - ((1.0 - probability) / reward_loss_ratio) if reward_loss_ratio > 0 else 0.0
full_kelly = max(0.0, full_kelly)
@@ -1032,19 +1270,88 @@ def _kelly_position(
bankroll = _safe_float((account or {}).get("equity"), settings.starting_balance_usdt)
if bankroll <= 0:
bankroll = settings.starting_balance_usdt
kelly_notional = max(0.0, bankroll * effective_fraction)
target_notional = max(0.0, bankroll * effective_fraction)
open_symbol_exposure = _account_symbol_exposure(account, symbol)
raw_remaining_notional = max(0.0, target_notional - open_symbol_exposure)
exchange_min_entry = _account_exchange_min_entry(account, settings)
remaining_notional = raw_remaining_notional
effective_target_notional = target_notional
layer_mode = False
if (
symbol
and target_notional > 0
and raw_remaining_notional < exchange_min_entry
and exchange_min_entry > settings.min_position_usdt + 1e-9
and _account_open_positions_for_symbol(account) > 0
):
room = min(
max(0.0, settings.max_position_usdt),
max(0.0, settings.max_symbol_exposure_usdt - open_symbol_exposure),
max(0.0, settings.max_total_exposure_usdt - _account_total_exposure(account)),
max(0.0, _safe_float((account or {}).get("cash"), settings.starting_balance_usdt) - settings.min_cash_reserve_usdt),
)
if room >= exchange_min_entry:
remaining_notional = exchange_min_entry
effective_target_notional = open_symbol_exposure + exchange_min_entry
layer_mode = True
return {
"kelly_probability": round(probability, 4),
"kelly_probability_source": probability_source,
"kelly_reward_loss_ratio": round(reward_loss_ratio, 4),
"kelly_win_return_percent": round(win_return * 100.0, 4),
"kelly_loss_return_percent": round(loss_return * 100.0, 4),
"kelly_expected_net_percent": round(expected_net_return * 100.0, 4),
"kelly_full_fraction": round(full_kelly, 4),
"kelly_fractional_fraction": round(fractional_kelly, 4),
"kelly_effective_fraction": round(effective_fraction, 4),
"kelly_bankroll_usdt": round(bankroll, 2),
"kelly_notional_usdt": round(kelly_notional, 2),
"kelly_target_notional_usdt": round(target_notional, 2),
"kelly_effective_target_notional_usdt": round(effective_target_notional, 2),
"kelly_open_symbol_exposure_usdt": round(open_symbol_exposure, 2),
"kelly_raw_remaining_notional_usdt": round(raw_remaining_notional, 2),
"kelly_remaining_notional_usdt": round(remaining_notional, 2),
"kelly_notional_usdt": round(remaining_notional, 2),
"kelly_exchange_min_entry_usdt": round(exchange_min_entry, 2),
"kelly_layer_mode": layer_mode,
}
def _account_symbol_exposure(account: dict | None, symbol: str | None = None) -> float:
if not isinstance(account, dict):
return 0.0
direct = _safe_float(account.get("symbol_exposure_usdt"), -1.0)
if direct >= 0:
return max(0.0, direct)
if not symbol:
symbol = str(account.get("symbol", "") or "")
exposures = account.get("symbol_exposures")
if isinstance(exposures, dict) and symbol:
return max(0.0, _safe_float(exposures.get(symbol), 0.0))
return 0.0
def _account_total_exposure(account: dict | None) -> float:
if not isinstance(account, dict):
return 0.0
return max(0.0, _safe_float(account.get("exposure"), 0.0))
def _account_open_positions_for_symbol(account: dict | None) -> int:
if not isinstance(account, dict):
return 0
try:
return max(0, int(account.get("open_positions_for_symbol", 0)))
except (TypeError, ValueError):
return 0
def _account_exchange_min_entry(account: dict | None, settings: Settings) -> float:
minimum = max(0.0, settings.min_position_usdt)
if not isinstance(account, dict):
return minimum
return max(minimum, _safe_float(account.get("exchange_min_entry_usdt"), minimum))
def _confidence_probability(final_score: float, min_signal_confidence: float) -> float:
denominator = max(0.0001, 1.0 - min_signal_confidence)
ratio = _clamp((final_score - min_signal_confidence) / denominator, 0.0, 1.0)
@@ -1309,6 +1616,27 @@ def _adaptive_percent(adaptive: dict, key: str, default: float, low: float, high
return _clamp(_safe_float(adaptive.get(key), default), low, high)
def _effective_stop_loss(settings: Settings, position: Position, stop_loss_percent: float) -> float | None:
if not settings.stop_loss_exit_enabled:
return None
return max(position.stop_loss, position.entry_price * (1 - stop_loss_percent))
def _atr_trailing_stop(
settings: Settings,
position: Position,
atr: float | None,
atr_multiplier: float,
effective_stop_loss: float | None,
) -> float | None:
if atr is None or atr <= 0 or position.highest_price <= position.entry_price:
return None
raw_stop = position.highest_price - atr * atr_multiplier
if settings.stop_loss_exit_enabled and effective_stop_loss is not None:
return max(effective_stop_loss, raw_stop)
return raw_stop if raw_stop > position.entry_price else None
def _estimated_exit_net_percent(position: Position, price: float, settings: Settings) -> float:
if position.entry_price <= 0:
return 0.0
@@ -1317,6 +1645,10 @@ def _estimated_exit_net_percent(position: Position, price: float, settings: Sett
return gross_percent - round_trip_cost_percent
def _min_exit_net_percent(settings: Settings) -> float:
return round(_clamp(settings.min_exit_net_percent, 0.0, 5.0), 4)
def _adaptive_indicator_exit_allowed(adaptive: dict, mode_key: str, estimated_exit_net_percent: float) -> bool:
mode = str(adaptive.get(mode_key, "normal")).lower()
if mode != "profit_only":
@@ -1333,6 +1665,7 @@ def _forecast_exit_signal(
estimated_exit_net_percent: float,
stop_loss_percent: float,
min_edge_percent: float,
min_exit_net_percent: float,
) -> tuple[str, float, str] | None:
if not forecast.get("usable"):
return None
@@ -1344,7 +1677,7 @@ def _forecast_exit_signal(
if not strong_negative:
return None
reason = forecast.get("reason") or "ожидается снижение"
if estimated_exit_net_percent >= 0:
if estimated_exit_net_percent >= min_exit_net_percent:
return "SELL", 0.82, f"прогноз временного ряда ухудшился: {reason}; фиксируем результат"
loss_from_entry = ((price - position.entry_price) / position.entry_price) if position.entry_price else 0.0
soft_loss_limit = -max(0.003, stop_loss_percent * 0.35)
+309
View File
@@ -0,0 +1,309 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
import uuid
from datetime import UTC
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from threading import Lock
from typing import Any
ALLOWED_TRAINING_ARTIFACTS = {
"lstm_forecaster.json",
"torch_retrain_guard.json",
"torch_threshold_calibration.json",
}
RUNNING_TIMEOUT = timedelta(hours=12)
ONLINE_WINDOW = timedelta(minutes=3)
class TrainingCoordinator:
def __init__(self, runtime_dir: Path) -> None:
self.runtime_dir = runtime_dir
self.state_path = runtime_dir / "training_coordination.json"
self.upload_root = runtime_dir / ".training_uploads"
self._lock = Lock()
def status(self) -> dict[str, Any]:
with self._lock:
state = self._load_state()
self._expire_stale_jobs(state)
self._save_state(state)
return self._public_status(state)
def request_retrain(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
self._expire_stale_jobs(state)
existing = self._active_job(state)
if existing is not None:
self._save_state(state)
return {"queued": False, "reason": "active_job_exists", "job": existing, "status": self._public_status(state)}
now = _now()
job = {
"id": str(uuid.uuid4()),
"status": "pending",
"requested_at": now,
"requested_by": str(payload.get("source") or "api"),
"parameters": _safe_parameters(payload.get("parameters")),
"message": "",
"artifacts": [],
}
state.setdefault("jobs", []).append(job)
self._trim_jobs(state)
self._save_state(state)
return {"queued": True, "job": job, "status": self._public_status(state)}
def heartbeat(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
worker = self._worker_from_payload(payload)
state["worker"] = worker
self._save_state(state)
return {"ok": True, "worker": worker, "status": self._public_status(state)}
def claim(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
self._expire_stale_jobs(state)
worker = self._worker_from_payload(payload)
state["worker"] = worker
job = self._oldest_pending_job(state)
if job is None:
self._save_state(state)
return {"claimed": False, "job": None, "status": self._public_status(state)}
now = _now()
job["status"] = "running"
job["claimed_at"] = now
job["claimed_by"] = worker["id"]
job["worker"] = worker
self._save_state(state)
return {"claimed": True, "job": job, "status": self._public_status(state)}
def save_artifact_chunk(self, job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
name = Path(str(payload.get("name") or "")).name
if name not in ALLOWED_TRAINING_ARTIFACTS:
raise ValueError(f"artifact is not allowed: {name}")
index = int(payload.get("index", -1))
total = int(payload.get("total", 0))
sha256 = str(payload.get("sha256") or "").strip().lower()
if index < 0 or total <= 0 or index >= total:
raise ValueError("invalid artifact chunk index")
if not sha256:
raise ValueError("artifact sha256 is required")
try:
chunk = base64.b64decode(str(payload.get("data_base64") or ""), validate=True)
except (ValueError, TypeError) as exc:
raise ValueError("invalid artifact chunk payload") from exc
chunk_dir = self.upload_root / job_id / name
chunk_dir.mkdir(parents=True, exist_ok=True)
(chunk_dir / f"{index:06d}.part").write_bytes(chunk)
if not all((chunk_dir / f"{part:06d}.part").is_file() for part in range(total)):
return {"complete": False, "received": index + 1, "total": total}
target_tmp = self.runtime_dir / f".{name}.{job_id}.tmp"
digest = hashlib.sha256()
with target_tmp.open("wb") as output:
for part in range(total):
data = (chunk_dir / f"{part:06d}.part").read_bytes()
digest.update(data)
output.write(data)
if digest.hexdigest().lower() != sha256:
target_tmp.unlink(missing_ok=True)
raise ValueError("artifact sha256 mismatch")
self.runtime_dir.mkdir(parents=True, exist_ok=True)
os.replace(target_tmp, self.runtime_dir / name)
_remove_tree(chunk_dir)
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is not None:
artifacts = job.setdefault("artifacts", [])
artifacts = [item for item in artifacts if item.get("name") != name]
artifacts.append({"name": name, "sha256": sha256, "uploaded_at": _now()})
job["artifacts"] = artifacts
self._save_state(state)
return {"complete": True, "name": name, "sha256": sha256}
def progress(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is None:
raise ValueError(f"training job not found: {job_id}")
if isinstance(payload.get("worker"), dict):
state["worker"] = self._worker_from_payload(payload["worker"])
job["status"] = str(payload.get("status") or job.get("status") or "running")
job["phase"] = str(payload.get("phase") or job.get("phase") or "running")
job["message"] = str(payload.get("message") or job.get("message") or "")
job["progress_percent"] = _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
job["updated_at"] = _now()
if isinstance(payload.get("details"), dict):
job["details"] = payload["details"]
self._save_state(state)
return {"ok": True, "job": job, "status": self._public_status(state)}
def complete(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is None:
raise ValueError(f"training job not found: {job_id}")
success = bool(payload.get("success", payload.get("status") == "completed"))
job["status"] = "completed" if success else "failed"
job["phase"] = "completed" if success else "failed"
job["progress_percent"] = 100 if success else _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
job["completed_at"] = _now()
job["message"] = str(payload.get("message") or "")
if isinstance(payload.get("summary"), dict):
job["summary"] = payload["summary"]
self._save_state(state)
return {"ok": True, "job": job, "status": self._public_status(state)}
def _load_state(self) -> dict[str, Any]:
try:
data = json.loads(self.state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
if not isinstance(data, dict):
data = {}
data.setdefault("jobs", [])
return data
def _save_state(self, state: dict[str, Any]) -> None:
self.runtime_dir.mkdir(parents=True, exist_ok=True)
tmp = self.state_path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, self.state_path)
def _worker_from_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(payload.get("worker_id") or payload.get("id") or "windows-training-host"),
"name": str(payload.get("name") or "DESKTOP-TMFDL0H"),
"path": str(payload.get("path") or "C:\\Repos\\TradeBot"),
"version": str(payload.get("version") or "1"),
"last_seen_at": _now(),
}
def _public_status(self, state: dict[str, Any]) -> dict[str, Any]:
worker = state.get("worker") if isinstance(state.get("worker"), dict) else {}
last_seen = _parse_time(str(worker.get("last_seen_at") or ""))
active = self._active_job(state)
latest = _latest_job(state)
recently_seen = bool(last_seen and datetime.now(UTC) - last_seen <= ONLINE_WINDOW)
agent_busy = bool(
active
and active.get("status") == "running"
and worker
and active.get("claimed_by") == worker.get("id")
)
return {
"available": True,
"agent_online": recently_seen or agent_busy,
"agent_recently_seen": recently_seen,
"agent_busy": agent_busy,
"worker": worker,
"active_job": active,
"latest_job": latest,
"pending_jobs": sum(1 for job in state.get("jobs", []) if job.get("status") == "pending"),
}
def _active_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
for job in reversed(state.get("jobs", [])):
if job.get("status") in {"pending", "running"}:
return job
return None
def _oldest_pending_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
for job in state.get("jobs", []):
if job.get("status") == "pending":
return job
return None
def _job_by_id(self, state: dict[str, Any], job_id: str) -> dict[str, Any] | None:
for job in state.get("jobs", []):
if job.get("id") == job_id:
return job
return None
def _expire_stale_jobs(self, state: dict[str, Any]) -> None:
now = datetime.now(UTC)
for job in state.get("jobs", []):
if job.get("status") != "running":
continue
claimed_at = _parse_time(str(job.get("claimed_at") or ""))
if claimed_at and now - claimed_at > RUNNING_TIMEOUT:
job["status"] = "failed"
job["completed_at"] = _now()
job["message"] = "training worker timeout"
def _trim_jobs(self, state: dict[str, Any]) -> None:
jobs = state.get("jobs", [])
if isinstance(jobs, list) and len(jobs) > 30:
state["jobs"] = jobs[-30:]
def _safe_parameters(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
allowed = {"symbols", "limit", "lookbacks", "architectures", "hidden_sizes", "layers", "dropouts", "epochs"}
return {key: value[key] for key in allowed if key in value}
def _latest_job(state: dict[str, Any]) -> dict[str, Any] | None:
jobs = state.get("jobs", [])
if not jobs:
return None
latest = jobs[-1]
return latest if isinstance(latest, dict) else None
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _parse_time(value: str) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def _coerce_percent(value: Any, default: Any = 0) -> int:
try:
number = int(float(value))
except (TypeError, ValueError):
try:
number = int(float(default))
except (TypeError, ValueError):
number = 0
return max(0, min(number, 100))
def _remove_tree(path: Path) -> None:
if not path.exists():
return
for child in path.iterdir():
if child.is_dir():
_remove_tree(child)
else:
child.unlink(missing_ok=True)
path.rmdir()
File diff suppressed because it is too large Load Diff
+1159900 -363484
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -67,6 +67,7 @@ def make_settings():
kelly_max_fraction=0.20,
risk_per_trade_percent=0.01,
risk_guard_enabled=True,
risk_symbol_guard_enabled=True,
risk_recent_trade_window=20,
risk_max_consecutive_losses=4,
risk_min_recent_profit_factor=0.85,
@@ -87,10 +88,13 @@ def make_settings():
time_series_probe_min_edge_percent=0.02,
time_series_probe_min_probability_up=0.55,
time_series_probe_size_multiplier=0.40,
time_series_rebound_fallback_enabled=True,
stop_loss_percent=0.02,
stop_loss_exit_enabled=True,
take_profit_percent=0.035,
trailing_stop_percent=0.015,
min_hold_seconds=180,
min_exit_net_percent=0.20,
entry_cooldown_seconds=180,
max_daily_drawdown_usdt=6.0,
min_cash_reserve_usdt=5.0,
+41 -1
View File
@@ -34,7 +34,12 @@ def test_risk_guard_reduces_size_after_consecutive_losses(make_settings, tmp_pat
def test_risk_guard_blocks_only_bad_symbol(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, risk_max_consecutive_losses=3, symbols=["BTCUSDT", "ETHUSDT"])
settings = make_settings(
tmp_path,
risk_symbol_guard_enabled=True,
risk_max_consecutive_losses=3,
symbols=["BTCUSDT", "ETHUSDT"],
)
storage = Storage(settings.database_path)
now = utc_now()
for _ in range(3):
@@ -76,6 +81,41 @@ def test_risk_guard_blocks_only_bad_symbol(make_settings, tmp_path) -> None:
assert symbol_rows["ETHUSDT"]["block_new_entries"] is False
def test_risk_guard_can_disable_symbol_blocks(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
risk_symbol_guard_enabled=False,
risk_max_consecutive_losses=3,
symbols=["BTCUSDT", "ETHUSDT"],
)
storage = Storage(settings.database_path)
now = utc_now()
for _ in range(3):
storage.insert_trade(
Trade(
id=None,
symbol="BTCUSDT",
side="SELL",
qty=1.0,
entry_price=100.0,
exit_price=99.0,
net_pnl=-1.0,
opened_at=now,
closed_at=now,
entry_diagnostics={"forecast": {"probability_up": 0.64, "model": "torch_gru"}},
)
)
guard = risk_guard_snapshot(settings, storage.closed_trades(), storage.latest_equity())
assert guard["symbol_guard_enabled"] is False
assert guard["blocked_symbols"] == []
symbol_rows = {row["symbol"]: row for row in guard["symbols"]}
assert symbol_rows["BTCUSDT"]["consecutive_losses"] == 3
assert symbol_rows["BTCUSDT"]["block_new_entries"] is False
assert symbol_rows["BTCUSDT"]["reasons"] == []
def test_risk_guard_ignores_trades_outside_active_universe(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, risk_max_consecutive_losses=2, symbols=["BTCUSDT", "ETHUSDT"])
storage = Storage(settings.database_path)
+44 -4
View File
@@ -52,6 +52,21 @@ def test_fast_trading_env_sets_effective_intervals(tmp_path, monkeypatch) -> Non
assert settings.max_entries_per_minute == 4
def test_symbol_risk_guard_can_be_disabled(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("RISK_SYMBOL_GUARD_ENABLED", raising=False)
monkeypatch.setenv("TRADING_MODE", "paper")
env_file = tmp_path / ".env"
env_file.write_text(
"TRADING_MODE=paper\nRISK_SYMBOL_GUARD_ENABLED=false\n",
encoding="utf-8",
)
settings = load_settings(env_file)
assert settings.risk_guard_enabled is True
assert settings.risk_symbol_guard_enabled is False
def test_llm_advisor_is_disabled_by_default(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("LLM_ADVISOR_ENABLED", raising=False)
monkeypatch.setenv("TRADING_MODE", "paper")
@@ -84,7 +99,7 @@ def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None:
assert settings.time_series_forecast_enabled is True
def test_torch_forecast_forces_fixed_symbols(tmp_path, monkeypatch) -> None:
def test_torch_forecast_keeps_configured_symbol_selection(tmp_path, monkeypatch) -> None:
for key in (
"AUTO_SELECT_SYMBOLS",
"TOP_SYMBOLS_COUNT",
@@ -110,7 +125,32 @@ def test_torch_forecast_forces_fixed_symbols(tmp_path, monkeypatch) -> None:
settings = load_settings(env_file)
assert settings.auto_select_symbols is False
assert settings.top_symbols_count == len(FIXED_SPOT_SYMBOLS)
assert settings.symbols == FIXED_SPOT_SYMBOLS
assert settings.auto_select_symbols is True
assert settings.top_symbols_count == 9
assert settings.symbols == ("DOGEUSDT", "XRPUSDT")
assert settings.time_series_forecast_enabled is True
def test_auto_select_uses_empty_symbol_list(tmp_path, monkeypatch) -> None:
for key in ("AUTO_SELECT_SYMBOLS", "TOP_SYMBOLS_COUNT", "SYMBOLS", "STRATEGY_MODE"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("TRADING_MODE", "paper")
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"TRADING_MODE=paper",
"STRATEGY_MODE=torch_forecast",
"AUTO_SELECT_SYMBOLS=true",
"TOP_SYMBOLS_COUNT=12",
"SYMBOLS=",
]
),
encoding="utf-8",
)
settings = load_settings(env_file)
assert settings.auto_select_symbols is True
assert settings.top_symbols_count == 12
assert settings.symbols == ()
+7
View File
@@ -4,6 +4,7 @@ import json
from crypto_spot_bot.dashboard import _apply_fast_trading
from crypto_spot_bot.dashboard import _safe_config
from crypto_spot_bot.dashboard import WEB_UI_REMOVED_MESSAGE
from crypto_spot_bot.storage import Storage
@@ -43,6 +44,7 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
assert config["time_series_probe_min_edge_percent"] == 0.02
assert config["time_series_probe_min_probability_up"] == 0.55
assert config["time_series_probe_size_multiplier"] == 0.40
assert config["time_series_rebound_fallback_enabled"] is True
assert config["time_series_model_artifact"] == {
"available": True,
"type": "pytorch_recurrent_forecaster",
@@ -54,3 +56,8 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
"target_horizon": 0,
"direct_horizon": False,
}
def test_web_ui_is_removed_from_api_service() -> None:
assert "Web UI removed" in WEB_UI_REMOVED_MESSAGE
assert "/api/*" in WEB_UI_REMOVED_MESSAGE
+96 -4
View File
@@ -27,6 +27,9 @@ def test_paper_broker_buy_and_sell_records_trade(make_settings, tmp_path) -> Non
assert trade.side == "SELL"
assert len(broker.open_positions()) == 0
assert storage.recent_trades(limit=10)
summary = storage.closed_trade_summary()
assert summary["trades"] == 1
assert summary["net_pnl"] == round(trade.net_pnl, 6)
def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) -> None:
@@ -54,12 +57,13 @@ def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) ->
def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=6,
max_total_exposure_usdt=50,
max_open_positions=20,
max_positions_per_symbol=1,
max_positions_per_symbol=6,
max_entries_per_minute=0,
)
storage = Storage(settings.database_path)
@@ -100,6 +104,63 @@ def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_
assert 5.5 <= broker.symbol_exposure("BTCUSDT") <= 6.0
def test_paper_broker_raises_small_signal_to_exchange_min_notional(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=20,
max_total_exposure_usdt=80,
max_open_positions=20,
max_positions_per_symbol=20,
max_entries_per_minute=0,
)
storage = Storage(settings.database_path)
broker = PaperBroker(settings, storage)
ticker = Ticker("XRPUSDT", 1.0, 0.999, 1.001, 10_000_000, 100, 0)
instrument = Instrument("XRPUSDT", "XRP", "USDT", "Trading", 0.0001, 0.01, 0.01, 5)
position = broker.buy(
Signal("XRPUSDT", "BUY", 0.8, "small rebound", {"position_notional_usdt": 1.5}),
ticker,
instrument,
{"XRPUSDT": 1.0},
)
assert position is not None
assert position.notional_usdt >= instrument.min_notional_value
assert position.notional_usdt <= settings.max_position_usdt
def test_paper_broker_rounds_small_order_up_to_exchange_qty_step(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=20,
max_total_exposure_usdt=80,
max_open_positions=20,
max_positions_per_symbol=20,
max_entries_per_minute=0,
)
storage = Storage(settings.database_path)
broker = PaperBroker(settings, storage)
ticker = Ticker("HYPEUSDT", 39.6, 39.59, 39.61, 10_000_000, 100, 0)
instrument = Instrument("HYPEUSDT", "HYPE", "USDT", "Trading", 0.001, 0.01, 0.01, 1)
position = broker.buy(
Signal("HYPEUSDT", "BUY", 0.8, "small torch edge", {"position_notional_usdt": 1.05}),
ticker,
instrument,
{"HYPEUSDT": 39.6},
)
assert position is not None
assert position.qty == 0.03
assert position.notional_usdt >= instrument.min_notional_value
assert position.notional_usdt <= settings.max_position_usdt
def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
@@ -160,7 +221,7 @@ def test_trend_macd_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -
assert len(broker.open_positions()) == 1
def test_torch_forecast_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -> None:
def test_torch_forecast_broker_allows_dynamic_entries_until_total_limit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
@@ -179,10 +240,41 @@ def test_torch_forecast_broker_blocks_dca_for_same_symbol(make_settings, tmp_pat
first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
third = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "third", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
fourth = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "fourth", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
assert first is not None
assert second is None
assert len(broker.open_positions()) == 1
assert second is not None
assert third is not None
assert fourth is None
assert len(broker.open_positions()) == 3
def test_torch_forecast_broker_respects_configured_symbol_position_limit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_position_usdt=20,
max_symbol_exposure_usdt=20,
max_total_exposure_usdt=80,
max_open_positions=20,
max_positions_per_symbol=2,
max_entries_per_minute=0,
)
storage = Storage(settings.database_path)
broker = PaperBroker(settings, storage)
ticker = Ticker("BTCUSDT", 100, 99.9, 100.1, 10_000_000, 100, 0)
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 1)
first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
third = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "third", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
assert first is not None
assert second is not None
assert third is None
assert len(broker.open_positions()) == 2
def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_settings, tmp_path) -> None:
+687 -6
View File
@@ -233,6 +233,90 @@ def test_trend_macd_exits_on_atr_trailing_stop(make_settings, tmp_path) -> None:
assert "ATR trailing" in signal.reason
def test_trend_macd_holds_below_stop_when_stop_loss_exit_disabled(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="trend_macd",
stop_loss_exit_enabled=False,
atr_trailing_multiplier=2.2,
)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=99.0, ema50=95.0, macd_cross_up=False)
candles[-2].macd = 0.1
candles[-2].macd_signal = 0.0
candles[-1].macd = 0.1
candles[-1].macd_signal = 0.0
candles[-1].atr_14 = 1.0
position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 100.5)
ticker = Ticker("BTCUSDT", 95.5, 95.49, 95.51, 1_000_000, 100, 0)
signal = strategy.exit_signal(position, candles, ticker)
assert signal.action == "HOLD"
assert signal.diagnostics["stop_loss"] is None
assert signal.diagnostics["stop_loss_exit_enabled"] is False
def test_torch_atr_trailing_without_stop_loss_waits_for_profit_stop(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
stop_loss_exit_enabled=False,
atr_trailing_multiplier=2.2,
)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=99.0, ema50=95.0, macd_cross_up=False)
candles[-1].atr_14 = 1.0
position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 101.0)
ticker = Ticker("BTCUSDT", 98.7, 98.69, 98.71, 1_000_000, 100, 0)
signal = strategy.exit_signal(
position,
candles,
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": 0.4,
"probability_up": 0.62,
"skill": 0.18,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["atr_trailing_stop"] is None
def test_torch_atr_trailing_without_stop_loss_can_lock_profit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
stop_loss_exit_enabled=False,
atr_trailing_multiplier=2.2,
)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=104.0, ema50=95.0, macd_cross_up=False)
candles[-1].atr_14 = 1.0
position = Position(1, "BTCUSDT", 1, 100, 100, 0.1, 96, 120, 104.0)
ticker = Ticker("BTCUSDT", 101.7, 101.69, 101.71, 1_000_000, 100, 0)
signal = strategy.exit_signal(
position,
candles,
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": 0.4,
"probability_up": 0.62,
"skill": 0.18,
},
)
assert signal.action == "SELL"
assert "ATR trailing" in signal.reason
def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
@@ -263,7 +347,8 @@ def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_pa
assert signal.action == "BUY"
assert signal.diagnostics["strategy_mode"] == "torch_forecast"
assert signal.diagnostics["checks"]["torch_model_ok"] is True
assert signal.diagnostics["position_notional_usdt"] == 25.0
assert signal.diagnostics["position_sizing"]["method"] == "torch_forecast_fractional_kelly"
assert settings.min_position_usdt <= signal.diagnostics["position_notional_usdt"] <= settings.max_position_usdt
def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path) -> None:
@@ -284,6 +369,169 @@ def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path
assert signal.diagnostics["checks"]["torch_model_ok"] is False
def test_torch_forecast_allows_additional_entries_until_symbol_limit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_symbol_exposure_usdt=3,
max_positions_per_symbol=3,
max_position_usdt=25,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
forecast = {
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.36,
"probability_up": 0.66,
"skill": 0.22,
"block_entry": False,
}
additional = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=1,
forecast=forecast,
account={"equity": 100.0},
)
capped = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=3,
forecast=forecast,
account={"equity": 100.0},
)
assert additional.action == "BUY"
assert capped.action == "HOLD"
assert "symbol position limit" in capped.reason
def test_torch_forecast_kelly_buys_only_remaining_symbol_allocation(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_position_usdt=8,
max_symbol_exposure_usdt=25,
max_positions_per_symbol=6,
stop_loss_percent=0.04,
take_profit_percent=0.035,
kelly_sizing_enabled=True,
kelly_fraction=0.25,
kelly_max_fraction=0.20,
time_series_min_edge_percent=0.10,
time_series_min_probability_up=0.47,
)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
forecast = {
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.60,
"probability_up": 0.84,
"skill": 0.22,
"block_entry": False,
}
first = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=0,
forecast=forecast,
account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 0.0},
)
second = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=1,
forecast=forecast,
account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 8.0},
)
filled = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=2,
forecast=forecast,
account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 20.0},
)
first_sizing = first.diagnostics["position_sizing"]
second_sizing = second.diagnostics["position_sizing"]
assert first.action == "BUY"
assert first_sizing["method"] == "torch_forecast_fractional_kelly"
assert first_sizing["kelly_target_notional_usdt"] > settings.max_position_usdt
assert first.diagnostics["position_notional_usdt"] == settings.max_position_usdt
assert second.action == "BUY"
assert 1 <= second.diagnostics["position_notional_usdt"] < settings.max_position_usdt
assert second_sizing["kelly_open_symbol_exposure_usdt"] == 8.0
assert filled.action == "HOLD"
assert filled.diagnostics["checks"]["risk_size_ok"] is False
def test_torch_forecast_kelly_allows_next_exchange_minimum_layer(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_position_usdt=8,
max_symbol_exposure_usdt=25,
max_total_exposure_usdt=75,
max_positions_per_symbol=6,
stop_loss_percent=0.04,
take_profit_percent=0.035,
kelly_sizing_enabled=True,
kelly_fraction=0.25,
kelly_max_fraction=0.20,
time_series_min_edge_percent=0.10,
time_series_min_probability_up=0.47,
time_series_min_confidence=0.4,
)
strategy = SpotStrategy(settings)
ticker = Ticker("HYPEUSDT", 63.14, 63.13, 63.15, 10_000_000, 1000, 1.0)
signal = strategy.entry_signal(
"HYPEUSDT",
[],
ticker,
open_positions_for_symbol=1,
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.2115,
"probability_up": 0.5163,
"skill": 0.0156,
"block_entry": False,
},
account={
"equity": 98.6,
"cash": 88.54,
"exposure": 10.07,
"symbol": "HYPEUSDT",
"symbol_exposure_usdt": 5.05,
"open_positions_for_symbol": 1,
"exchange_min_entry_usdt": 5.07,
},
)
sizing = signal.diagnostics["position_sizing"]
assert signal.action == "BUY"
assert signal.diagnostics["checks"]["risk_size_ok"] is True
assert sizing["kelly_target_notional_usdt"] < sizing["kelly_open_symbol_exposure_usdt"]
assert sizing["kelly_raw_remaining_notional_usdt"] == 0.0
assert sizing["kelly_layer_mode"] is True
assert signal.diagnostics["position_notional_usdt"] == 5.07
def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
@@ -318,7 +566,7 @@ def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> N
assert signal.diagnostics["checks"]["quality_gate_ok"] is False
def test_torch_forecast_probe_buys_on_positive_high_probability(make_settings, tmp_path) -> None:
def test_torch_forecast_probe_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
@@ -351,11 +599,11 @@ def test_torch_forecast_probe_buys_on_positive_high_probability(make_settings, t
account={"equity": 100.0},
)
assert signal.action == "BUY"
assert signal.action == "HOLD"
assert signal.diagnostics["edge_mode"] == "probe"
assert signal.diagnostics["checks"]["expected_edge_ok"] is True
assert signal.diagnostics["position_sizing"]["edge_mode"] == "probe"
assert settings.min_position_usdt <= signal.diagnostics["position_notional_usdt"] < settings.max_position_usdt
assert signal.diagnostics["checks"]["risk_size_ok"] is False
assert signal.diagnostics["position_notional_usdt"] == 0.0
def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp_path) -> None:
@@ -392,9 +640,227 @@ def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp
assert signal.diagnostics["checks"]["expected_edge_ok"] is False
def test_torch_forecast_rebound_overlay_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
rebound_max_position_usdt=6.0,
time_series_min_edge_percent=0.10,
time_series_probe_min_probability_up=0.55,
max_position_usdt=25,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.01,
"probability_up": 0.56,
"skill": 0.05,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound"]["active"] is True
assert signal.diagnostics["model_rebound_entry_ok"] is True
assert signal.diagnostics["rebound_entry_sized_ok"] is False
assert signal.diagnostics["checks"]["expected_edge_ok"] is False
assert signal.diagnostics["checks"]["risk_size_ok"] is False
def test_torch_forecast_rebound_overlay_does_not_buy_negative_forecast(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
time_series_min_edge_percent=0.10,
time_series_probe_min_probability_up=0.55,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": -0.01,
"probability_up": 0.56,
"skill": 0.05,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound"]["active"] is True
assert signal.diagnostics["rebound_entry_ok"] is False
assert signal.diagnostics["edge_mode"] == "blocked"
def test_torch_forecast_rebound_fallback_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
rebound_max_position_usdt=6.0,
time_series_rebound_fallback_enabled=True,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="HYPEUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"HYPEUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["fallback_rebound_entry_ok"] is True
assert signal.diagnostics["rebound_entry_sized_ok"] is False
assert signal.diagnostics["missing_torch_model"] is True
assert signal.diagnostics["checks"]["risk_size_ok"] is False
def test_torch_forecast_rebound_fallback_can_be_disabled(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
time_series_rebound_fallback_enabled=False,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="HYPEUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"HYPEUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["missing_torch_model"] is True
assert signal.diagnostics["fallback_rebound_entry_ok"] is False
def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", stop_loss_percent=0.04)
strategy = SpotStrategy(settings)
position = Position(
1,
"SOLUSDT",
1,
100,
100,
0.1,
96,
120,
103,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("SOLUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(),
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": -0.08,
"probability_up": 0.43,
"skill": 0.18,
"block_entry": True,
},
)
assert signal.action == "SELL"
assert "прогноз" in signal.reason
def test_torch_forecast_holds_negative_forecast_during_min_hold(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
strategy = SpotStrategy(settings)
position = Position(1, "SOLUSDT", 1, 100, 100, 0.1, 96, 120, 103)
ticker = Ticker("SOLUSDT", 101, 100.99, 101.01, 10_000_000, 1000, 1.0)
@@ -412,8 +878,223 @@ def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_pa
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["forecast_exit_blocked_by_min_hold"] is True
def test_torch_forecast_holds_fee_churn_exit_after_min_hold(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60)
strategy = SpotStrategy(settings)
position = Position(
1,
"BTCUSDT",
1,
100,
100,
0.1,
96,
120,
100.1,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("BTCUSDT", 99.99, 99.98, 100.0, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(),
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": -0.01,
"probability_up": 0.499,
"skill": 0.18,
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["forecast_exit_blocked_by_cost"] is True
def test_torch_forecast_holds_atr_trailing_exit_that_does_not_cover_fees(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=100.2)
candles[-1].atr_14 = 0.8
position = Position(
1,
"MNTUSDT",
1,
100,
100,
0.1,
96,
120,
102,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("MNTUSDT", 100.2, 100.19, 100.21, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
candles,
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": 0.4,
"probability_up": 0.58,
"skill": 0.18,
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["atr_exit_blocked_by_cost"] is True
def test_torch_forecast_holds_atr_trailing_exit_below_min_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20)
strategy = SpotStrategy(settings)
candles = _trend_entry_candles(close=100.35)
candles[-1].atr_14 = 0.6
position = Position(
1,
"MNTUSDT",
1,
100,
100,
0.1,
96,
120,
102,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("MNTUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
candles,
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": 0.4,
"probability_up": 0.58,
"skill": 0.18,
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["atr_exit_blocked_by_min_profit"] is True
assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent
def test_torch_forecast_holds_negative_forecast_exit_below_min_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20)
strategy = SpotStrategy(settings)
position = Position(
1,
"BTCUSDT",
1,
100,
100,
0.1,
96,
120,
100.5,
opened_at=utc_now() - timedelta(seconds=600),
)
ticker = Ticker("BTCUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=100.35),
ticker,
forecast={
"usable": True,
"model": "torch_lstm",
"expected_return_percent": -0.2,
"probability_up": 0.40,
"skill": 0.18,
"block_entry": False,
"reason": "model turned down",
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["forecast_exit_blocked_by_min_profit"] is True
assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent
def test_torch_forecast_rebound_fallback_holds_without_model(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
strategy = SpotStrategy(settings)
position = Position(
1,
"XRPUSDT",
5,
1.0,
5.0,
0.005,
0.96,
1.035,
1.0,
entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"},
)
ticker = Ticker("XRPUSDT", 1.001, 1.0009, 1.0011, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=1.0, ema50=0.98),
ticker,
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound_fallback_position"] is True
assert "rebound fallback" in signal.reason
def test_torch_forecast_rebound_fallback_still_sells_take_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast")
strategy = SpotStrategy(settings)
position = Position(
1,
"XRPUSDT",
5,
1.0,
5.0,
0.005,
0.96,
1.035,
1.04,
opened_at=utc_now() - timedelta(seconds=600),
entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"},
)
ticker = Ticker("XRPUSDT", 1.036, 1.0359, 1.0361, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=1.0, ema50=0.98),
ticker,
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
)
assert signal.action == "SELL"
assert "torch_forecast" in signal.reason
assert "take-profit" in signal.reason
def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None:
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import base64
import hashlib
import json
from crypto_spot_bot.training_coordination import TrainingCoordinator
def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
requested = coordinator.request_retrain({"source": "android"})
job_id = requested["job"]["id"]
heartbeat = coordinator.heartbeat({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
claimed = coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
assert requested["queued"] is True
assert heartbeat["status"]["agent_online"] is True
assert claimed["claimed"] is True
assert claimed["job"]["id"] == job_id
assert coordinator.status()["active_job"]["status"] == "running"
progress = coordinator.progress(
job_id,
{"status": "running", "phase": "training", "progress_percent": 42, "message": "epoch 1"},
)
assert progress["job"]["phase"] == "training"
assert progress["job"]["progress_percent"] == 42
assert coordinator.status()["active_job"]["message"] == "epoch 1"
completed = coordinator.complete(job_id, {"success": True, "message": "ok"})
assert completed["job"]["status"] == "completed"
assert coordinator.status()["active_job"] is None
def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
job = coordinator.request_retrain({"source": "test"})["job"]
payload = b'{"type":"pytorch_recurrent_forecaster","symbols":{}}\n'
sha256 = hashlib.sha256(payload).hexdigest()
first = payload[:20]
second = payload[20:]
part_1 = coordinator.save_artifact_chunk(
job["id"],
{
"name": "lstm_forecaster.json",
"index": 0,
"total": 2,
"sha256": sha256,
"data_base64": base64.b64encode(first).decode("ascii"),
},
)
part_2 = coordinator.save_artifact_chunk(
job["id"],
{
"name": "lstm_forecaster.json",
"index": 1,
"total": 2,
"sha256": sha256,
"data_base64": base64.b64encode(second).decode("ascii"),
},
)
assert part_1["complete"] is False
assert part_2["complete"] is True
assert (tmp_path / "lstm_forecaster.json").read_bytes() == payload
assert coordinator.status()["latest_job"]["artifacts"][0]["sha256"] == sha256
def test_running_claimed_job_keeps_agent_online_when_heartbeat_is_stale(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
coordinator.request_retrain({"source": "android"})
coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
state_path = tmp_path / "training_coordination.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
state["worker"]["last_seen_at"] = "2026-01-01T00:00:00+00:00"
state_path.write_text(json.dumps(state), encoding="utf-8")
status = coordinator.status()
assert status["agent_recently_seen"] is False
assert status["agent_busy"] is True
assert status["agent_online"] is True
+1 -1
View File
@@ -74,7 +74,7 @@ def _decision(
return {"accepted": False, "reason": "candidate_expectancy_non_positive"}
if int(walk_summary.get("trades", 0) or 0) >= min_trades and float(walk_summary.get("avg_net_percent", 0.0) or 0.0) <= min_avg_net_percent:
return {"accepted": False, "reason": "candidate_walk_forward_expectancy_non_positive"}
if current_score > 0 and candidate_score < current_score * (1.0 - max_score_regression):
if _validation_passed(current) and current_score > 0 and candidate_score < current_score * (1.0 - max_score_regression):
return {"accepted": False, "reason": "candidate_score_regressed_vs_current"}
return {"accepted": True, "reason": "candidate_passed_guard"}
+23 -11
View File
@@ -222,13 +222,13 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--min-trades", type=int, default=12, help="Minimum non-overlapping trades for recommendation.")
parser.add_argument("--min-full-replay-trades", type=int, default=8, help="Prefer recommendations with at least this many full replay trades.")
parser.add_argument("--edge-grid", default="0.00,0.02,0.04,0.05,0.06,0.08,0.10", help="Percent edge thresholds.")
parser.add_argument("--probability-grid", default="0.55,0.56,0.57,0.58,0.59,0.60,0.62,0.64,0.66,0.68,0.70", help="P(up) thresholds.")
parser.add_argument("--confidence-grid", default="0.40,0.50,0.56,0.60,0.64,0.68,0.72", help="Confidence thresholds.")
parser.add_argument("--probability-grid", default="0.45,0.47,0.50,0.52,0.54,0.55,0.56,0.58,0.60,0.62,0.64,0.66,0.68,0.70", help="P(up) thresholds.")
parser.add_argument("--confidence-grid", default="0.40", help="Confidence thresholds.")
parser.add_argument("--top", type=int, default=15, help="How many top results to print and save.")
parser.add_argument("--output", default="", help="Optional JSON output path.")
parser.add_argument("--batch-size", type=int, default=256, help="Torch inference batch size.")
parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.")
parser.add_argument("--walk-forward-folds", type=int, default=4, help="Threshold walk-forward folds.")
parser.add_argument("--walk-forward-folds", type=int, default=8, help="Threshold walk-forward folds.")
parser.add_argument("--min-oos-trades", type=int, default=30, help="Minimum out-of-sample walk-forward trades for a valid model.")
parser.add_argument("--min-oos-symbols", type=int, default=2, help="Minimum symbols with out-of-sample trades.")
parser.add_argument("--max-oos-symbol-share", type=float, default=0.75, help="Reject if one symbol contributes more than this share of out-of-sample trades.")
@@ -569,6 +569,7 @@ def _full_backtest(
rows: list[dict[str, Any]] = []
max_hold = max(12, horizon * 8)
stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True))
atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier)))
for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)):
position = positions.get(record.symbol)
@@ -576,10 +577,15 @@ def _full_backtest(
position["highest"] = max(position["highest"], record.close)
net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost)
held = record.index - int(position["entry_index"])
atr_stop = (
record.close <= position["highest"] - record.atr * atr_multiplier
atr_stop_level = (
position["highest"] - record.atr * atr_multiplier
if record.atr > 0 and position["highest"] > position["entry_price"]
else False
else None
)
atr_stop = bool(
atr_stop_level is not None
and record.close <= atr_stop_level
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
)
weak_forecast = (
record.expected_percent < thresholds.edge
@@ -587,7 +593,7 @@ def _full_backtest(
or record.skill <= 0.0
)
exit_reason = ""
if net_percent <= -stop_loss_percent:
if stop_loss_exit_enabled and net_percent <= -stop_loss_percent:
exit_reason = "stop_loss"
elif atr_stop:
exit_reason = "atr_trailing_stop"
@@ -663,6 +669,7 @@ def _benchmark_backtest(
rows: list[dict[str, Any]] = []
max_hold = max(12, horizon * 8)
stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True))
atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier)))
for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)):
position = positions.get(record.symbol)
@@ -670,13 +677,18 @@ def _benchmark_backtest(
position["highest"] = max(position["highest"], record.close)
net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost)
held = record.index - int(position["entry_index"])
atr_stop = (
record.close <= position["highest"] - record.atr * atr_multiplier
atr_stop_level = (
position["highest"] - record.atr * atr_multiplier
if record.atr > 0 and position["highest"] > position["entry_price"]
else False
else None
)
atr_stop = bool(
atr_stop_level is not None
and record.close <= atr_stop_level
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
)
exit_reason = ""
if net_percent <= -stop_loss_percent:
if stop_loss_exit_enabled and net_percent <= -stop_loss_percent:
exit_reason = "stop_loss"
elif atr_stop:
exit_reason = "atr_trailing_stop"
+1 -1
View File
@@ -2,7 +2,7 @@
param(
[string]$TaskName = "TradeBot PyTorch Forecaster Retrainer",
[int]$EveryHours = 6,
[string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT",
[string]$Symbols = "",
[int]$Limit = 3000,
[int]$Horizon = 0,
[string]$Horizons = "",
+119
View File
@@ -0,0 +1,119 @@
[CmdletBinding()]
param(
[string]$TaskName = "TradeBot Windows Training Agent",
[string]$ApiBaseUrl = "https://tb.kusoft.xyz",
[string]$ApiAuth = "",
[int]$PollSeconds = 10,
[int]$WatchdogMinutes = 5,
[string]$RepoRoot = "",
[switch]$StartNow,
[switch]$KeepLegacyRetrainer
)
$ErrorActionPreference = "Stop"
if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
}
$Agent = Join-Path $RepoRoot "tools\windows_training_agent.py"
if (-not (Test-Path $Agent)) {
throw "Windows training agent not found: $Agent"
}
function Resolve-Python {
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
return $venvPython
}
$userPython = Join-Path $env:LOCALAPPDATA "Programs\TradeBotPython312\python.exe"
if (Test-Path $userPython) {
return $userPython
}
foreach ($candidate in @("python.exe", "python")) {
$command = Get-Command $candidate -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
}
throw "Python was not found. Create .venv or install Python 3.12."
}
function Resolve-WindowlessPython {
$python = Resolve-Python
$pythonw = Join-Path (Split-Path -Parent $python) "pythonw.exe"
if (Test-Path $pythonw) {
return $pythonw
}
return $python
}
if ($ApiAuth) {
[Environment]::SetEnvironmentVariable("TRADEBOT_API_AUTH", $ApiAuth, "User")
$env:TRADEBOT_API_AUTH = $ApiAuth
}
[Environment]::SetEnvironmentVariable("TRADEBOT_API_BASE_URL", $ApiBaseUrl, "User")
[Environment]::SetEnvironmentVariable("TRADEBOT_TRAINING_WORKER_NAME", $env:COMPUTERNAME, "User")
$env:TRADEBOT_API_BASE_URL = $ApiBaseUrl
$env:TRADEBOT_TRAINING_WORKER_NAME = $env:COMPUTERNAME
if (-not $KeepLegacyRetrainer) {
foreach ($legacyName in @("TradeBot PyTorch Forecaster Retrainer", "TradeBot LSTM Retrainer")) {
$legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue
if ($legacyTask) {
Unregister-ScheduledTask -TaskName $legacyName -Confirm:$false
Write-Host "Removed legacy scheduled task '$legacyName'."
}
}
}
$python = Resolve-WindowlessPython
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$arguments = @(
"-u",
"`"$Agent`"",
"--repo-root", "`"$RepoRoot`"",
"--api-base-url", "`"$ApiBaseUrl`"",
"--poll-seconds", $PollSeconds.ToString()
) -join " "
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $RepoRoot
$trigger = @(
New-ScheduledTaskTrigger -AtLogOn -User $currentUser
New-ScheduledTaskTrigger -AtStartup
New-ScheduledTaskTrigger `
-Once `
-At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
-RepetitionDuration (New-TimeSpan -Days 3650)
)
$principal = New-ScheduledTaskPrincipal `
-UserId $currentUser `
-LogonType Interactive `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-MultipleInstances IgnoreNew `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-RestartCount 999 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit (New-TimeSpan -Days 30)
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Description "Keeps the TradeBot Windows training agent online and polls the public bot API for retrain jobs." `
-Force | Out-Null
if ($StartNow) {
Start-ScheduledTask -TaskName $TaskName
}
Write-Host "Registered scheduled task '$TaskName' for Windows startup, logon, and watchdog restarts."
Write-Host "Agent API: $ApiBaseUrl"
Write-Host "Agent script: $Agent"
+4 -2
View File
@@ -2,7 +2,7 @@
param(
[int]$MinReplayTrades = 8,
[int]$MaxAttempts = 0,
[string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT",
[string]$Symbols = "",
[int]$Limit = 3000,
[switch]$DeployToPi,
[string]$PiHost = "192.168.0.185",
@@ -119,10 +119,12 @@ while ($true) {
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", $Runner,
"-Symbols", $Symbols,
"-Limit", $Limit.ToString(),
"-Seed", $attemptSeed.ToString()
)
if ($Symbols) {
$runnerArgs += @("-Symbols", $Symbols)
}
if ($DeployToPi) {
$runnerArgs += "-DeployToPi"
if ($PiHost) { $runnerArgs += @("-PiHost", $PiHost) }
+5 -3
View File
@@ -190,9 +190,11 @@ try {
$calibrationBaseArgs = @(
"-u",
"tools\calibrate_torch_thresholds.py",
"--limit", "2000",
"--calibration-window", "720",
"--min-trades", "12"
"--limit", "3000",
"--calibration-window", "1200",
"--min-trades", "60",
"--walk-forward-folds", "8",
"--confidence-grid", "0.40"
)
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
+21 -5
View File
@@ -118,6 +118,10 @@ def main() -> None:
target_horizons = _horizons(args.horizons, decision_horizon)
feature_names = _feature_names_arg(args.features)
round_trip_cost = max(0.0, 2.0 * (float(settings.taker_fee_rate) + float(settings.slippage_rate)))
_progress(
f"training started: symbols={len(symbols)} interval={interval} "
f"limit={args.limit} epochs={args.epochs}"
)
artifact: dict[str, Any] = {
"version": 4,
@@ -141,7 +145,9 @@ def main() -> None:
"symbols": {},
}
for symbol in symbols:
total_symbols = len(symbols)
for index, symbol in enumerate(symbols, start=1):
_progress(f"{symbol}: training started ({index}/{total_symbols})")
result = _train_symbol(
client=client,
symbol=symbol,
@@ -170,10 +176,10 @@ def main() -> None:
seed=args.seed,
)
if result is None:
print(f"{symbol}: skipped, not enough candles or train/validation samples")
_progress(f"{symbol}: skipped, not enough candles or train/validation samples")
continue
artifact["symbols"][symbol] = result
print(
_progress(
f"{symbol}: model={result['model']} lookback={result['lookback']} "
f"features={result['input_size']} hidden={result['hidden_size']} "
f"layers={result['num_layers']} horizons={','.join(map(str, result['target_horizons']))} "
@@ -187,7 +193,11 @@ def main() -> None:
tmp_output = output.with_name(f"{output.name}.tmp")
tmp_output.write_text(json.dumps(artifact, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_output.replace(output)
print(f"saved {output}")
_progress(f"saved {output}")
def _progress(message: str) -> None:
print(message, flush=True)
def _parse_args() -> argparse.Namespace:
@@ -274,12 +284,13 @@ def _train_symbol(
add_indicators(rows)
market_candles[context_symbol] = rows
except Exception as exc:
print(f"{symbol}: context {context_symbol} skipped: {exc}")
_progress(f"{symbol}: context {context_symbol} skipped: {exc}")
trend_candles = _historical_klines(client, symbol, "D", min(max(260, limit // 24 + 260), 1000))
add_indicators(trend_candles)
best: dict[str, Any] | None = None
for lookback in lookbacks:
_progress(f"{symbol}: preparing lookback={lookback}")
prepared = _prepare_data(
candles=candles,
feature_names=feature_names,
@@ -307,6 +318,11 @@ def _train_symbol(
for dropout in dropouts:
if num_layers <= 1 and dropout != 0.0:
continue
_progress(
f"{symbol}: fitting {architecture} "
f"lookback={lookback} hidden={hidden_size} "
f"layers={num_layers} dropout={dropout}"
)
candidate = _fit_candidate(
prepared=prepared,
architecture=architecture,
+421
View File
@@ -0,0 +1,421 @@
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import platform
import queue
import re
import subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.error import HTTPError
from urllib.error import URLError
from urllib.request import Request
from urllib.request import urlopen
ARTIFACT_NAMES = (
"lstm_forecaster.json",
"torch_retrain_guard.json",
"torch_threshold_calibration.json",
)
def main() -> None:
args = parse_args()
repo_root = Path(args.repo_root).resolve()
runtime_dir = repo_root / "runtime"
runtime_dir.mkdir(parents=True, exist_ok=True)
log_path = Path(args.log_file).resolve() if args.log_file else runtime_dir / "windows_training_agent.log"
log(log_path, f"TradeBot Windows training agent started for {args.api_base_url}")
while True:
try:
poll_once(args, repo_root, runtime_dir, log_path)
except Exception as exc: # noqa: BLE001 - agent must keep running.
log(log_path, f"ERROR: {exc}")
if args.once:
break
time.sleep(max(5, args.poll_seconds))
def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_path: Path) -> None:
worker = worker_payload(args, repo_root)
api_json(args, "/api/training/heartbeat", worker)
claim = api_json(args, "/api/training/claim", worker)
if not claim.get("claimed"):
return
job = claim.get("job") if isinstance(claim.get("job"), dict) else {}
job_id = str(job.get("id") or "")
if not job_id:
return
log(log_path, f"Claimed retrain job {job_id}")
report_progress(args, job_id, "running", "claimed", 2, "Задание получено Windows-agent")
success = False
message = ""
summary: dict[str, Any] = {}
try:
run_retrain(args, job_id, job, repo_root, log_path)
summary = read_json(runtime_dir / "torch_retrain_guard.json")
report_progress(args, job_id, "running", "uploading", 72, "Обучение завершено, загружаю артефакты")
for name in ARTIFACT_NAMES:
path = runtime_dir / name
if path.is_file():
upload_artifact(args, job_id, path, log_path)
success = True
message = "training completed"
log(log_path, f"Completed retrain job {job_id}")
except Exception as exc: # noqa: BLE001 - report failure to the bot.
message = str(exc)
log(log_path, f"Job {job_id} failed: {message}")
finally:
payload = {"success": success, "message": message, "summary": summary}
api_json(args, f"/api/training/jobs/{job_id}/complete", payload)
def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo_root: Path, log_path: Path) -> None:
script = repo_root / "tools" / "run_torch_retrain.ps1"
if not script.is_file():
raise RuntimeError(f"retrain script not found: {script}")
cmd = [
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
]
parameters = job.get("parameters") if isinstance(job.get("parameters"), dict) else {}
arg_map = {
"symbols": "-Symbols",
"limit": "-Limit",
"lookbacks": "-Lookbacks",
"architectures": "-Architectures",
"hidden_sizes": "-HiddenSizes",
"layers": "-Layers",
"dropouts": "-Dropouts",
"epochs": "-Epochs",
}
for key, ps_arg in arg_map.items():
value = parameters.get(key)
if value not in (None, ""):
cmd.extend([ps_arg, str(value)])
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
report_progress(args, job_id, "running", "training", 8, "PyTorch retrain запущен")
line_count = 0
output_queue: queue.Queue[str] = queue.Queue()
def read_output() -> None:
assert process.stdout is not None
for raw_line in process.stdout:
output_queue.put(raw_line.rstrip())
with subprocess.Popen(
cmd,
cwd=str(repo_root),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
**hidden_subprocess_kwargs(),
) as process:
reader = threading.Thread(target=read_output, name="training-output-reader", daemon=True)
reader.start()
last_report_at = 0.0
started_at = time.monotonic()
last_output_at = started_at
last_message = "PyTorch retrain выполняется"
while True:
got_line = False
try:
message = output_queue.get(timeout=5)
got_line = True
last_output_at = time.monotonic()
log(log_path, message)
line_count += 1
if message:
last_message = friendly_training_message(message)
except queue.Empty:
pass
progress = min(70, 8 + line_count // 3)
now = time.monotonic()
if got_line or now - last_report_at >= 30:
report_message = last_message
if not got_line:
report_message = training_heartbeat_message(now, started_at, last_output_at, last_message)
safe_report_progress(args, job_id, "running", "training", progress, report_message, log_path)
last_report_at = now
if process.poll() is not None and output_queue.empty():
break
reader.join(timeout=2)
code = process.wait()
if code != 0:
raise RuntimeError(f"retrain failed with exit code {code}")
report_progress(args, job_id, "running", "guard", 70, "Guard завершён, подготавливаю артефакты")
def friendly_training_message(message: str) -> str:
cleaned = message.strip()
if not cleaned:
return "PyTorch обучает модель"
if "Starting PyTorch recurrent retrain:" in cleaned:
return "PyTorch LSTM/GRU запущен: готовлю данные и варианты модели"
started = re.search(
r"training started: symbols=(?P<symbols>\d+) interval=(?P<interval>\d+) "
r"limit=(?P<limit>\d+) epochs=(?P<epochs>\d+)",
cleaned,
)
if started:
interval = started.group("interval")
timeframe = "1h" if interval == "60" else f"{interval}m"
return (
f"Старт обучения: {started.group('symbols')} пар, таймфрейм {timeframe}, "
f"история {started.group('limit')} свечей, до {started.group('epochs')} эпох"
)
pair_started = re.search(r"^(?P<symbol>[A-Z0-9]+): training started \((?P<index>\d+)/(?P<total>\d+)\)", cleaned)
if pair_started:
return (
f"{pair_started.group('symbol')}: обучение пары "
f"{pair_started.group('index')}/{pair_started.group('total')}"
)
preparing = re.search(r"^(?P<symbol>[A-Z0-9]+): preparing lookback=(?P<lookback>\d+)", cleaned)
if preparing:
return f"{preparing.group('symbol')}: готовлю окно {preparing.group('lookback')} свечей"
fitting = re.search(
r"^(?P<symbol>[A-Z0-9]+): fitting (?P<arch>lstm|gru) "
r"lookback=(?P<lookback>\d+) hidden=(?P<hidden>\d+) "
r"layers=(?P<layers>\d+) dropout=(?P<dropout>[0-9.]+)",
cleaned,
)
if fitting:
return (
f"{fitting.group('symbol')}: обучаю {fitting.group('arch').upper()}, "
f"окно {fitting.group('lookback')}, нейронов {fitting.group('hidden')}, "
f"слоёв {fitting.group('layers')}, dropout {fitting.group('dropout')}"
)
model = re.search(
r"^(?P<symbol>[A-Z0-9]+): model=torch_(?P<arch>lstm|gru).*?"
r"mae=(?P<mae>[0-9.]+)%.*?skill=(?P<skill>-?[0-9.]+).*?dir=(?P<direction>[0-9.]+)",
cleaned,
)
if model:
direction = float(model.group("direction")) * 100
skill = float(model.group("skill")) * 100
return (
f"{model.group('symbol')}: выбран {model.group('arch').upper()}, "
f"ошибка {model.group('mae')}%, skill {skill:.1f}%, направление {direction:.1f}%"
)
if "Calibrating current artifact" in cleaned:
return "Проверяю текущую модель на replay"
if "Calibrating candidate artifact" in cleaned:
return "Проверяю новую модель на replay"
if "Running retrain guard" in cleaned:
return "Gate сравнивает новую модель с текущей"
if "Candidate rejected by guard" in cleaned:
return "Новая модель обучилась, но gate не дал ей ходу"
if "Candidate accepted by guard" in cleaned:
return "Новая модель прошла gate и стала активной"
return cleaned[-220:]
def training_heartbeat_message(now: float, started_at: float, last_output_at: float, last_message: str) -> str:
elapsed = format_duration(now - started_at)
idle_seconds = max(0.0, now - last_output_at)
if idle_seconds >= 45:
return (
f"PyTorch обучает модель: процесс активен {elapsed}; "
f"последний лог {format_duration(idle_seconds)} назад: {last_message[:140]}"
)
return last_message or f"PyTorch обучает модель: процесс активен {elapsed}"
def format_duration(seconds: float) -> str:
total_seconds = max(0, int(seconds))
minutes, seconds_part = divmod(total_seconds, 60)
hours, minutes_part = divmod(minutes, 60)
if hours:
return f"{hours}ч {minutes_part}м"
if minutes:
return f"{minutes}м {seconds_part}с"
return f"{seconds_part}с"
def upload_artifact(args: argparse.Namespace, job_id: str, path: Path, log_path: Path) -> None:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
size = path.stat().st_size
chunk_size = max(64 * 1024, args.chunk_size)
total = max(1, (size + chunk_size - 1) // chunk_size)
log(log_path, f"Uploading {path.name}: {size} bytes, {total} chunks")
with path.open("rb") as source:
for index in range(total):
data = source.read(chunk_size)
payload = {
"name": path.name,
"index": index,
"total": total,
"sha256": digest,
"data_base64": base64.b64encode(data).decode("ascii"),
}
api_json(args, f"/api/training/jobs/{job_id}/artifacts/chunk", payload, timeout=120)
if index == 0 or index == total - 1 or index % 10 == 0:
progress = 72 + int(((index + 1) / total) * 23)
report_progress(args, job_id, "running", "uploading", progress, f"Загружаю {path.name}: {index + 1}/{total}")
def report_progress(
args: argparse.Namespace,
job_id: str,
status: str,
phase: str,
progress_percent: int,
message: str,
) -> None:
api_json(
args,
f"/api/training/jobs/{job_id}/progress",
{
"status": status,
"phase": phase,
"progress_percent": progress_percent,
"message": message,
"worker": worker_payload(args, Path(args.repo_root).resolve()),
},
)
def safe_report_progress(
args: argparse.Namespace,
job_id: str,
status: str,
phase: str,
progress_percent: int,
message: str,
log_path: Path,
) -> None:
last_error: Exception | None = None
for attempt in range(1, 4):
try:
report_progress(args, job_id, status, phase, progress_percent, message)
return
except Exception as exc: # noqa: BLE001 - keep the local training process alive.
last_error = exc
if attempt < 3:
time.sleep(attempt * 2)
log(log_path, f"Temporary progress upload error; training continues: {last_error}")
def api_json(args: argparse.Namespace, path: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
url = args.api_base_url.rstrip("/") + path
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
token = args.api_auth or os.environ.get("TRADEBOT_API_AUTH", "")
headers.update(auth_headers(token))
request = Request(url, data=body, headers=headers, method="POST")
try:
with urlopen(request, timeout=timeout) as response:
text = response.read().decode("utf-8")
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} {path}: {detail[:300]}") from exc
except URLError as exc:
raise RuntimeError(f"network error {path}: {exc.reason}") from exc
return json.loads(text) if text.strip() else {}
def auth_headers(token: str) -> dict[str, str]:
value = token.strip()
if not value:
return {}
headers = {"X-TradeBot-Token": value}
if value.lower().startswith(("basic ", "bearer ")):
headers["Authorization"] = value
elif ":" in value:
encoded = base64.b64encode(value.encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {encoded}"
else:
headers["Authorization"] = f"Bearer {value}"
return headers
def worker_payload(args: argparse.Namespace, repo_root: Path) -> dict[str, Any]:
name = args.worker_name or platform.node() or "Windows training host"
return {
"worker_id": args.worker_id or f"{name}:{repo_root}",
"name": name,
"path": str(repo_root),
"version": "1",
}
def log(path: Path, message: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().astimezone().isoformat(timespec="seconds")
line = f"[{stamp}] {message}"
if sys.stdout is not None:
try:
print(line, flush=True)
except OSError:
pass
with path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
def read_json(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def hidden_subprocess_kwargs() -> dict[str, Any]:
if os.name != "nt":
return {}
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0
return {
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
"startupinfo": startupinfo,
}
def quote_for_log(value: str) -> str:
return f'"{value}"' if " " in value else value
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Poll TradeBot for retrain jobs and execute them on Windows.")
parser.add_argument("--api-base-url", default=os.environ.get("TRADEBOT_API_BASE_URL", "https://tb.kusoft.xyz"))
parser.add_argument("--api-auth", default=os.environ.get("TRADEBOT_API_AUTH", ""))
parser.add_argument("--repo-root", default=str(Path(__file__).resolve().parents[1]))
parser.add_argument("--worker-id", default=os.environ.get("TRADEBOT_TRAINING_WORKER_ID", ""))
parser.add_argument("--worker-name", default=os.environ.get("TRADEBOT_TRAINING_WORKER_NAME", ""))
parser.add_argument("--poll-seconds", type=int, default=int(os.environ.get("TRADEBOT_TRAINING_POLL_SECONDS", "60")))
parser.add_argument("--chunk-size", type=int, default=int(os.environ.get("TRADEBOT_TRAINING_CHUNK_SIZE", str(512 * 1024))))
parser.add_argument("--log-file", default=os.environ.get("TRADEBOT_TRAINING_LOG", ""))
parser.add_argument("--once", action="store_true")
return parser.parse_args()
if __name__ == "__main__":
main()