From da5348316406d3a5e5c1ffa9e792dca879f4e807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Sun, 12 Jul 2026 22:56:49 +0300 Subject: [PATCH] Fix remote training and model validation pipeline --- README.md | 10 +- android/TradeBotMonitor/README.md | 8 +- android/TradeBotMonitor/app/build.gradle.kts | 4 +- .../xyz/kusoft/tradebotmonitor/AppPrefs.kt | 15 +- .../kusoft/tradebotmonitor/MainActivity.kt | 111 ++-- .../xyz/kusoft/tradebotmonitor/TradeBotApi.kt | 16 +- .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/TradeBotMonitor/gradlew | 248 +++++++++ android/TradeBotMonitor/gradlew.bat | 93 ++++ crypto_spot_bot/strategy.py | 20 +- crypto_spot_bot/time_series.py | 107 +++- crypto_spot_bot/training_coordination.py | 11 +- tests/test_calibrate_thresholds.py | 87 +++ tests/test_time_series.py | 54 ++ tests/test_training_coordination.py | 36 ++ tools/calibrate_torch_thresholds.py | 272 +++++++++- tools/install_windows_training_agent.ps1 | 207 +++++--- tools/run_torch_retrain.ps1 | 26 +- tools/run_windows_training_agent.ps1 | 82 +++ tools/train_torch_recurrent_forecaster.py | 494 ++++++++++++++++-- tools/windows_training_agent.py | 29 +- 22 files changed, 1691 insertions(+), 246 deletions(-) create mode 100644 android/TradeBotMonitor/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/TradeBotMonitor/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/TradeBotMonitor/gradlew create mode 100644 android/TradeBotMonitor/gradlew.bat create mode 100644 tests/test_calibrate_thresholds.py create mode 100644 tools/run_windows_training_agent.ps1 diff --git a/README.md b/README.md index 58fd99c..f9d12e3 100644 --- a/README.md +++ b/README.md @@ -95,12 +95,16 @@ powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.p Для удалённого запуска с телефона или с бота используется Windows training agent. Бот на `tb.kusoft.xyz` хранит очередь заданий, а Windows-машина сама подключается к интернету, забирает задания, обучает модель и загружает артефакты обратно: ```powershell -powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps1 -ApiAuth "login:password" -StartNow +powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps1 -ApiAuth "" -StartNow ``` -Установщик регистрирует Scheduled Task `TradeBot Windows Training Agent` при входе в Windows и удаляет старые локальные retrain-задачи, чтобы обучение запускалось через очередь, а не двумя независимыми механизмами. +Установщик сохраняет worker-токен через Windows DPAPI, удаляет его старую plaintext-копию из пользовательского окружения и включает постоянный запуск агента. С правами администратора используется Scheduled Task с watchdog; без повышения прав — штатный ярлык в пользовательской папке Startup. Старые локальные retrain-задачи удаляются, чтобы обучение запускалось через очередь, а не двумя независимыми механизмами. -По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на 12 spot-парах из `SYMBOLS`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`. +По умолчанию Windows-agent обучает pooled multi-asset PyTorch `LSTM/GRU` на `6000` часовых свечах: общие recurrent-веса получают one-hot embedding символа, прогноз усредняется по seed `7/19/43`, модели сравниваются на validation-folds, а пороги калибруются отдельно для каждой пары. Search space использует lookback `32/64/128`, hidden `64/96`, dropout `0.20`, AdamW learning rate `0.0007` и weight decay `0.0005`; untouched holdout и quality gate не ослабляются. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_ENSEMBLE_SEEDS`, `TORCH_RETRAIN_SELECTION_FOLDS`, `TORCH_RETRAIN_LEARNING_RATE`, `TORCH_RETRAIN_WEIGHT_DECAY`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`. + +Loss и выбор гиперпараметров учитывают after-cost trading utility и ранговую связь прогноза с будущей доходностью, а не только MAE. В каждом walk-forward fold вероятность `P(up)` калибруется Platt-моделью исключительно на train-части; затем на этой же train-части выбираются глобальные и per-symbol пороги, которые применяются к test-части. Калибратор не имеет fallback на единичные сделки: если минимальная статистика не набрана, кандидат получает `calibration_insufficient` и не может пройти gate. + +Основной decision horizon — `12h`, дополнительные горизонты — `3/6/12/24`. Это согласует прогноз с round-trip cost: при текущих fee/slippage полный вход-выход стоит около `0.26%`, поэтому прежний `3h` target чаще описывал шум, который не покрывал издержки. Threshold search оценивается тем же execution replay со stop-loss, take-profit, ATR trailing и forecast-exit, который используется в walk-forward. `holdout_skill` остаётся только в финальном отчёте и никогда не участвует в фильтрации входов или подборе порогов. Если retrain запускается с `-DeployToPi`, после успешного guard он синхронизирует `runtime/lstm_forecaster.json`, `runtime/torch_retrain_guard.json` и `runtime/torch_threshold_calibration.json` на Raspberry Pi через SSH-ключ и перезапускает сервис `tradebot`. Отдельный запуск sync: diff --git a/android/TradeBotMonitor/README.md b/android/TradeBotMonitor/README.md index b2c5358..1d82a97 100644 --- a/android/TradeBotMonitor/README.md +++ b/android/TradeBotMonitor/README.md @@ -38,15 +38,11 @@ https://tb.kusoft.xyz Этот адрес установлен в приложении по умолчанию. Если в настройках ввести просто `tb.kusoft.xyz`, приложение само добавит `https://`. -Если домен защищён авторизацией, в поле `API auth` можно указать: - -- `login:password` — приложение отправит HTTP Basic; -- `Basic ...` — готовый Basic header; -- `Bearer ...` или просто токен — приложение отправит Bearer. +В поле `API-токен` указывается отдельный токен Android-клиента (`TRADEBOT_API_TOKEN` на сервере). Логин и пароль reverse proxy приложению не нужны. Токен отправляется как Bearer/X-TradeBot-Token и хранится зашифрованным ключом Android Keystore. ## Переобучение -Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `DESKTOP-TMFDL0H` сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети. +Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `SEVENHILL` (`G:\Repos\TradeBot`) сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети. ## Live-торговля diff --git a/android/TradeBotMonitor/app/build.gradle.kts b/android/TradeBotMonitor/app/build.gradle.kts index f681b5a..0c9f416 100644 --- a/android/TradeBotMonitor/app/build.gradle.kts +++ b/android/TradeBotMonitor/app/build.gradle.kts @@ -10,7 +10,7 @@ android { applicationId = "xyz.kusoft.tradebotmonitor" minSdk = 26 targetSdk = 36 - versionCode = 18 - versionName = "0.3.0" + versionCode = 20 + versionName = "0.4.1" } } diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPrefs.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPrefs.kt index 1851deb..4b6a6b7 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPrefs.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPrefs.kt @@ -16,7 +16,14 @@ class AppPrefs(context: Context) { if (saved.isNullOrBlank() || saved == LEGACY_PI_API_BASE_URL) { prefs.edit().putString("api_base_url", DEFAULT_API_BASE_URL).apply() } - if (prefs.getString("training_computer_name", null).isNullOrBlank()) { + val trainingComputerName = prefs.getString("training_computer_name", null)?.trim() + val trainingComputerPath = prefs.getString("training_computer_path", null)?.trim() + if ( + trainingComputerName.isNullOrBlank() || + trainingComputerName == LEGACY_TRAINING_COMPUTER_NAME || + trainingComputerPath.isNullOrBlank() || + trainingComputerPath == LEGACY_TRAINING_COMPUTER_PATH + ) { pinDefaultTrainingComputer() } } @@ -134,8 +141,10 @@ class AppPrefs(context: Context) { private companion object { const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz" const val LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787" - const val DEFAULT_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H" - const val DEFAULT_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot" + const val DEFAULT_TRAINING_COMPUTER_NAME = "SEVENHILL" + const val DEFAULT_TRAINING_COMPUTER_PATH = "G:\\Repos\\TradeBot" + const val LEGACY_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H" + const val LEGACY_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot" const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1" } } diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt index 4c4383b..c517a17 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt @@ -489,21 +489,19 @@ class MainActivity : Activity() { liveSignature = liveBlockSignature(snapshot), ) - val (savedLogin, savedPassword) = authParts(prefs.commandToken) val apiInput = input("Адрес API бота", prefs.apiBaseUrl) - val loginInput = input("Логин API", savedLogin) - val passwordInput = input("Пароль API", savedPassword).apply { + val tokenInput = input("API-токен", prefs.commandToken).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD } box.addView(section("Подключение", LinearLayout(this).apply { orientation = LinearLayout.VERTICAL addView(apiInput) - addView(loginInput.top(dp(8))) - addView(passwordInput.top(dp(8))) + addView(tokenInput.top(dp(8))) + addView(text("Токен выдаётся сервером для Android-клиента и хранится в Android Keystore.", 12f, Typeface.NORMAL, palette.muted).top(dp(8))) addView(actionRow( "Сохранить" to { prefs.apiBaseUrl = apiInput.text.toString() - prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString()) + prefs.commandToken = tokenInput.text.toString() toast("Подключение сохранено") refreshData(silent = false) }, @@ -1020,6 +1018,13 @@ class MainActivity : Activity() { LinearLayout(this).apply { val coordination = retrain.optJSONObject("coordination") ?: JSONObject() val activeJob = coordination.optJSONObject("active_job") + val worker = coordination.optJSONObject("worker") + val trainingComputerName = worker?.optStringClean("name") + ?.takeIf { it.isNotBlank() } + ?: prefs.trainingComputerName + val trainingComputerPath = worker?.optStringClean("path") + ?.takeIf { it.isNotBlank() } + ?: prefs.trainingComputerPath val agentRecentlySeen = coordination.optBoolean( "agent_recently_seen", coordination.optBoolean("agent_online", false), @@ -1036,8 +1041,8 @@ class MainActivity : Activity() { val connectionColor = if (agentRecentlySeen || agentBusy) palette.green else palette.amber orientation = LinearLayout.VERTICAL addView(text("Компьютер обучения", 12f, Typeface.NORMAL, palette.muted)) - addView(text(prefs.trainingComputerName, 18f, Typeface.BOLD, palette.green).top(dp(5))) - addView(text(prefs.trainingComputerPath, 12f, Typeface.NORMAL, palette.muted).top(dp(4))) + addView(text(trainingComputerName, 18f, Typeface.BOLD, palette.green).top(dp(5))) + addView(text(trainingComputerPath, 12f, Typeface.NORMAL, palette.muted).top(dp(4))) addView(keyValueLine("Связь агента", connectionText, connectionColor).top(dp(4))) addView(text("Бот ставит задания через tb.kusoft.xyz, а этот Windows-agent сам забирает их через интернет и возвращает результат.", 12f, Typeface.NORMAL, palette.muted).top(dp(8))) } @@ -1052,10 +1057,9 @@ class MainActivity : Activity() { val agentOnline = coordination.optBoolean("agent_online", false) addView(keyValueLine("Состояние", if (agentOnline) "готов к запуску" else "ждет Windows-agent", if (agentOnline) palette.green else palette.amber).top(dp(8))) if (latestJob != null) { - val latestStatus = latestJob.optStringClean("status") - val latestPhase = latestJob.optStringClean("phase") val latestMessage = latestJob.optStringClean("message") - addView(keyValueLine("Последнее обучение", trainingJobLabel(latestStatus, latestPhase), trainingJobColor(latestStatus, latestPhase)).top(dp(8))) + addView(keyValueLine("Последнее обучение", trainingJobLabel(latestJob), trainingJobColor(latestJob)).top(dp(8))) + addView(trainingModelDecisionLine(latestJob).top(dp(6))) if (latestMessage.isNotBlank()) { addView(text(latestMessage.take(160), 12f, Typeface.NORMAL, palette.muted).top(dp(6))) } @@ -1064,12 +1068,11 @@ class MainActivity : Activity() { } val job = activeJob val status = job.optStringClean("status") - val phase = job.optStringClean("phase") val progress = job.optInt("progress_percent", if (status == "completed") 100 else 0).coerceIn(0, 100) val message = job.optStringClean("message") - addView(keyValueLine("Состояние", trainingJobLabel(status, phase), trainingJobColor(status, phase)).top(dp(8))) + addView(keyValueLine("Состояние", trainingJobLabel(job), trainingJobColor(job)).top(dp(8))) addView(allocationBar(progress / 100.0).top(dp(10))) - addView(keyValueLine("Прогресс", "$progress%", trainingJobColor(status, phase)).top(dp(8))) + addView(keyValueLine("Прогресс", "$progress%", trainingJobColor(job)).top(dp(8))) if (message.isNotBlank()) { addView(text(message.take(160), 12f, Typeface.NORMAL, palette.text).top(dp(8))) } @@ -1241,22 +1244,19 @@ class MainActivity : Activity() { if (!isAuthError()) { return emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}") } - val (savedLogin, savedPassword) = authParts(prefs.commandToken) val apiInput = input("Адрес API бота", prefs.apiBaseUrl) - val loginInput = input("Логин API", savedLogin) - val passwordInput = input("Пароль API", savedPassword).apply { + val tokenInput = input("API-токен", prefs.commandToken).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD } - return section("Нужен вход в API", LinearLayout(this).apply { + return section("Нужен API-токен", LinearLayout(this).apply { orientation = LinearLayout.VERTICAL - addView(text("Сервер tb.kusoft.xyz отвечает 401, значит он доступен, но требует авторизацию. Введите логин и пароль, приложение само отправит Basic Auth.", 13f, Typeface.NORMAL, palette.muted)) + addView(text("Сервер доступен, но Android API-токен отсутствует или неверен. Введите отдельный токен клиента; логин и пароль сайта здесь не используются.", 13f, Typeface.NORMAL, palette.muted)) addView(apiInput.top(dp(12))) - addView(loginInput.top(dp(8))) - addView(passwordInput.top(dp(8))) + addView(tokenInput.top(dp(8))) addView(actionRow( "Подключиться" to { prefs.apiBaseUrl = apiInput.text.toString() - prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString()) + prefs.commandToken = tokenInput.text.toString() toast("Доступ сохранен, проверяю API") refreshData(silent = false) }, @@ -1642,11 +1642,40 @@ class MainActivity : Activity() { }, ).joinToString("|") - private fun trainingJobLabel(status: String, phase: String): String = - when (status) { + private fun trainingModelDecision(job: JSONObject): String { + val explicit = job.optStringClean("model_decision") + if (explicit in setOf("accepted", "rejected")) return explicit + val summary = job.optJSONObject("summary") ?: return "" + return if (summary.has("accepted")) { + if (summary.optBoolean("accepted", false)) "accepted" else "rejected" + } else { + "" + } + } + + private fun trainingModelDecisionLine(job: JSONObject): View { + val decision = trainingModelDecision(job) + val value = when (decision) { + "accepted" -> "кандидат принят" + "rejected" -> "кандидат отклонён" + else -> "нет решения gate" + } + val color = when (decision) { + "accepted" -> palette.green + "rejected" -> palette.amber + else -> palette.muted + } + return keyValueLine("Результат модели", value, color) + } + + private fun trainingJobLabel(job: JSONObject): String { + val status = job.optStringClean("status") + val phase = job.optStringClean("phase") + val completedWithDecision = trainingModelDecision(job).isNotBlank() + return when (status) { "pending" -> "ждет Windows-agent" "completed" -> "завершено успешно" - "failed" -> "ошибка обучения" + "failed" -> if (completedWithDecision) "завершено успешно" else "ошибка обучения" "running" -> when (phase) { "claimed" -> "задание получено" "training" -> "идет обучение" @@ -1656,14 +1685,19 @@ class MainActivity : Activity() { } else -> "готов к запуску" } + } - private fun trainingJobColor(status: String, phase: String): Int = - when { - status == "completed" -> palette.green + private fun trainingJobColor(job: JSONObject): Int { + val status = job.optStringClean("status") + val phase = job.optStringClean("phase") + val completedWithDecision = trainingModelDecision(job).isNotBlank() + return when { + status == "completed" || completedWithDecision -> palette.green status == "failed" -> palette.red status == "pending" || status == "running" || phase.isNotBlank() -> palette.amber else -> palette.text } + } private fun normalizedAction(raw: String?): String { val value = raw.orEmpty().uppercase(Locale.US) @@ -1700,27 +1734,6 @@ class MainActivity : Activity() { else -> value.ifBlank { "нет данных" } } - private fun authParts(value: String): Pair { - val trimmed = value.trim() - if (trimmed.isBlank() || trimmed.startsWith("Bearer ", ignoreCase = true)) { - return "" to "" - } - val basicPrefix = "Basic " - val raw = if (trimmed.startsWith(basicPrefix, ignoreCase = true)) "" else trimmed - val separator = raw.indexOf(':') - return if (separator >= 0) { - raw.substring(0, separator) to raw.substring(separator + 1) - } else { - raw to "" - } - } - - private fun authToken(login: String, password: String): String { - val cleanLogin = login.trim() - val cleanPassword = password.trim() - return if (cleanLogin.isBlank() && cleanPassword.isBlank()) "" else "$cleanLogin:$cleanPassword" - } - private fun modelLabel(value: String): String = when { value.contains("gru", ignoreCase = true) -> "PyTorch GRU" diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt index 1731298..86b3e48 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt @@ -1,6 +1,5 @@ package xyz.kusoft.tradebotmonitor -import android.util.Base64 import org.json.JSONArray import org.json.JSONObject import java.io.BufferedReader @@ -98,7 +97,7 @@ class TradeBotApi( connection.disconnect() if (code !in 200..299) { if (code == HttpURLConnection.HTTP_UNAUTHORIZED) { - throw IllegalStateException("HTTP 401: сервер требует логин и пароль") + throw IllegalStateException("HTTP 401: API-токен отсутствует или неверен") } throw IllegalStateException("HTTP $code: ${text.take(240)}") } @@ -282,15 +281,8 @@ class TradeBotApi( private fun applyAuthHeaders(connection: HttpURLConnection, token: String) { val value = token.trim() if (value.isBlank()) return - connection.setRequestProperty("X-TradeBot-Token", value) - val authorization = when { - value.startsWith("Basic ", ignoreCase = true) -> value - value.startsWith("Bearer ", ignoreCase = true) -> value - ":" in value -> { - val encoded = Base64.encodeToString(value.toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP) - "Basic $encoded" - } - else -> "Bearer $value" - } + val rawToken = value.removePrefix("Bearer ").removePrefix("bearer ").trim() + connection.setRequestProperty("X-TradeBot-Token", rawToken) + val authorization = "Bearer $rawToken" connection.setRequestProperty("Authorization", authorization) } diff --git a/android/TradeBotMonitor/gradle/wrapper/gradle-wrapper.jar b/android/TradeBotMonitor/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/android/TradeBotMonitor/gradle/wrapper/gradle-wrapper.properties b/android/TradeBotMonitor/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/android/TradeBotMonitor/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/TradeBotMonitor/gradlew b/android/TradeBotMonitor/gradlew new file mode 100644 index 0000000..739907d --- /dev/null +++ b/android/TradeBotMonitor/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/TradeBotMonitor/gradlew.bat b/android/TradeBotMonitor/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/android/TradeBotMonitor/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index 4706af1..6a87bb3 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -644,8 +644,12 @@ def _torch_forecast_entry_signal( expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) probability_up = _safe_float(forecast.get("probability_up"), 0.5) skill = _safe_float(forecast.get("skill"), 0.0) - min_edge = max(0.0, settings.time_series_min_edge_percent) - min_probability = _torch_min_probability(settings) + min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent)) + min_probability = _clamp( + _safe_float(forecast.get("calibrated_min_probability_up"), _torch_min_probability(settings)), + 0.5, + 0.95, + ) probe_min_edge = max(0.0, min(settings.time_series_probe_min_edge_percent, min_edge)) probe_min_probability = round( _clamp(settings.time_series_probe_min_probability_up, min_probability, 0.85), @@ -716,7 +720,7 @@ def _torch_forecast_entry_signal( and expected_return >= 0.0 and probability_up >= rebound_model_probability_min and skill > 0.0 - and confidence >= settings.time_series_min_confidence + and confidence >= _safe_float(forecast.get("calibrated_min_confidence"), settings.time_series_min_confidence) ) fallback_rebound_entry_ok = bool( settings.time_series_rebound_fallback_enabled @@ -725,7 +729,7 @@ def _torch_forecast_entry_signal( and quality_gate_ok and model_fresh_ok and not bool(forecast.get("block_entry", False)) - and confidence >= settings.time_series_min_confidence + and confidence >= _safe_float(forecast.get("calibrated_min_confidence"), settings.time_series_min_confidence) ) rebound_entry_ok = model_rebound_entry_ok or fallback_rebound_entry_ok if rebound_entry_ok and position_notional > 0: @@ -896,8 +900,12 @@ def _torch_forecast_exit_signal( expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) probability_up = _safe_float(forecast.get("probability_up"), 0.5) skill = _safe_float(forecast.get("skill"), 0.0) - min_edge = max(0.0, settings.time_series_min_edge_percent) - min_probability = _torch_min_probability(settings) + min_edge = max(0.0, _safe_float(forecast.get("calibrated_min_edge_percent"), settings.time_series_min_edge_percent)) + min_probability = _clamp( + _safe_float(forecast.get("calibrated_min_probability_up"), _torch_min_probability(settings)), + 0.5, + 0.95, + ) estimated_exit_net_percent = _estimated_exit_net_percent(position, price, settings) min_exit_net_percent = _min_exit_net_percent(settings) entry_path = str(position.entry_diagnostics.get("entry_path", "")) diff --git a/crypto_spot_bot/time_series.py b/crypto_spot_bot/time_series.py index aa2065f..43e9e9b 100644 --- a/crypto_spot_bot/time_series.py +++ b/crypto_spot_bot/time_series.py @@ -160,6 +160,9 @@ class TimeSeriesForecast: model_created_at: str = "" model_age_hours: float | None = None model_fresh: bool = False + calibrated_min_edge_percent: float = 0.0 + calibrated_min_probability_up: float = 0.0 + calibrated_min_confidence: float = 0.0 def as_dict(self) -> dict[str, Any]: return asdict(self) @@ -196,8 +199,20 @@ class TimeSeriesForecaster: artifact, self.settings.time_series_model_max_age_hours, ) - quality_gate = self._load_quality_gate() + calibration = self._load_quality_gate() + quality_gate = ( + calibration.get("validation") + if isinstance(calibration.get("validation"), dict) + else calibration + ) quality_gate_passed = _quality_gate_passed(quality_gate) + calibrated = _calibrated_thresholds( + calibration, + symbol, + edge=self.settings.time_series_min_edge_percent, + probability=self.settings.time_series_min_probability_up, + confidence=self.settings.time_series_min_confidence, + ) entry = _torch_recurrent_entry(symbol, artifact) model = _torch_recurrent_model_name(symbol, artifact) clip = _clamp(_float_entry(entry or {}, "clip", 8.0), 1.0, 50.0) @@ -249,7 +264,7 @@ class TimeSeriesForecaster: q90_percent = (math.exp(float(selected.get("q90", expected_return))) - 1) * 100 skill = _clamp(_float_entry(entry, "skill", 0.0), -1.0, 1.0) horizon = int(selected.get("horizon", _entry_horizon(entry, self.settings.time_series_forecast_horizon))) - min_edge = max(0.0, self.settings.time_series_min_edge_percent) + min_edge = calibrated["edge"] confidence_adjustment = _confidence_adjustment( expected_return_percent=expected_return_percent, probability_up=probability_up, @@ -299,6 +314,9 @@ class TimeSeriesForecaster: model_created_at=model_created_at, model_age_hours=model_age_hours, model_fresh=model_fresh, + calibrated_min_edge_percent=calibrated["edge"], + calibrated_min_probability_up=calibrated["probability"], + calibrated_min_confidence=calibrated["confidence"], ) direct_horizon = _is_direct_horizon(entry) @@ -318,7 +336,7 @@ class TimeSeriesForecaster: expected_return_percent = (math.exp(expected_return) - 1) * 100 probability_up = _normal_cdf(expected_return / max(uncertainty, 1e-9)) skill = _clamp(_float_entry(entry, "skill", 0.0), -1.0, 1.0) - min_edge = max(0.0, self.settings.time_series_min_edge_percent) + min_edge = calibrated["edge"] confidence_adjustment = _confidence_adjustment( expected_return_percent=expected_return_percent, probability_up=probability_up, @@ -364,6 +382,9 @@ class TimeSeriesForecaster: model_created_at=model_created_at, model_age_hours=model_age_hours, model_fresh=model_fresh, + calibrated_min_edge_percent=calibrated["edge"], + calibrated_min_probability_up=calibrated["probability"], + calibrated_min_confidence=calibrated["confidence"], ) def _load_lstm_artifact(self) -> dict[str, Any]: @@ -400,8 +421,7 @@ class TimeSeriesForecaster: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): data = {} - validation = data.get("validation") if isinstance(data, dict) else {} - self._quality_gate = validation if isinstance(validation, dict) else {} + self._quality_gate = data if isinstance(data, dict) else {} self._calibration_mtime = stat.st_mtime return self._quality_gate @@ -443,6 +463,9 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast: def _quality_gate_passed(quality_gate: dict[str, Any]) -> bool | None: if not quality_gate: return None + validation = quality_gate.get("validation") + if isinstance(validation, dict): + return _quality_gate_passed(validation) if "passed" in quality_gate: return bool(quality_gate.get("passed")) status = str(quality_gate.get("status", "")).strip().lower() @@ -453,6 +476,26 @@ def _quality_gate_passed(quality_gate: dict[str, Any]) -> bool | None: return None +def _calibrated_thresholds( + calibration: dict[str, Any], + symbol: str | None, + *, + edge: float, + probability: float, + confidence: float, +) -> dict[str, float]: + recommended = calibration.get("recommended") if isinstance(calibration, dict) else None + per_symbol = calibration.get("symbol_recommendations") if isinstance(calibration, dict) else None + if symbol and isinstance(per_symbol, dict) and isinstance(per_symbol.get(symbol.upper()), dict): + recommended = per_symbol[symbol.upper()] + row = recommended if isinstance(recommended, dict) else {} + return { + "edge": max(0.0, float(row.get("edge", edge) or edge)), + "probability": _clamp(float(row.get("probability", probability) or probability), 0.5, 0.95), + "confidence": _clamp(float(row.get("confidence", confidence) or confidence), 0.0, 1.0), + } + + def _model_freshness(artifact: dict[str, Any], max_age_hours: float) -> tuple[str, float | None, bool]: raw = str(artifact.get("created_at", "")).strip() if isinstance(artifact, dict) else "" if not raw: @@ -525,6 +568,8 @@ def _feature_context( def _feature_value(name: str, candles: list[Candle], index: int, candle: Candle, context: dict[str, Any]) -> float: close = max(float(candle.close), 1e-12) previous = candles[index - 1] if index >= 1 else candle + if name.startswith("symbol_is_"): + return 1.0 if context.get("symbol") == name.removeprefix("symbol_is_").upper() else 0.0 if name == "return_1": return _log_change(candle.close, previous.close) if name == "return_3": @@ -990,6 +1035,31 @@ def _torch_recurrent_predict( model_name = _torch_recurrent_model_name(symbol, artifact) if not entry or not model_name: return None + ensemble_members = entry.get("ensemble_members") + if isinstance(ensemble_members, list) and ensemble_members: + predictions: list[float | dict[str, Any]] = [] + for member in ensemble_members: + if not isinstance(member, dict): + continue + member_entry = {**entry, **member} + member_entry.pop("ensemble_members", None) + member_entry.pop("ensemble_size", None) + member_artifact: dict[str, Any] = {"type": "pytorch_recurrent_forecaster"} + if symbol: + member_artifact["symbols"] = {symbol.upper(): member_entry} + else: + member_artifact["default"] = member_entry + prediction = _torch_recurrent_predict( + returns, + symbol, + member_artifact, + feature_rows=feature_rows, + closes=closes, + candles=candles, + ) + if isinstance(prediction, (int, float, dict)): + predictions.append(prediction) + return _average_ensemble_predictions(predictions) lookback = int(_clamp(_float_entry(entry, "lookback", 0.0), 4.0, 512.0)) hidden_size = int(_clamp(_float_entry(entry, "hidden_size", 0.0), 1.0, 512.0)) num_layers = int(_clamp(_float_entry(entry, "num_layers", 1.0), 1.0, 8.0)) @@ -1050,6 +1120,33 @@ def _torch_recurrent_predict( return _clamp(prediction, -cap, cap) +def _average_ensemble_predictions(predictions: list[float | dict[str, Any]]) -> float | dict[str, Any] | None: + if not predictions: + return None + numeric = [float(value) for value in predictions if isinstance(value, (int, float))] + if numeric: + return sum(numeric) / len(numeric) + mappings = [value for value in predictions if isinstance(value, dict)] + if not mappings: + return None + first = mappings[0] + output: dict[str, Any] = {} + for key, value in first.items(): + if key == "horizons" and isinstance(value, dict): + horizons: dict[str, Any] = {} + for horizon, row in value.items(): + rows = [item.get("horizons", {}).get(horizon) for item in mappings] + rows = [item for item in rows if isinstance(item, dict)] + if rows: + horizons[horizon] = _average_ensemble_predictions(rows) + output[key] = horizons + continue + values = [item.get(key) for item in mappings] + finite = [float(item) for item in values if isinstance(item, (int, float)) and math.isfinite(float(item))] + output[key] = sum(finite) / len(finite) if finite else value + return output + + def _torch_head_outputs(context: list[float], entry: dict[str, Any], hidden_size: int) -> list[float]: context = _apply_context_norm(context, entry) raw_weight = entry.get("head_weight") diff --git a/crypto_spot_bot/training_coordination.py b/crypto_spot_bot/training_coordination.py index 82e84dc..4969020 100644 --- a/crypto_spot_bot/training_coordination.py +++ b/crypto_spot_bot/training_coordination.py @@ -215,6 +215,10 @@ class TrainingCoordinator: job["message"] = str(payload.get("message") or "") if isinstance(payload.get("summary"), dict): job["summary"] = payload["summary"] + if isinstance(payload["summary"].get("accepted"), bool): + job["model_decision"] = ( + "accepted" if payload["summary"]["accepted"] else "rejected" + ) self._save_state(state) return {"ok": True, "job": job, "status": self._public_status(state)} @@ -294,10 +298,11 @@ class TrainingCoordinator: os.replace(tmp, self.state_path) def _worker_from_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + worker_id = str(payload.get("worker_id") or payload.get("id") or "windows-training-host").strip() return { - "id": str(payload.get("worker_id") or payload.get("id") or "windows-training-host"), - "name": str(payload.get("name") or "DESKTOP-TMFDL0H"), - "path": str(payload.get("path") or "C:\\Repos\\TradeBot"), + "id": worker_id, + "name": str(payload.get("name") or worker_id).strip(), + "path": str(payload.get("path") or "").strip(), "version": str(payload.get("version") or "1"), "last_seen_at": _now(), } diff --git a/tests/test_calibrate_thresholds.py b/tests/test_calibrate_thresholds.py new file mode 100644 index 0000000..0583ff8 --- /dev/null +++ b/tests/test_calibrate_thresholds.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from tools.calibrate_torch_thresholds import ( + CalibrationResult, + ForecastRecord, + _apply_platt_calibration, + _choose_recommendation, + _fit_platt_calibration, + _entry_validation_skill, +) + + +def _result(*, trades: int, average: float, total: float, profit_factor: float) -> CalibrationResult: + return CalibrationResult( + edge=0.05, + probability=0.52, + confidence=0.4, + trades=trades, + wins=max(0, trades // 2), + win_rate=0.5, + total_net_percent=total, + average_net_percent=average, + max_drawdown_percent=1.0, + profit_factor=profit_factor, + score=1.0, + ) + + +def _record(index: int, probability: float, future: float) -> ForecastRecord: + return ForecastRecord( + symbol="BTCUSDT", + index=index, + timestamp=index, + close=100.0, + high=101.0, + low=99.0, + next_open=100.0, + next_timestamp=index + 1, + atr=1.0, + expected_percent=0.1, + probability_up=probability, + confidence=0.5, + skill=0.1, + q50_percent=0.1, + block_entry=False, + future_net_percent=future, + benchmark_entry=False, + benchmark_exit=False, + ) + + +def test_calibration_does_not_fallback_to_too_few_trades() -> None: + selected = _choose_recommendation( + [_result(trades=1, average=2.0, total=2.0, profit_factor=999.0)], + min_trades=30, + ) + + assert selected is None + + +def test_calibration_selects_only_viable_result() -> None: + viable = _result(trades=30, average=0.2, total=6.0, profit_factor=1.4) + + assert _choose_recommendation([viable], min_trades=30) is viable + + +def test_platt_calibration_learns_probability_direction_from_train_records() -> None: + records = [ + _record(index, 0.8 if index % 2 else 0.2, -1.0 if index % 2 else 1.0) + for index in range(100) + ] + + calibration = _fit_platt_calibration(records) + calibrated = _apply_platt_calibration( + [_record(101, 0.8, -1.0), _record(102, 0.2, 1.0)], + calibration, + ) + + assert calibration["slope"] < 0 + assert calibrated[0].probability_up < calibrated[1].probability_up + + +def test_entry_quality_never_falls_back_to_holdout_skill() -> None: + entry = {"validation_skill": 0.12, "skill": 0.99, "holdout_skill": 0.99} + + assert _entry_validation_skill(entry) == 0.12 + assert _entry_validation_skill({"skill": 0.99, "holdout_skill": 0.99}) == 0.0 diff --git a/tests/test_time_series.py b/tests/test_time_series.py index 9c0a516..10a1657 100644 --- a/tests/test_time_series.py +++ b/tests/test_time_series.py @@ -304,6 +304,60 @@ def test_time_series_forecaster_attaches_quality_gate(make_settings, tmp_path) - assert forecast.quality_gate["status"] == "fail" +def test_time_series_forecaster_uses_symbol_calibration(make_settings, tmp_path) -> None: + artifact_path = tmp_path / "lstm_forecaster.json" + _write_torch_gru_artifact(artifact_path, head_bias=0.2) + (tmp_path / "torch_threshold_calibration.json").write_text( + json.dumps( + { + "validation": {"status": "pass", "passed": True}, + "recommended": {"edge": 0.08, "probability": 0.52, "confidence": 0.4}, + "symbol_recommendations": { + "BTCUSDT": {"edge": 0.03, "probability": 0.55, "confidence": 0.45} + }, + } + ), + encoding="utf-8", + ) + settings = make_settings( + tmp_path, + time_series_lstm_model_path=artifact_path, + time_series_forecast_horizon=1, + ) + + forecast = TimeSeriesForecaster(settings).forecast( + _candles_from_returns([0.0001] * 140), symbol="BTCUSDT" + ) + + assert forecast.calibrated_min_edge_percent == 0.03 + assert forecast.calibrated_min_probability_up == 0.55 + assert forecast.calibrated_min_confidence == 0.45 + + +def test_time_series_forecaster_averages_ensemble_members(make_settings, tmp_path) -> None: + artifact_path = tmp_path / "lstm_forecaster.json" + _write_torch_gru_artifact(artifact_path, head_bias=0.9) + artifact = json.loads(artifact_path.read_text(encoding="utf-8")) + entry = artifact["symbols"]["BTCUSDT"] + entry["ensemble_members"] = [ + {"state_dict": entry["state_dict"], "head_weight": [0.0, 0.0], "head_bias": bias} + for bias in (0.1, 0.3) + ] + artifact_path.write_text(json.dumps(artifact), encoding="utf-8") + settings = make_settings( + tmp_path, + time_series_lstm_model_path=artifact_path, + time_series_forecast_horizon=1, + ) + + forecast = TimeSeriesForecaster(settings).forecast( + _candles_from_returns([0.0001] * 140), symbol="BTCUSDT" + ) + + assert forecast.usable is True + assert 0.015 <= forecast.expected_return_percent <= 0.025 + + def test_time_series_forecaster_reads_multifeature_direct_horizon_artifact(make_settings, tmp_path) -> None: artifact_path = tmp_path / "lstm_forecaster.json" _write_multifeature_torch_gru_artifact(artifact_path, head_bias=0.2) diff --git a/tests/test_training_coordination.py b/tests/test_training_coordination.py index 1686a0b..264c4f7 100644 --- a/tests/test_training_coordination.py +++ b/tests/test_training_coordination.py @@ -48,6 +48,42 @@ def test_training_coordinator_preserves_boolean_resume_candidate_parameter(tmp_p assert requested["job"]["parameters"] == {"resume_candidate": True} +def test_training_coordinator_reports_worker_identity_from_heartbeat(tmp_path) -> None: + coordinator = TrainingCoordinator(tmp_path) + + heartbeat = coordinator.heartbeat( + { + "worker_id": "SEVENHILL:G:\\Repos\\TradeBot", + "name": "SEVENHILL", + "path": "G:\\Repos\\TradeBot", + } + ) + + assert heartbeat["worker"]["name"] == "SEVENHILL" + assert heartbeat["worker"]["path"] == "G:\\Repos\\TradeBot" + assert heartbeat["status"]["worker"] == heartbeat["worker"] + + +def test_training_coordinator_records_rejected_candidate_as_completed_training(tmp_path) -> None: + coordinator = TrainingCoordinator(tmp_path) + job = coordinator.request_retrain({"source": "android"})["job"] + coordinator.claim({"worker_id": "worker-1"}) + + completed = coordinator.complete( + job["id"], + { + "success": True, + "message": "training completed; candidate rejected by quality gate", + "summary": {"accepted": False, "reason": "candidate_failed_honest_validation"}, + }, + ) + + assert completed["job"]["status"] == "completed" + assert completed["job"]["phase"] == "completed" + assert completed["job"]["progress_percent"] == 100 + assert completed["job"]["model_decision"] == "rejected" + + def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None: coordinator = TrainingCoordinator(tmp_path) job = coordinator.request_retrain({"source": "test"})["job"] diff --git a/tools/calibrate_torch_thresholds.py b/tools/calibrate_torch_thresholds.py index 5dda0e1..62c45bf 100644 --- a/tools/calibrate_torch_thresholds.py +++ b/tools/calibrate_torch_thresholds.py @@ -6,7 +6,7 @@ import json import math import sys import time -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any @@ -130,13 +130,15 @@ def main() -> None: if not records: raise SystemExit("No forecast records could be built for calibration.") - results = _calibrate( + results = _calibrate_strategy( records, edges=_float_grid(args.edge_grid), probabilities=_float_grid(args.probability_grid), confidences=_float_grid(args.confidence_grid), min_trades=args.min_trades, horizon=horizon, + round_trip_cost=round_trip_cost, + settings=settings, ) if not results: raise SystemExit("No calibration result produced trades. Use wider grids or more history.") @@ -156,6 +158,33 @@ def main() -> None: round_trip_cost=round_trip_cost, settings=settings, ) + symbol_recommendations: dict[str, dict[str, Any]] = {} + for symbol in symbols: + symbol_records = [record for record in records if record.symbol == symbol] + symbol_results = _calibrate_strategy( + symbol_records, + edges=_float_grid(args.edge_grid), + probabilities=_float_grid(args.probability_grid), + confidences=_float_grid(args.confidence_grid), + min_trades=max(3, min(args.min_trades, len(symbol_records) // 8)), + horizon=horizon, + round_trip_cost=round_trip_cost, + settings=settings, + ) + symbol_selected = _choose_recommendation( + symbol_results, + min_trades=max(3, min(args.min_trades, len(symbol_records) // 8)), + ) if symbol_results else None + if symbol_selected is not None: + symbol_recommendations[symbol] = _result_dict(symbol_selected) + calibration_insufficient = recommended is None + if recommended is None: + recommended = _empty_recommendation( + _float_grid(args.edge_grid), + _float_grid(args.probability_grid), + _float_grid(args.confidence_grid), + ) + full_backtest = {**_stats([]), "trades_detail": [], "symbol_breakdown": []} print("\nRECOMMENDED") print(_result_line(recommended)) print("\nFULL_REPLAY") @@ -188,6 +217,16 @@ def main() -> None: min_profit_factor=args.min_oos_profit_factor, min_benchmark_edge=args.min_benchmark_edge_percent, ) + deployment_recommended = recommended + deployment_symbol_recommendations = symbol_recommendations + if walk_forward.get("folds"): + last_fold = walk_forward["folds"][-1] + fold_thresholds = last_fold.get("thresholds") + if isinstance(fold_thresholds, dict): + deployment_recommended = _result_from_dict(fold_thresholds) + fold_symbols = last_fold.get("symbol_thresholds") + if isinstance(fold_symbols, dict): + deployment_symbol_recommendations = fold_symbols print("\nWALK_FORWARD") print(json.dumps(walk_forward["summary"], ensure_ascii=False, sort_keys=True)) print("\nBENCHMARK") @@ -206,7 +245,9 @@ def main() -> None: "artifact_sha256": artifact_sha256, "artifact": _artifact_summary(artifact), "records_by_symbol": per_symbol_counts, - "recommended": _result_dict(recommended), + "recommended": _result_dict(deployment_recommended), + "calibration_insufficient": calibration_insufficient, + "symbol_recommendations": deployment_symbol_recommendations, "full_replay": full_backtest, "walk_forward": walk_forward, "benchmark": benchmark, @@ -306,7 +347,9 @@ def _forecast_records( return batched_records records: list[ForecastRecord] = [] - skill = float(entry.get("skill", 0.0) or 0.0) + # Entry eligibility may use validation-derived quality only. Holdout metrics + # belong exclusively to the final quality gate and cannot influence replay. + skill = _entry_validation_skill(entry) for index in range(start, max(start, end)): prediction = _torch_recurrent_predict( _log_returns(closes[: index + 1]), @@ -394,7 +437,7 @@ def _batch_forecast_records( return [] records: list[ForecastRecord] = [] - skill = float(entry.get("skill", 0.0) or 0.0) + skill = _entry_validation_skill(entry) model.eval() with torch.no_grad(): for offset in range(0, len(indices), max(1, batch_size)): @@ -460,6 +503,10 @@ def _batch_forecast_records( def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None: + if isinstance(entry.get("ensemble_members"), list) and entry["ensemble_members"]: + # Ensemble inference is handled by the shared pure-Python runtime so + # calibration and production use the exact same averaging path. + return None if torch is None or RecurrentReturnModel is None: return None architecture = "lstm" if model_name == "torch_lstm" else "gru" if model_name == "torch_gru" else "" @@ -590,6 +637,7 @@ def _full_backtest( round_trip_cost: float, settings: Any, detail_limit: int = 50, + symbol_thresholds: dict[str, CalibrationResult] | None = None, ) -> dict[str, Any]: positions: dict[str, dict[str, Any]] = {} trades: list[float] = [] @@ -600,6 +648,7 @@ def _full_backtest( stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True)) atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier))) for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)): + active_thresholds = (symbol_thresholds or {}).get(record.symbol, thresholds) position = positions.get(record.symbol) if position is not None: position["highest"] = max(position["highest"], record.high) @@ -616,8 +665,8 @@ def _full_backtest( and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"]) ) weak_forecast = ( - record.expected_percent < thresholds.edge - or record.probability_up < thresholds.probability + record.expected_percent < active_thresholds.edge + or record.probability_up < active_thresholds.probability or record.skill <= 0.0 ) exit_reason = "" @@ -633,7 +682,7 @@ def _full_backtest( elif atr_stop: exit_reason = "atr_trailing_stop" exit_price = float(atr_stop_level) - elif (record.expected_percent <= 0.0 or record.probability_up <= 0.50 or _candidate_blocks(record, thresholds.edge)): + elif (record.expected_percent <= 0.0 or record.probability_up <= 0.50 or _candidate_blocks(record, active_thresholds.edge)): exit_reason = "forecast_negative" elif weak_forecast and net_percent >= 0: exit_reason = "forecast_weak_profit_lock" @@ -659,7 +708,7 @@ def _full_backtest( if record.symbol in positions: continue - if _candidate_allows(record, thresholds.edge, thresholds.probability, thresholds.confidence): + if _candidate_allows(record, active_thresholds.edge, active_thresholds.probability, active_thresholds.confidence): positions[record.symbol] = { "entry_price": record.next_open, "entry_index": record.index + 1, @@ -815,24 +864,49 @@ def _walk_forward( test_end = timestamps[(fold + 1) * fold_size - 1] if fold < folds - 1 else timestamps[-1] train = [record for record in ordered if record.timestamp < test_start] test = [record for record in ordered if test_start <= record.timestamp <= test_end] - train_results = _calibrate( - train, + probability_calibration = _fit_platt_calibration(train) + calibrated_train = _apply_platt_calibration(train, probability_calibration) + calibrated_test = _apply_platt_calibration(test, probability_calibration) + train_results = _calibrate_strategy( + calibrated_train, edges=edges, probabilities=probabilities, confidences=confidences, min_trades=max(4, min_trades // 2), horizon=horizon, + round_trip_cost=round_trip_cost, + settings=settings, ) if not train_results: continue selected = _choose_recommendation(train_results, min_trades=max(4, min_trades // 2)) + if selected is None: + continue + symbol_thresholds: dict[str, CalibrationResult] = {} + train_symbols = sorted({record.symbol for record in calibrated_train}) + symbol_min_trades = max(3, min_trades // max(2, len(train_symbols) * 2)) + for symbol in train_symbols: + symbol_results = _calibrate_strategy( + [record for record in calibrated_train if record.symbol == symbol], + edges=edges, + probabilities=probabilities, + confidences=confidences, + min_trades=symbol_min_trades, + horizon=horizon, + round_trip_cost=round_trip_cost, + settings=settings, + ) + symbol_selected = _choose_recommendation(symbol_results, min_trades=symbol_min_trades) if symbol_results else None + if symbol_selected is not None: + symbol_thresholds[symbol] = symbol_selected test_backtest = _full_backtest( - test, + calibrated_test, selected, horizon=horizon, round_trip_cost=round_trip_cost, settings=settings, detail_limit=0, + symbol_thresholds=symbol_thresholds, ) test_rows = test_backtest.get("trades_detail", []) test_trades = [float(row.get("net_percent", 0.0) or 0.0) for row in test_rows if isinstance(row, dict)] @@ -844,6 +918,10 @@ def _walk_forward( "train_records": len(train), "test_records": len(test), "thresholds": _result_dict(selected), + "symbol_thresholds": { + symbol: _result_dict(value) for symbol, value in symbol_thresholds.items() + }, + "probability_calibration": probability_calibration, "test": {key: value for key, value in test_backtest.items() if key != "trades_detail"}, } ) @@ -980,6 +1058,11 @@ def _candidate_blocks(record: ForecastRecord, edge: float) -> bool: ) +def _entry_validation_skill(entry: dict[str, Any]) -> float: + value = entry.get("validation_skill") + return float(value) if isinstance(value, (int, float)) and math.isfinite(float(value)) else 0.0 + + def _candidate_allows(record: ForecastRecord, edge: float, probability: float, confidence: float) -> bool: dynamic_confidence = _forecast_confidence(record.expected_percent, record.probability_up, record.skill, edge) return ( @@ -1146,6 +1229,101 @@ def _calibrate( return results +def _calibrate_strategy( + records: list[ForecastRecord], + *, + edges: list[float], + probabilities: list[float], + confidences: list[float], + min_trades: int, + horizon: int, + round_trip_cost: float, + settings: Any, +) -> list[CalibrationResult]: + results: list[CalibrationResult] = [] + for edge in edges: + for probability in probabilities: + for confidence in confidences: + thresholds = CalibrationResult( + edge=edge, + probability=probability, + confidence=confidence, + trades=0, + wins=0, + win_rate=0.0, + total_net_percent=0.0, + average_net_percent=0.0, + max_drawdown_percent=0.0, + profit_factor=0.0, + score=0.0, + ) + replay = _full_backtest( + records, + thresholds, + horizon=horizon, + round_trip_cost=round_trip_cost, + settings=settings, + detail_limit=0, + ) + trades = int(replay.get("trades", 0) or 0) + if trades <= 0: + continue + wins = int(replay.get("wins", 0) or 0) + total = float(replay.get("total_net_percent", 0.0) or 0.0) + average = float(replay.get("avg_net_percent", 0.0) or 0.0) + drawdown = float(replay.get("max_drawdown_percent", 0.0) or 0.0) + profit_factor = float(replay.get("profit_factor", 0.0) or 0.0) + trade_factor = min(1.0, trades / max(1, min_trades)) + score = ( + average * trade_factor + + total * 0.015 + - drawdown * 0.03 + + (wins / trades) * 0.04 + ) + results.append( + CalibrationResult( + edge=edge, + probability=probability, + confidence=confidence, + trades=trades, + wins=wins, + win_rate=wins / trades, + total_net_percent=total, + average_net_percent=average, + max_drawdown_percent=drawdown, + profit_factor=profit_factor, + score=score, + ) + ) + results.sort( + key=lambda item: ( + item.score, + item.average_net_percent, + item.total_net_percent, + item.profit_factor, + item.trades, + ), + reverse=True, + ) + return results + + +def _result_from_dict(value: dict[str, Any]) -> CalibrationResult: + return CalibrationResult( + edge=float(value.get("edge", 0.1) or 0.1), + probability=float(value.get("probability", 0.7) or 0.7), + confidence=float(value.get("confidence", 0.4) or 0.4), + trades=int(value.get("trades", 0) or 0), + wins=int(value.get("wins", 0) or 0), + win_rate=float(value.get("win_rate", 0.0) or 0.0), + total_net_percent=float(value.get("total_net_percent", 0.0) or 0.0), + average_net_percent=float(value.get("average_net_percent", 0.0) or 0.0), + max_drawdown_percent=float(value.get("max_drawdown_percent", 0.0) or 0.0), + profit_factor=float(value.get("profit_factor", 0.0) or 0.0), + score=float(value.get("score", 0.0) or 0.0), + ) + + def _selected_trades( records: list[ForecastRecord], edge: float, @@ -1164,7 +1342,7 @@ def _selected_trades( return trades -def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int) -> CalibrationResult: +def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int) -> CalibrationResult | None: viable = [ result for result in results @@ -1173,7 +1351,67 @@ def _choose_recommendation(results: list[CalibrationResult], *, min_trades: int) and result.total_net_percent > 0 and result.profit_factor >= 1.05 ] - return viable[0] if viable else results[0] + return viable[0] if viable else None + + +def _empty_recommendation( + edges: list[float], probabilities: list[float], confidences: list[float] +) -> CalibrationResult: + return CalibrationResult( + edge=max(edges or [1.0]), + probability=max(probabilities or [0.95]), + confidence=max(confidences or [1.0]), + trades=0, + wins=0, + win_rate=0.0, + total_net_percent=0.0, + average_net_percent=0.0, + max_drawdown_percent=0.0, + profit_factor=0.0, + score=-1.0, + ) + + +def _fit_platt_calibration(records: list[ForecastRecord]) -> dict[str, float]: + samples = [ + ( + math.log(_clamp(record.probability_up, 1e-5, 1.0 - 1e-5) / (1.0 - _clamp(record.probability_up, 1e-5, 1.0 - 1e-5))), + 1.0 if record.future_net_percent > 0 else 0.0, + ) + for record in records + ] + if len(samples) < 30: + return {"slope": 1.0, "intercept": 0.0, "samples": float(len(samples))} + slope = 1.0 + intercept = 0.0 + learning_rate = 0.05 + for _ in range(300): + grad_slope = 0.0 + grad_intercept = 0.0 + for logit, target in samples: + probability = 1.0 / (1.0 + math.exp(-_clamp(slope * logit + intercept, -30.0, 30.0))) + error = probability - target + grad_slope += error * logit + grad_intercept += error + grad_slope = grad_slope / len(samples) + 0.001 * (slope - 1.0) + grad_intercept /= len(samples) + slope -= learning_rate * grad_slope + intercept -= learning_rate * grad_intercept + return {"slope": round(slope, 8), "intercept": round(intercept, 8), "samples": float(len(samples))} + + +def _apply_platt_calibration( + records: list[ForecastRecord], calibration: dict[str, float] +) -> list[ForecastRecord]: + slope = float(calibration.get("slope", 1.0)) + intercept = float(calibration.get("intercept", 0.0)) + output: list[ForecastRecord] = [] + for record in records: + probability = _clamp(record.probability_up, 1e-5, 1.0 - 1e-5) + logit = math.log(probability / (1.0 - probability)) + calibrated = 1.0 / (1.0 + math.exp(-_clamp(slope * logit + intercept, -30.0, 30.0))) + output.append(replace(record, probability_up=calibrated)) + return output def _choose_replay_recommendation( @@ -1185,8 +1423,10 @@ def _choose_replay_recommendation( horizon: int, round_trip_cost: float, settings: Any, -) -> tuple[CalibrationResult, dict[str, Any]]: +) -> tuple[CalibrationResult | None, dict[str, Any]]: fallback = _choose_recommendation(results, min_trades=min_trades) + if fallback is None: + return None, {**_stats([]), "trades_detail": [], "symbol_breakdown": []} fallback_replay = _full_backtest(records, fallback, horizon=horizon, round_trip_cost=round_trip_cost, settings=settings) if min_full_replay_trades <= 0: return fallback, fallback_replay @@ -1207,7 +1447,7 @@ def _choose_replay_recommendation( viable.append((result, replay)) if not viable: - return fallback, fallback_replay + return None, fallback_replay viable.sort( key=lambda item: ( item[0].score, diff --git a/tools/install_windows_training_agent.ps1 b/tools/install_windows_training_agent.ps1 index 5e83f0c..162b616 100644 --- a/tools/install_windows_training_agent.ps1 +++ b/tools/install_windows_training_agent.ps1 @@ -6,6 +6,7 @@ param( [int]$PollSeconds = 10, [int]$WatchdogMinutes = 5, [string]$RepoRoot = "", + [string]$CredentialPath = "", [switch]$StartNow, [switch]$KeepLegacyRetrainer ) @@ -15,105 +16,153 @@ $ErrorActionPreference = "Stop" if (-not $RepoRoot) { $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path } -$Agent = Join-Path $RepoRoot "tools\windows_training_agent.py" -if (-not (Test-Path $Agent)) { - throw "Windows training agent not found: $Agent" +if (-not $CredentialPath) { + $CredentialPath = Join-Path $env:LOCALAPPDATA "TradeBot\training-agent.token" } -function Resolve-Python { - $venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe" - if (Test-Path $venvPython) { - return $venvPython - } - - $userPython = Join-Path $env:LOCALAPPDATA "Programs\TradeBotPython312\python.exe" - if (Test-Path $userPython) { - return $userPython - } - - foreach ($candidate in @("python.exe", "python")) { - $command = Get-Command $candidate -ErrorAction SilentlyContinue - if ($command) { - return $command.Source - } - } - throw "Python was not found. Create .venv or install Python 3.12." -} - -function Resolve-WindowlessPython { - $python = Resolve-Python - $pythonw = Join-Path (Split-Path -Parent $python) "pythonw.exe" - if (Test-Path $pythonw) { - return $pythonw - } - return $python +$runner = Join-Path $RepoRoot "tools\run_windows_training_agent.ps1" +if (-not (Test-Path -LiteralPath $runner)) { + throw "Windows training agent runner not found: $runner" } +$credentialDirectory = Split-Path -Parent $CredentialPath +New-Item -ItemType Directory -Path $credentialDirectory -Force | Out-Null if ($ApiAuth) { - [Environment]::SetEnvironmentVariable("TRADEBOT_API_AUTH", $ApiAuth, "User") - $env:TRADEBOT_API_AUTH = $ApiAuth + $ApiAuth.Trim() | + ConvertTo-SecureString -AsPlainText -Force | + ConvertFrom-SecureString | + Set-Content -LiteralPath $CredentialPath -Encoding UTF8 } +if (-not (Test-Path -LiteralPath $CredentialPath)) { + throw "ApiAuth is required for the first installation." +} + +# Remove the legacy plaintext secret from the user environment. The new runner +# decrypts the DPAPI-protected credential only inside the agent process tree. +[Environment]::SetEnvironmentVariable("TRADEBOT_API_AUTH", $null, "User") +Remove-Item Env:TRADEBOT_API_AUTH -ErrorAction SilentlyContinue [Environment]::SetEnvironmentVariable("TRADEBOT_API_BASE_URL", $ApiBaseUrl, "User") [Environment]::SetEnvironmentVariable("TRADEBOT_TRAINING_WORKER_NAME", $env:COMPUTERNAME, "User") -$env:TRADEBOT_API_BASE_URL = $ApiBaseUrl -$env:TRADEBOT_TRAINING_WORKER_NAME = $env:COMPUTERNAME if (-not $KeepLegacyRetrainer) { foreach ($legacyName in @("TradeBot PyTorch Forecaster Retrainer", "TradeBot LSTM Retrainer")) { - $legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue - if ($legacyTask) { - Unregister-ScheduledTask -TaskName $legacyName -Confirm:$false - Write-Host "Removed legacy scheduled task '$legacyName'." + try { + $legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue + if ($legacyTask) { + Unregister-ScheduledTask -TaskName $legacyName -Confirm:$false + Write-Host "Removed legacy scheduled task '$legacyName'." + } + } + catch { + Write-Warning "Could not remove legacy scheduled task '$legacyName': $($_.Exception.Message)" } } } -$python = Resolve-WindowlessPython $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name -$arguments = @( - "-u", - "`"$Agent`"", - "--repo-root", "`"$RepoRoot`"", - "--api-base-url", "`"$ApiBaseUrl`"", - "--poll-seconds", $PollSeconds.ToString() +$principal = New-Object System.Security.Principal.WindowsPrincipal( + [System.Security.Principal.WindowsIdentity]::GetCurrent() +) +$isAdministrator = $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +$powershell = (Get-Command powershell.exe -ErrorAction Stop).Source +$runnerArguments = @( + "-NoProfile", + "-WindowStyle", "Hidden", + "-ExecutionPolicy", "Bypass", + "-File", "`"$runner`"", + "-RepoRoot", "`"$RepoRoot`"", + "-ApiBaseUrl", "`"$ApiBaseUrl`"", + "-CredentialPath", "`"$CredentialPath`"", + "-WorkerName", "`"$env:COMPUTERNAME`"", + "-PollSeconds", $PollSeconds.ToString() ) -join " " -$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $RepoRoot -$trigger = @( - 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) +$startupShortcut = Join-Path ([Environment]::GetFolderPath("Startup")) "$TaskName.lnk" +$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" +Remove-ItemProperty -Path $runKey -Name "TradeBotWindowsTrainingAgent" -ErrorAction SilentlyContinue -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 +$installMode = "startup shortcut" +if ($isAdministrator) { + if (Test-Path -LiteralPath $startupShortcut) { + Remove-Item -LiteralPath $startupShortcut -Force + } + $action = New-ScheduledTaskAction -Execute $powershell -Argument $runnerArguments -WorkingDirectory $RepoRoot + $trigger = @( + New-ScheduledTaskTrigger -AtLogOn -User $currentUser + New-ScheduledTaskTrigger -AtStartup + New-ScheduledTaskTrigger ` + -Once ` + -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) ` + -RepetitionDuration (New-TimeSpan -Days 3650) + ) + $taskPrincipal = New-ScheduledTaskPrincipal ` + -UserId $currentUser ` + -LogonType Interactive ` + -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet ` + -StartWhenAvailable ` + -MultipleInstances IgnoreNew ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -RestartCount 999 ` + -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit (New-TimeSpan -Days 30) -if ($StartNow) { - Start-ScheduledTask -TaskName $TaskName + Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $trigger ` + -Principal $taskPrincipal ` + -Settings $settings ` + -Description "Keeps the TradeBot Windows training agent online and polls the bot API for retrain jobs." ` + -Force | Out-Null + $installMode = "scheduled task" +} +else { + try { + $existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($existingTask) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false + } + } + catch { + Write-Warning "Could not remove an existing elevated task: $($_.Exception.Message)" + } + + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($startupShortcut) + $shortcut.TargetPath = $powershell + $shortcut.Arguments = $runnerArguments + $shortcut.WorkingDirectory = $RepoRoot + $shortcut.WindowStyle = 7 + $shortcut.Description = "TradeBot Windows Training Agent" + $shortcut.Save() } -Write-Host "Registered scheduled task '$TaskName' for Windows startup, logon, and watchdog restarts." +Get-CimInstance Win32_Process | + Where-Object { + $_.ProcessId -ne $PID -and + $_.CommandLine -and + ($_.CommandLine -match [regex]::Escape("windows_training_agent.py") -or + $_.CommandLine -match [regex]::Escape("run_windows_training_agent.ps1")) + } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + +if ($StartNow) { + if ($installMode -eq "scheduled task") { + Start-ScheduledTask -TaskName $TaskName + } + else { + Start-Process ` + -FilePath $powershell ` + -ArgumentList $runnerArguments ` + -WorkingDirectory $RepoRoot ` + -WindowStyle Hidden | Out-Null + } +} + +Write-Host "Installed '$TaskName' using $installMode." Write-Host "Agent API: $ApiBaseUrl" -Write-Host "Agent script: $Agent" +Write-Host "Encrypted credential: $CredentialPath" +Write-Host "Agent runner: $runner" diff --git a/tools/run_torch_retrain.ps1 b/tools/run_torch_retrain.ps1 index ac843a3..6f4fd53 100644 --- a/tools/run_torch_retrain.ps1 +++ b/tools/run_torch_retrain.ps1 @@ -12,6 +12,10 @@ param( [string]$Features = "", [string]$ContextSymbols = "", [int]$Seed = 0, + [string]$EnsembleSeeds = "", + [int]$SelectionFolds = 0, + [double]$LearningRate = 0, + [double]$WeightDecay = 0, [int]$Epochs = 0, [int]$Patience = 0, [int]$HoldoutWindow = 0, @@ -132,18 +136,22 @@ function Sync-AcceptedArtifactsToPi { if (-not $Symbols -and $env:TORCH_RETRAIN_SYMBOLS) { $Symbols = $env:TORCH_RETRAIN_SYMBOLS } if ($Limit -le 0) { - $Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 3000 } + $Limit = if ($env:TORCH_RETRAIN_LIMIT) { [int]$env:TORCH_RETRAIN_LIMIT } else { 6000 } } -if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "64" } } +if (-not $Lookbacks) { $Lookbacks = if ($env:TORCH_RETRAIN_LOOKBACKS) { $env:TORCH_RETRAIN_LOOKBACKS } else { "32,64,128" } } if (-not $Architectures) { $Architectures = if ($env:TORCH_RETRAIN_ARCHITECTURES) { $env:TORCH_RETRAIN_ARCHITECTURES } else { "lstm,gru" } } if (-not $HiddenSizes) { $HiddenSizes = if ($env:TORCH_RETRAIN_HIDDEN_SIZES) { $env:TORCH_RETRAIN_HIDDEN_SIZES } else { "64,96" } } if (-not $Layers) { $Layers = if ($env:TORCH_RETRAIN_LAYERS) { $env:TORCH_RETRAIN_LAYERS } else { "2" } } -if (-not $Dropouts) { $Dropouts = if ($env:TORCH_RETRAIN_DROPOUTS) { $env:TORCH_RETRAIN_DROPOUTS } else { "0.15" } } -if ($Horizon -le 0 -and $env:TORCH_RETRAIN_HORIZON) { $Horizon = [int]$env:TORCH_RETRAIN_HORIZON } -if (-not $Horizons -and $env:TORCH_RETRAIN_HORIZONS) { $Horizons = $env:TORCH_RETRAIN_HORIZONS } +if (-not $Dropouts) { $Dropouts = if ($env:TORCH_RETRAIN_DROPOUTS) { $env:TORCH_RETRAIN_DROPOUTS } else { "0.20" } } +if ($Horizon -le 0) { $Horizon = if ($env:TORCH_RETRAIN_HORIZON) { [int]$env:TORCH_RETRAIN_HORIZON } else { 12 } } +if (-not $Horizons) { $Horizons = if ($env:TORCH_RETRAIN_HORIZONS) { $env:TORCH_RETRAIN_HORIZONS } else { "3,6,12,24" } } if (-not $Features -and $env:TORCH_RETRAIN_FEATURES) { $Features = $env:TORCH_RETRAIN_FEATURES } if (-not $ContextSymbols -and $env:TORCH_RETRAIN_CONTEXT_SYMBOLS) { $ContextSymbols = $env:TORCH_RETRAIN_CONTEXT_SYMBOLS } if ($Seed -le 0 -and $env:TORCH_RETRAIN_SEED) { $Seed = [int]$env:TORCH_RETRAIN_SEED } +if (-not $EnsembleSeeds) { $EnsembleSeeds = if ($env:TORCH_RETRAIN_ENSEMBLE_SEEDS) { $env:TORCH_RETRAIN_ENSEMBLE_SEEDS } else { "7,19,43" } } +if ($SelectionFolds -le 0) { $SelectionFolds = if ($env:TORCH_RETRAIN_SELECTION_FOLDS) { [int]$env:TORCH_RETRAIN_SELECTION_FOLDS } else { 3 } } +if ($LearningRate -le 0) { $LearningRate = if ($env:TORCH_RETRAIN_LEARNING_RATE) { [double]$env:TORCH_RETRAIN_LEARNING_RATE } else { 0.0007 } } +if ($WeightDecay -le 0) { $WeightDecay = if ($env:TORCH_RETRAIN_WEIGHT_DECAY) { [double]$env:TORCH_RETRAIN_WEIGHT_DECAY } else { 0.0005 } } if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } } if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } } if ($HoldoutWindow -le 0) { $HoldoutWindow = if ($env:TORCH_RETRAIN_HOLDOUT_WINDOW) { [int]$env:TORCH_RETRAIN_HOLDOUT_WINDOW } else { 240 } } @@ -182,6 +190,10 @@ try { "--epochs", $Epochs.ToString(), "--patience", $Patience.ToString(), "--holdout-window", $HoldoutWindow.ToString(), + "--ensemble-seeds", $EnsembleSeeds, + "--selection-folds", $SelectionFolds.ToString(), + "--learning-rate", $LearningRate.ToString([Globalization.CultureInfo]::InvariantCulture), + "--weight-decay", $WeightDecay.ToString([Globalization.CultureInfo]::InvariantCulture), "--output", $CandidateFile ) if ($Symbols) { $trainerArgs += @("--symbols", $Symbols) } @@ -222,8 +234,8 @@ try { $calibrationBaseArgs = @( "-u", "tools\calibrate_torch_thresholds.py", - "--limit", "3000", - "--calibration-window", "1200", + "--limit", $Limit.ToString(), + "--calibration-window", ([Math]::Min(2400, [Math]::Max(1200, [int]($Limit / 2)))).ToString(), "--min-trades", "60", "--walk-forward-folds", "8", "--confidence-grid", "0.40" diff --git a/tools/run_windows_training_agent.ps1 b/tools/run_windows_training_agent.ps1 new file mode 100644 index 0000000..5ec375a --- /dev/null +++ b/tools/run_windows_training_agent.ps1 @@ -0,0 +1,82 @@ +[CmdletBinding()] +param( + [string]$ApiBaseUrl = "https://tb.kusoft.xyz", + [string]$RepoRoot = "", + [string]$CredentialPath = "", + [string]$WorkerName = $env:COMPUTERNAME, + [int]$PollSeconds = 10, + [int]$RestartDelaySeconds = 10 +) + +$ErrorActionPreference = "Stop" + +if (-not $RepoRoot) { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +} +if (-not $CredentialPath) { + $CredentialPath = Join-Path $env:LOCALAPPDATA "TradeBot\training-agent.token" +} + +$agent = Join-Path $RepoRoot "tools\windows_training_agent.py" +if (-not (Test-Path -LiteralPath $agent)) { + throw "Windows training agent not found: $agent" +} +if (-not (Test-Path -LiteralPath $CredentialPath)) { + throw "Encrypted training credential not found: $CredentialPath" +} + +function Resolve-Python { + $venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe" + if (Test-Path -LiteralPath $venvPython) { + return $venvPython + } + + $userPython = Join-Path $env:LOCALAPPDATA "Programs\TradeBotPython312\python.exe" + if (Test-Path -LiteralPath $userPython) { + return $userPython + } + + foreach ($candidate in @("python.exe", "python")) { + $command = Get-Command $candidate -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + } + throw "Python was not found. Create .venv or install Python 3.12." +} + +$createdNew = $false +$mutex = [System.Threading.Mutex]::new($false, "Local\TradeBotWindowsTrainingAgent", [ref]$createdNew) +if (-not $createdNew) { + $mutex.Dispose() + exit 0 +} + +$encryptedToken = (Get-Content -LiteralPath $CredentialPath -Raw -Encoding UTF8).Trim() +$secureToken = $encryptedToken | ConvertTo-SecureString +$tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken) +try { + $env:TRADEBOT_API_AUTH = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer) + $python = Resolve-Python + $workerId = "${WorkerName}:$RepoRoot" + $arguments = @( + "-u", + $agent, + "--repo-root", $RepoRoot, + "--api-base-url", $ApiBaseUrl, + "--worker-id", $workerId, + "--worker-name", $WorkerName, + "--poll-seconds", [Math]::Max(5, $PollSeconds).ToString() + ) + + while ($true) { + & $python @arguments + Start-Sleep -Seconds ([Math]::Max(5, $RestartDelaySeconds)) + } +} +finally { + $env:TRADEBOT_API_AUTH = $null + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer) + $mutex.ReleaseMutex() + $mutex.Dispose() +} diff --git a/tools/train_torch_recurrent_forecaster.py b/tools/train_torch_recurrent_forecaster.py index b6805d9..08f14d1 100644 --- a/tools/train_torch_recurrent_forecaster.py +++ b/tools/train_torch_recurrent_forecaster.py @@ -125,6 +125,9 @@ def main() -> None: decision_horizon = args.horizon if args.horizon > 0 else max(1, settings.time_series_forecast_horizon) target_horizons = _horizons(args.horizons, decision_horizon) feature_names = _feature_names_arg(args.features) + if args.pooled: + feature_names.extend(f"symbol_is_{symbol}" for symbol in symbols) + ensemble_seeds = _ints(args.ensemble_seeds) or [args.seed] 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} " @@ -151,9 +154,82 @@ def main() -> None: "feature_names": feature_names, "feature_count": len(feature_names), "device": str(device), + "ensemble_seeds": ensemble_seeds, + "selection_folds": args.selection_folds, "symbols": {}, } + if args.pooled: + artifact["version"] = 5 + artifact["pooled_multi_asset"] = True + artifact["symbol_embedding"] = "learned_one_hot_projection" + artifact["symbols"] = _train_pooled_symbols( + client=client, + symbols=symbols, + interval=interval, + limit=args.limit, + validation_window=args.validation_window, + holdout_window=args.holdout_window, + target_horizons=target_horizons, + decision_horizon=decision_horizon, + feature_names=feature_names, + round_trip_cost=round_trip_cost, + context_symbols=_strings(args.context_symbols), + architectures=_strings(args.architectures), + lookbacks=_ints(args.lookbacks), + hidden_sizes=_ints(args.hidden_sizes), + layers_values=_ints(args.layers), + dropouts=_floats(args.dropouts), + epochs=args.epochs, + patience=args.patience, + batch_size=args.batch_size, + learning_rate=args.learning_rate, + weight_decay=args.weight_decay, + clip=args.clip, + attention_pooling=args.attention_pooling, + context_norm=args.context_norm, + device=device, + seeds=ensemble_seeds, + selection_folds=args.selection_folds, + ) + else: + artifact["symbols"] = _train_independent_symbols( + client=client, + symbols=symbols, + interval=interval, + args=args, + target_horizons=target_horizons, + decision_horizon=decision_horizon, + feature_names=feature_names, + round_trip_cost=round_trip_cost, + device=device, + ensemble_seeds=ensemble_seeds, + ) + + for symbol, result in artifact["symbols"].items(): + _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']))} " + f"mae={result['validation_mae_percent']:.5f}% " + f"baseline={result['baseline_mae_percent']:.5f}% " + f"skill={result['skill']:.4f} dir={result['directional_accuracy']:.3f} " + f"p_brier={result['probability_brier']:.4f}" + ) + + output.parent.mkdir(parents=True, exist_ok=True) + 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) + _progress(f"saved {output}") + + +def _train_independent_symbols( + *, client: BybitClient, symbols: list[str], interval: str, args: argparse.Namespace, + target_horizons: list[int], decision_horizon: int, feature_names: list[str], + round_trip_cost: float, device: torch.device, ensemble_seeds: list[int], +) -> dict[str, Any]: + results: dict[str, Any] = {} total_symbols = len(symbols) for index, symbol in enumerate(symbols, start=1): _progress(f"{symbol}: training started ({index}/{total_symbols})") @@ -183,27 +259,221 @@ def main() -> None: attention_pooling=args.attention_pooling, context_norm=args.context_norm, device=device, - seed=args.seed, + seeds=ensemble_seeds, + selection_folds=args.selection_folds, ) if result is None: _progress(f"{symbol}: skipped, not enough candles or train/validation samples") continue - artifact["symbols"][symbol] = result - _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']))} " - f"mae={result['validation_mae_percent']:.5f}% " - f"baseline={result['baseline_mae_percent']:.5f}% " - f"skill={result['skill']:.4f} dir={result['directional_accuracy']:.3f} " - f"p_brier={result['probability_brier']:.4f}" - ) + results[symbol] = result + return results - output.parent.mkdir(parents=True, exist_ok=True) - 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) - _progress(f"saved {output}") + +def _train_pooled_symbols( + *, client: BybitClient, symbols: list[str], interval: str, limit: int, + validation_window: int, holdout_window: int, target_horizons: list[int], + decision_horizon: int, feature_names: list[str], round_trip_cost: float, + context_symbols: list[str], architectures: list[str], lookbacks: list[int], + hidden_sizes: list[int], layers_values: list[int], dropouts: list[float], + epochs: int, patience: int, batch_size: int, learning_rate: float, + weight_decay: float, clip: float, attention_pooling: bool, context_norm: bool, + device: torch.device, seeds: list[int], selection_folds: int, +) -> dict[str, Any]: + market_candles: dict[str, list[Candle]] = {} + for symbol in sorted({item.upper() for item in symbols + context_symbols}): + rows = _historical_klines(client, symbol, interval, limit) + add_indicators(rows) + market_candles[symbol] = rows + _progress(f"{symbol}: pooled data loaded ({len(rows)} candles)") + trend_by_symbol: dict[str, list[Candle]] = {} + for symbol in symbols: + rows = _historical_klines(client, symbol, "D", min(max(260, limit // 24 + 260), 1000)) + add_indicators(rows) + trend_by_symbol[symbol] = rows + + best: dict[str, Any] | None = None + best_prepared: dict[str, PreparedData] = {} + for lookback in lookbacks: + prepared_by_symbol: dict[str, PreparedData] = {} + for symbol in symbols: + prepared = _prepare_data( + candles=market_candles[symbol], + feature_names=feature_names, + lookback=lookback, + target_horizons=target_horizons, + decision_horizon=decision_horizon, + round_trip_cost=round_trip_cost, + market_candles=market_candles, + trend_candles=trend_by_symbol[symbol], + validation_window=validation_window, + holdout_window=holdout_window, + clip=clip, + device=device, + ) + if prepared is not None: + prepared_by_symbol[symbol] = prepared + if len(prepared_by_symbol) < 2: + continue + for architecture in architectures: + if architecture not in {"lstm", "gru"}: + continue + for hidden_size in hidden_sizes: + for num_layers in layers_values: + for dropout in dropouts: + if num_layers <= 1 and dropout != 0.0: + continue + _progress( + f"pooled: fitting {architecture} lookback={lookback} hidden={hidden_size} " + f"layers={num_layers} dropout={dropout} symbols={len(prepared_by_symbol)}" + ) + members = [ + _fit_pooled_candidate( + prepared_by_symbol=prepared_by_symbol, + architecture=architecture, + input_size=len(feature_names), + output_size=len(target_horizons) * len(OUTPUT_LAYOUT), + hidden_size=hidden_size, + num_layers=num_layers, + dropout=dropout, + epochs=epochs, + patience=patience, + batch_size=batch_size, + learning_rate=learning_rate, + weight_decay=weight_decay, + clip=clip, + attention_pooling=attention_pooling, + context_norm=context_norm, + device=device, + seed=member_seed, + selection_folds=selection_folds, + ) + for member_seed in seeds + ] + candidate = _ensemble_candidate(members, seeds) + candidate.update( + model=f"torch_{architecture}", architecture=architecture, + lookback=lookback, hidden_size=hidden_size, num_layers=num_layers, + dropout=dropout if num_layers > 1 else 0.0, + attention_pooling=attention_pooling, context_norm=context_norm, + input_size=len(feature_names), output_size=len(target_horizons) * len(OUTPUT_LAYOUT), + ) + if best is None or _candidate_score(candidate) < _candidate_score(best): + best = candidate + best_prepared = prepared_by_symbol + if best is None: + return {} + + results: dict[str, Any] = {} + symbol_metrics = best.get("symbol_metrics", {}) + common = {key: value for key, value in best.items() if key != "symbol_metrics"} + for symbol, prepared in best_prepared.items(): + metrics = symbol_metrics.get(symbol, {}) if isinstance(symbol_metrics, dict) else {} + baseline = sum(abs(row[prepared.decision_horizon_index]) for row in prepared.validation_targets) / len(prepared.validation_targets) + validation_mae = float(metrics.get("validation_mae", baseline)) + results[symbol] = { + **common, **metrics, + "pooled_multi_asset": True, + "target_horizon": prepared.decision_horizon, + "target_horizons": prepared.target_horizons, + "direct_horizon": True, + "target_transform": "net_return_over_volatility", + "round_trip_cost": round(round_trip_cost, 10), + "output_layout": list(OUTPUT_LAYOUT), + "feature_names": feature_names, + "feature_means": prepared.feature_means, + "feature_scales": prepared.feature_scales, + "target_means": prepared.target_means, + "target_scales": prepared.target_scales, + "target_mean": prepared.target_means[prepared.decision_horizon_index], + "target_scale": prepared.target_scales[prepared.decision_horizon_index], + "clip": clip, + "validation_mae_percent": validation_mae * 100, + "baseline_mae_percent": baseline * 100, + "validation_skill": (baseline - validation_mae) / baseline if baseline > 0 else 0.0, + # Runtime and calibration may use validation skill. Untouched + # holdout skill is report-only and must never gate individual entries. + "skill": (baseline - validation_mae) / baseline if baseline > 0 else 0.0, + "train_samples": prepared.train_samples, + "validation_samples": prepared.validation_samples, + "holdout_samples": prepared.holdout_samples, + "holdout_start_timestamp": prepared.holdout_start_timestamp, + } + return results + + +def _fit_pooled_candidate( + *, prepared_by_symbol: dict[str, PreparedData], architecture: str, input_size: int, + output_size: int, hidden_size: int, num_layers: int, dropout: float, epochs: int, + patience: int, batch_size: int, learning_rate: float, weight_decay: float, + clip: float, attention_pooling: bool, context_norm: bool, device: torch.device, + seed: int, selection_folds: int, +) -> dict[str, Any]: + _seed(seed) + model = RecurrentReturnModel( + architecture=architecture, input_size=input_size, hidden_size=hidden_size, + num_layers=num_layers, dropout=dropout, output_size=output_size, + attention_pooling=attention_pooling, context_norm=context_norm, + ).to(device) + optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay) + loader = DataLoader( + TensorDataset( + torch.cat([row.train_x for row in prepared_by_symbol.values()]), + torch.cat([row.train_y for row in prepared_by_symbol.values()]), + torch.cat([row.train_up for row in prepared_by_symbol.values()]), + ), + batch_size=max(1, batch_size), shuffle=True, + generator=torch.Generator(device="cpu").manual_seed(seed), + ) + best_state: dict[str, torch.Tensor] | None = None + best_score = math.inf + stale = 0 + best_epoch = 0 + for epoch in range(1, max(1, epochs) + 1): + model.train() + for batch_x, batch_y, batch_up in loader: + optimizer.zero_grad(set_to_none=True) + loss = _forecast_loss(model(batch_x), batch_y, batch_up, len(next(iter(prepared_by_symbol.values())).target_horizons)) + loss.backward() + nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) + optimizer.step() + symbol_rows = { + symbol: _validation_metrics(model, prepared, clip) + for symbol, prepared in prepared_by_symbol.items() + } + score = sum(float(row["validation_mae"]) for row in symbol_rows.values()) / len(symbol_rows) + if score + 1e-12 < best_score: + best_score = score + best_epoch = epoch + best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()} + stale = 0 + else: + stale += 1 + if stale >= max(1, patience): + break + if best_state: + model.load_state_dict(best_state) + per_symbol: dict[str, dict[str, Any]] = {} + for symbol, prepared in prepared_by_symbol.items(): + metrics = _validation_metrics(model, prepared, clip) + metrics.update(_validation_stability_metrics(model, prepared, clip, selection_folds)) + metrics.update(_holdout_metrics(model, prepared, clip)) + per_symbol[symbol] = metrics + aggregate: dict[str, Any] = {"symbol_metrics": per_symbol} + for name in ( + "validation_mae", "directional_accuracy", "buy_precision", "probability_brier", + "holdout_skill", "validation_fold_mae_std", "validation_trade_mean", + "validation_trade_win_rate", + ): + values = [float(row[name]) for row in per_symbol.values() if isinstance(row.get(name), (int, float))] + aggregate[name] = sum(values) / len(values) if values else 0.0 + aggregate.update( + best_epoch=best_epoch, epochs_trained=best_epoch + stale, + state_dict=_export_recurrent_state(model), + head_weight=_round_nested(model.head.weight.detach().cpu().tolist()), + head_bias=_round_list(model.head.bias.detach().cpu().tolist()), + **_export_context_state(model), + ) + return aggregate def _progress(message: str) -> None: @@ -236,6 +506,9 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--attention-pooling", action=argparse.BooleanOptionalAction, default=True, help="Use exportable attention pooling over recurrent states.") parser.add_argument("--context-norm", action=argparse.BooleanOptionalAction, default=True, help="Use exportable LayerNorm before the forecast head.") parser.add_argument("--seed", type=int, default=7, help="Random seed.") + parser.add_argument("--ensemble-seeds", default="7,19,43", help="Comma-separated seeds averaged at inference time.") + parser.add_argument("--selection-folds", type=int, default=3, help="Validation slices used to penalize unstable candidates.") + parser.add_argument("--pooled", action=argparse.BooleanOptionalAction, default=True, help="Train shared multi-asset recurrent weights with learned symbol one-hot projection.") parser.add_argument("--threads", type=int, default=0, help="Torch CPU threads; 0 keeps torch default.") parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or mps.") parser.add_argument("--output", default="", help="Output JSON path. Defaults to TIME_SERIES_LSTM_MODEL_PATH.") @@ -277,7 +550,8 @@ def _train_symbol( attention_pooling: bool, context_norm: bool, device: torch.device, - seed: int, + seeds: list[int], + selection_folds: int, ) -> dict[str, Any] | None: candles = _historical_klines(client, symbol, interval, limit) add_indicators(candles) @@ -339,25 +613,30 @@ def _train_symbol( f"lookback={lookback} hidden={hidden_size} " f"layers={num_layers} dropout={dropout}" ) - candidate = _fit_candidate( - prepared=prepared, - architecture=architecture, - input_size=len(feature_names), - output_size=len(target_horizons) * len(OUTPUT_LAYOUT), - hidden_size=hidden_size, - num_layers=num_layers, - dropout=dropout, - epochs=epochs, - patience=patience, - batch_size=batch_size, - learning_rate=learning_rate, - weight_decay=weight_decay, - clip=clip, - attention_pooling=attention_pooling, - context_norm=context_norm, - device=device, - seed=seed, - ) + members = [ + _fit_candidate( + prepared=prepared, + architecture=architecture, + input_size=len(feature_names), + output_size=len(target_horizons) * len(OUTPUT_LAYOUT), + hidden_size=hidden_size, + num_layers=num_layers, + dropout=dropout, + epochs=epochs, + patience=patience, + batch_size=batch_size, + learning_rate=learning_rate, + weight_decay=weight_decay, + clip=clip, + attention_pooling=attention_pooling, + context_norm=context_norm, + device=device, + seed=member_seed, + selection_folds=selection_folds, + ) + for member_seed in seeds + ] + candidate = _ensemble_candidate(members, seeds) validation_mae = float(candidate["validation_mae"]) skill = (baseline_mae - validation_mae) / baseline_mae if baseline_mae > 0 else 0.0 row = { @@ -408,7 +687,7 @@ def _train_symbol( if best is None: return None best["validation_skill"] = best.get("skill", 0.0) - best["skill"] = best.get("holdout_skill", 0.0) + best["skill"] = best["validation_skill"] best.pop("validation_mae", None) return best @@ -484,7 +763,7 @@ def _prepare_data( if len(train_samples) < 24 or len(validation_samples) < 8 or len(holdout_samples) < 16: return None - feature_means, feature_scales = _feature_stats(train_samples, len(feature_names)) + feature_means, feature_scales = _feature_stats(train_samples, feature_names) target_means, target_scales = _target_stats(train_samples, len(target_horizons)) decision_horizon = decision_horizon if decision_horizon in target_horizons else min( target_horizons, @@ -545,7 +824,8 @@ def _prepare_data( ) -def _feature_stats(samples: list[TrainingSample], input_size: int) -> tuple[list[float], list[float]]: +def _feature_stats(samples: list[TrainingSample], feature_names: list[str]) -> tuple[list[float], list[float]]: + input_size = len(feature_names) columns = [[] for _ in range(input_size)] for sample in samples: window = sample.window @@ -554,7 +834,11 @@ def _feature_stats(samples: list[TrainingSample], input_size: int) -> tuple[list columns[index].append(float(row[index] if index < len(row) else 0.0)) means: list[float] = [] scales: list[float] = [] - for values in columns: + for index, values in enumerate(columns): + if feature_names[index].startswith("symbol_is_"): + means.append(0.0) + scales.append(1.0) + continue if not values: means.append(0.0) scales.append(1.0) @@ -641,6 +925,7 @@ def _fit_candidate( context_norm: bool, device: torch.device, seed: int, + selection_folds: int, ) -> dict[str, Any]: _seed(seed) model = RecurrentReturnModel( @@ -676,6 +961,7 @@ def _fit_candidate( optimizer.step() metrics = _validation_metrics(model, prepared, clip) + metrics.update(_validation_stability_metrics(model, prepared, clip, selection_folds)) if metrics["validation_mae"] + 1e-12 < best_metrics["validation_mae"]: best_metrics = metrics best_epoch = epoch @@ -701,6 +987,66 @@ def _fit_candidate( } +def _ensemble_candidate(members: list[dict[str, Any]], seeds: list[int]) -> dict[str, Any]: + if not members: + raise ValueError("ensemble requires at least one member") + result = dict(members[0]) + metric_names = ( + "validation_mae", + "directional_accuracy", + "buy_precision", + "probability_brier", + "holdout_mae", + "holdout_baseline_mae", + "holdout_skill", + "holdout_directional_accuracy", + "holdout_buy_precision", + "holdout_probability_brier", + "validation_fold_mae_std", + "validation_fold_mae_worst", + "validation_trade_mean", + "validation_trade_win_rate", + ) + for name in metric_names: + values = [float(member[name]) for member in members if isinstance(member.get(name), (int, float))] + if values: + result[name] = sum(values) / len(values) + export_names = ( + "state_dict", + "head_weight", + "head_bias", + "attention_weight", + "attention_bias", + "context_norm_weight", + "context_norm_bias", + ) + result["ensemble_members"] = [ + {name: member[name] for name in export_names if name in member} + | {"seed": seeds[index] if index < len(seeds) else index} + for index, member in enumerate(members) + ] + result["ensemble_size"] = len(members) + symbol_names = sorted( + { + symbol + for member in members + for symbol in (member.get("symbol_metrics") or {}) + } + ) + if symbol_names: + result["symbol_metrics"] = {} + for symbol in symbol_names: + rows = [member.get("symbol_metrics", {}).get(symbol, {}) for member in members] + keys = {key for row in rows if isinstance(row, dict) for key in row} + averaged: dict[str, float] = {} + for key in keys: + values = [float(row[key]) for row in rows if isinstance(row.get(key), (int, float))] + if values: + averaged[key] = sum(values) / len(values) + result["symbol_metrics"][symbol] = averaged + return result + + def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, float]: return _evaluation_metrics( model, @@ -712,6 +1058,39 @@ def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) - ) +def _validation_stability_metrics( + model: nn.Module, + prepared: PreparedData, + clip: float, + folds: int, +) -> dict[str, float]: + fold_count = max(1, min(int(folds), len(prepared.validation_targets))) + fold_size = max(1, len(prepared.validation_targets) // fold_count) + maes: list[float] = [] + for fold in range(fold_count): + start = fold * fold_size + end = len(prepared.validation_targets) if fold == fold_count - 1 else min(len(prepared.validation_targets), start + fold_size) + if end <= start: + continue + metrics = _evaluation_metrics( + model, + values=prepared.validation_x[start:end], + targets=prepared.validation_targets[start:end], + volatility_scales=prepared.validation_volatility_scales[start:end], + prepared=prepared, + clip=clip, + ) + maes.append(float(metrics["validation_mae"])) + if not maes: + return {"validation_fold_mae_std": 0.0, "validation_fold_mae_worst": math.inf} + mean = sum(maes) / len(maes) + variance = sum((value - mean) ** 2 for value in maes) / len(maes) + return { + "validation_fold_mae_std": math.sqrt(variance), + "validation_fold_mae_worst": max(maes), + } + + def _holdout_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, Any]: metrics = _evaluation_metrics( model, @@ -790,6 +1169,13 @@ def _evaluation_metrics( if prediction > 0 ] buy_wins = [actual for actual in buy_predictions if actual > 0] + ranked = sorted( + zip(decision_predictions, [row[decision] for row in probabilities], decision_targets), + key=lambda item: item[0] * max(0.0, item[1] - 0.5), + reverse=True, + ) + selected = ranked[: max(8, len(ranked) // 5)] + selected_targets = [row[2] for row in selected] by_horizon = {} baseline_by_horizon = {} for horizon_index, horizon in enumerate(prepared.target_horizons): @@ -815,6 +1201,12 @@ def _evaluation_metrics( "directional_accuracy": len(correct) / len(non_zero) if non_zero else 0.0, "buy_precision": len(buy_wins) / len(buy_predictions) if buy_predictions else 0.0, "probability_brier": sum(probability_errors) / len(probability_errors) if probability_errors else 1.0, + "validation_trade_mean": sum(selected_targets) / len(selected_targets) if selected_targets else 0.0, + "validation_trade_win_rate": ( + sum(1 for value in selected_targets if value > 0) / len(selected_targets) + if selected_targets + else 0.0 + ), } @@ -824,9 +1216,13 @@ def _candidate_score(row: dict[str, Any]) -> float: directional = float(row.get("directional_accuracy", 0.0)) buy_precision = float(row.get("buy_precision", 0.0)) probability_brier = float(row.get("probability_brier", 1.0)) - return mae * (1.0 - max(0.0, skill) * 0.05) * (1.0 - max(0.0, directional - 0.5) * 0.03) * ( + fold_std = max(0.0, float(row.get("validation_fold_mae_std", 0.0))) + stability_penalty = 1.0 + min(1.0, fold_std / max(mae, 1e-9)) * 0.25 + trade_mean = float(row.get("validation_trade_mean", 0.0)) + trade_penalty = max(0.0, -trade_mean) * 2.0 - max(0.0, trade_mean) * 0.5 + return mae * stability_penalty * (1.0 - max(0.0, skill) * 0.05) * (1.0 - max(0.0, directional - 0.5) * 0.03) * ( 1.0 - max(0.0, buy_precision - 0.5) * 0.02 - ) * (1.0 + max(0.0, probability_brier - 0.25) * 0.02) + ) * (1.0 + max(0.0, probability_brier - 0.25) * 0.02) + trade_penalty def _forecast_loss(outputs: torch.Tensor, targets: torch.Tensor, up_targets: torch.Tensor, horizon_count: int) -> torch.Tensor: @@ -842,7 +1238,19 @@ def _forecast_loss(outputs: torch.Tensor, targets: torch.Tensor, up_targets: tor probabilities = torch.sigmoid(logits) pt = probabilities * up_targets + (1.0 - probabilities) * (1.0 - up_targets) focal = ((1.0 - pt) ** 2.0 * bce).mean() - return mean_loss + 0.35 * sum(quantile_losses) / len(quantile_losses) + 0.15 * focal + soft_long = torch.sigmoid(values[:, :, 0] * 2.0) * probabilities + after_cost_utility = -(soft_long * targets).mean() + prediction_centered = values[:, :, 0] - values[:, :, 0].mean(dim=0, keepdim=True) + target_centered = targets - targets.mean(dim=0, keepdim=True) + cosine = nn.functional.cosine_similarity(prediction_centered, target_centered, dim=0).mean() + ranking_loss = 1.0 - cosine + return ( + mean_loss + + 0.35 * sum(quantile_losses) / len(quantile_losses) + + 0.15 * focal + + 0.10 * after_cost_utility + + 0.05 * ranking_loss + ) def _export_recurrent_state(model: RecurrentReturnModel) -> dict[str, Any]: diff --git a/tools/windows_training_agent.py b/tools/windows_training_agent.py index f949d7a..a9f00b8 100644 --- a/tools/windows_training_agent.py +++ b/tools/windows_training_agent.py @@ -64,19 +64,20 @@ def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_ try: run_retrain(args, job_id, job, repo_root, log_path) summary = read_json(runtime_dir / "torch_retrain_guard.json") - if summary.get("accepted") is not True: - raise RuntimeError( - "candidate rejected by untouched-holdout guard: " - + str(summary.get("reason") or "validation failed") - ) - 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) + accepted = summary.get("accepted") is True + if accepted: + 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) + message = "training completed; candidate accepted" + log(log_path, f"Completed retrain job {job_id}; candidate accepted") + else: + reason = str(summary.get("reason") or "validation failed") + message = f"training completed; candidate rejected by quality gate: {reason}" + log(log_path, f"Completed retrain job {job_id}; candidate rejected: {reason}") success = True - 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}") @@ -108,6 +109,10 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo "dropouts": "-Dropouts", "epochs": "-Epochs", "holdout_window": "-HoldoutWindow", + "ensemble_seeds": "-EnsembleSeeds", + "selection_folds": "-SelectionFolds", + "learning_rate": "-LearningRate", + "weight_decay": "-WeightDecay", } for key, ps_arg in arg_map.items(): value = parameters.get(key)