Add global Windows training agent queue

This commit is contained in:
Codex
2026-06-27 08:56:54 +03:00
parent 98d6277d65
commit f8611a1c3f
12 changed files with 723 additions and 54 deletions
+8
View File
@@ -89,6 +89,14 @@ powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1
powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1 powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1
``` ```
Для удалённого запуска с телефона или с бота используется Windows training agent. Бот на `tb.kusoft.xyz` хранит очередь заданий, а Windows-машина сама подключается к интернету, забирает задания, обучает модель и загружает артефакты обратно:
```powershell
powershell -ExecutionPolicy Bypass -File tools\install_windows_training_agent.ps1 -ApiAuth "login:password" -StartNow
```
Установщик регистрирует Scheduled Task `TradeBot Windows Training Agent` при входе в Windows и удаляет старые локальные retrain-задачи, чтобы обучение запускалось через очередь, а не двумя независимыми механизмами.
По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на 12 spot-парах из `SYMBOLS`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`. По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на 12 spot-парах из `SYMBOLS`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`.
Если retrain запускается с `-DeployToPi`, после успешного guard он синхронизирует `runtime/lstm_forecaster.json`, `runtime/torch_retrain_guard.json` и `runtime/torch_threshold_calibration.json` на Raspberry Pi через SSH-ключ и перезапускает сервис `tradebot`. Отдельный запуск sync: Если retrain запускается с `-DeployToPi`, после успешного guard он синхронизирует `runtime/lstm_forecaster.json`, `runtime/torch_retrain_guard.json` и `runtime/torch_threshold_calibration.json` на Raspberry Pi через SSH-ключ и перезапускает сервис `tradebot`. Отдельный запуск sync:
+2 -2
View File
@@ -10,7 +10,7 @@
- Параметры Torch: edge, P(up), confidence, skill, quantiles, gate, причина решения. - Параметры Torch: edge, P(up), confidence, skill, quantiles, gate, причина решения.
- Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill. - Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill.
- Обзор equity/cash/exposure/PnL и последних решений. - Обзор equity/cash/exposure/PnL и последних решений.
- Удалённый запуск retrain через настраиваемый webhook. - Удалённый запуск retrain через очередь заданий на боте и закреплённый Windows-компьютер обучения.
- Расписание retrain на телефоне: Android отправляет команду по расписанию, но обучение идёт на Windows-машине. - Расписание retrain на телефоне: Android отправляет команду по расписанию, но обучение идёт на Windows-машине.
- Настройки API, токена команд, тёмной/светлой темы. - Настройки API, токена команд, тёмной/светлой темы.
- Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой. - Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой.
@@ -46,7 +46,7 @@ https://tb.kusoft.xyz
## Переобучение ## Переобучение
Телефон не обучает модель локально. Вкладка `Обучение` отправляет POST на указанный webhook Windows-машины. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере. Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `DESKTOP-TMFDL0H` сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети.
## Live-торговля ## Live-торговля
+2 -2
View File
@@ -10,7 +10,7 @@ android {
applicationId = "xyz.kusoft.tradebotmonitor" applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 5 versionCode = 6
versionName = "0.2.2" versionName = "0.2.3"
} }
} }
@@ -23,10 +23,6 @@ class AppPrefs(context: Context) {
get() = prefs.getString("command_token", "") ?: "" get() = prefs.getString("command_token", "") ?: ""
set(value) = prefs.edit().putString("command_token", value.trim()).apply() set(value) = prefs.edit().putString("command_token", value.trim()).apply()
var retrainWebhookUrl: String
get() = prefs.getString("retrain_webhook_url", "") ?: ""
set(value) = prefs.edit().putString("retrain_webhook_url", value.trim()).apply()
var selectedSymbol: String var selectedSymbol: String
get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT" get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT"
set(value) = prefs.edit().putString("selected_symbol", value).apply() set(value) = prefs.edit().putString("selected_symbol", value).apply()
@@ -565,13 +565,20 @@ class MainActivity : Activity() {
return section("Открытые позиции", box) return section("Открытые позиции", box)
} }
private fun trainingComputerPanel(): View = private fun trainingComputerPanel(retrain: JSONObject): View =
LinearLayout(this).apply { LinearLayout(this).apply {
val coordination = retrain.optJSONObject("coordination") ?: JSONObject()
val activeJob = coordination.optJSONObject("active_job")
val agentOnline = coordination.optBoolean("agent_online", false)
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
addView(text("Компьютер обучения", 12f, Typeface.NORMAL, palette.muted)) addView(text("Компьютер обучения", 12f, Typeface.NORMAL, palette.muted))
addView(text(prefs.trainingComputerName, 18f, Typeface.BOLD, palette.green).top(dp(5))) 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(prefs.trainingComputerPath, 12f, Typeface.NORMAL, palette.muted).top(dp(4)))
addView(keyValueLine("Статус", if (prefs.trainingComputerPinned) "закреплен" else "не закреплен", if (prefs.trainingComputerPinned) palette.green else palette.amber).top(dp(8))) addView(keyValueLine("Статус", if (prefs.trainingComputerPinned) "закреплен" else "не закреплен", if (prefs.trainingComputerPinned) palette.green else palette.amber).top(dp(8)))
addView(keyValueLine("Связь агента", if (agentOnline) "онлайн" else "нет связи", if (agentOnline) palette.green else palette.amber).top(dp(4)))
if (activeJob != null) {
addView(keyValueLine("Задание", activeJob.optStringClean("status").ifBlank { "активно" }).top(dp(4)))
}
addView(text("Переобучение Torch закреплено за этой Windows-машиной. Телефон только управляет запуском и расписанием, Pi только исполняет бота.", 12f, Typeface.NORMAL, palette.muted).top(dp(8))) addView(text("Переобучение Torch закреплено за этой Windows-машиной. Телефон только управляет запуском и расписанием, Pi только исполняет бота.", 12f, Typeface.NORMAL, palette.muted).top(dp(8)))
addView(actionRow( addView(actionRow(
"Закрепить эту машину" to { "Закрепить эту машину" to {
@@ -583,24 +590,17 @@ class MainActivity : Activity() {
} }
private fun retrainSettingsBlock(retrain: JSONObject, backtest: JSONObject): View { private fun retrainSettingsBlock(retrain: JSONObject, backtest: JSONObject): View {
val webhookInput = input("URL запуска retrain на Windows", prefs.retrainWebhookUrl)
return LinearLayout(this).apply { return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
addView(keyValueLine("Доступно", yesNo(retrain.optBoolean("available", false)))) addView(keyValueLine("Доступно", yesNo(retrain.optBoolean("available", false))))
addView(keyValueLine("Принята новая модель", yesNo(retrain.optBoolean("accepted", false))).top(dp(4))) addView(keyValueLine("Принята новая модель", yesNo(retrain.optBoolean("accepted", false))).top(dp(4)))
addView(trainingComputerPanel().top(dp(12))) addView(trainingComputerPanel(retrain).top(dp(12)))
addView(thinDivider().top(dp(12))) addView(thinDivider().top(dp(12)))
val replay = backtest.optJSONObject("full_replay") ?: JSONObject() val replay = backtest.optJSONObject("full_replay") ?: JSONObject()
addView(keyValueLine("Replay сделок", replay.optInt("trades", 0).toString()).top(dp(4))) addView(keyValueLine("Replay сделок", replay.optInt("trades", 0).toString()).top(dp(4)))
addView(keyValueLine("Replay PnL", signedMoney(replay.optDouble("net_pnl", 0.0)), colorForSigned(replay.optDouble("net_pnl", 0.0))).top(dp(4))) addView(keyValueLine("Replay PnL", signedMoney(replay.optDouble("net_pnl", 0.0)), colorForSigned(replay.optDouble("net_pnl", 0.0))).top(dp(4)))
addView(webhookInput.top(dp(12)))
addView(actionRow( addView(actionRow(
"Сохранить URL" to {
prefs.retrainWebhookUrl = webhookInput.text.toString()
toast("URL retrain сохранен")
},
"Запустить сейчас" to { "Запустить сейчас" to {
prefs.retrainWebhookUrl = webhookInput.text.toString()
triggerRetrainNow() triggerRetrainNow()
}, },
).top(dp(10))) ).top(dp(10)))
@@ -956,7 +956,7 @@ class MainActivity : Activity() {
private fun triggerRetrainNow() { private fun triggerRetrainNow() {
executor.execute { executor.execute {
try { try {
val message = RetrainCommandClient(prefs.retrainWebhookUrl, prefs.commandToken).triggerNow() val message = TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
mainHandler.post { toast(message.take(180)) } mainHandler.post { toast(message.take(180)) }
} catch (error: Exception) { } catch (error: Exception) {
mainHandler.post { toast(error.message ?: "retrain не запущен") } mainHandler.post { toast(error.message ?: "retrain не запущен") }
@@ -42,8 +42,8 @@ class RetrainAlarmReceiver : BroadcastReceiver() {
Executors.newSingleThreadExecutor().execute { Executors.newSingleThreadExecutor().execute {
try { try {
val prefs = AppPrefs(context) val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled && prefs.retrainWebhookUrl.isNotBlank()) { if (prefs.retrainScheduleEnabled) {
RetrainCommandClient(prefs.retrainWebhookUrl, prefs.commandToken).triggerNow() TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
} }
} finally { } finally {
pending.finish() pending.finish()
@@ -52,11 +52,22 @@ class TradeBotApi(
postJson("/api/control/stop") postJson("/api/control/stop")
} }
fun requestRetrain(): String {
val response = postJson("/api/training/retrain", "{\"source\":\"android\"}")
if (response.optBoolean("queued", false)) {
return "Задание переобучения отправлено на закрепленный компьютер"
}
return when (response.optStringClean("reason")) {
"active_job_exists" -> "Задание переобучения уже выполняется или ждет агента"
else -> "Задание переобучения принято ботом"
}
}
private fun getJson(path: String): JSONObject = request(path, "GET") private fun getJson(path: String): JSONObject = request(path, "GET")
private fun postJson(path: String): JSONObject = request(path, "POST") private fun postJson(path: String, body: String = "{}"): JSONObject = request(path, "POST", body)
private fun request(path: String, method: String): JSONObject { private fun request(path: String, method: String, body: String = "{}"): JSONObject {
val root = baseUrl.trim().trimEnd('/') val root = baseUrl.trim().trimEnd('/')
val url = URL(root + path) val url = URL(root + path)
val connection = (url.openConnection() as HttpURLConnection).apply { val connection = (url.openConnection() as HttpURLConnection).apply {
@@ -68,7 +79,7 @@ class TradeBotApi(
if (method == "POST") { if (method == "POST") {
doOutput = true doOutput = true
setRequestProperty("Content-Type", "application/json") setRequestProperty("Content-Type", "application/json")
outputStream.use { stream -> stream.write("{}".toByteArray(StandardCharsets.UTF_8)) } outputStream.use { stream -> stream.write(body.toByteArray(StandardCharsets.UTF_8)) }
} }
} }
val code = connection.responseCode val code = connection.responseCode
@@ -216,35 +227,6 @@ class TradeBotApi(
} }
} }
class RetrainCommandClient(
private val webhookUrl: String,
private val token: String,
) {
fun triggerNow(): String {
if (webhookUrl.isBlank()) {
throw IllegalStateException("URL запуска retrain не задан")
}
val connection = (URL(webhookUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 6000
readTimeout = 15000
doOutput = true
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Accept", "application/json,text/plain,*/*")
applyAuthHeaders(this, token)
outputStream.use { stream -> stream.write("{\"source\":\"android\"}".toByteArray(StandardCharsets.UTF_8)) }
}
val code = connection.responseCode
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
val text = stream?.bufferedReader(StandardCharsets.UTF_8)?.use(BufferedReader::readText).orEmpty()
connection.disconnect()
if (code !in 200..299) {
throw IllegalStateException("Retrain HTTP $code: ${text.take(240)}")
}
return text.ifBlank { "Команда retrain отправлена" }
}
}
private fun applyAuthHeaders(connection: HttpURLConnection, token: String) { private fun applyAuthHeaders(connection: HttpURLConnection, token: String) {
val value = token.trim() val value = token.trim()
if (value.isBlank()) return if (value.isBlank()) return
+37 -2
View File
@@ -4,7 +4,7 @@ import json
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
from fastapi import FastAPI, Response from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.responses import JSONResponse, PlainTextResponse
from crypto_spot_bot.analytics import analytics_snapshot from crypto_spot_bot.analytics import analytics_snapshot
@@ -19,6 +19,7 @@ from crypto_spot_bot.reconciliation import reconciliation_snapshot
from crypto_spot_bot.storage import Storage from crypto_spot_bot.storage import Storage
from crypto_spot_bot.strategy import SpotStrategy from crypto_spot_bot.strategy import SpotStrategy
from crypto_spot_bot.time_series import TimeSeriesForecaster from crypto_spot_bot.time_series import TimeSeriesForecaster
from crypto_spot_bot.training_coordination import TrainingCoordinator
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints." WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
@@ -42,6 +43,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
learner = TradeLearner(settings, storage) learner = TradeLearner(settings, storage)
forecaster = TimeSeriesForecaster(settings) forecaster = TimeSeriesForecaster(settings)
bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster) bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster)
training = TrainingCoordinator(settings.time_series_lstm_model_path.parent)
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
@@ -56,6 +58,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.storage = storage app.state.storage = storage
app.state.bot = bot app.state.bot = bot
app.state.market = market app.state.market = market
app.state.training = training
@app.get("/", response_class=PlainTextResponse, status_code=410) @app.get("/", response_class=PlainTextResponse, status_code=410)
async def index() -> str: async def index() -> str:
@@ -114,7 +117,39 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/api/retrain") @app.get("/api/retrain")
async def retrain() -> dict[str, Any]: async def retrain() -> dict[str, Any]:
return _runtime_json(settings, "torch_retrain_guard.json") data = _runtime_json(settings, "torch_retrain_guard.json")
data["coordination"] = training.status()
return data
@app.get("/api/training/status")
async def training_status() -> dict[str, Any]:
return training.status()
@app.post("/api/training/retrain")
async def training_retrain(payload: dict[str, Any] | None = None) -> dict[str, Any]:
return training.request_retrain(payload)
@app.post("/api/training/heartbeat")
async def training_heartbeat(payload: dict[str, Any] | None = None) -> dict[str, Any]:
return training.heartbeat(payload)
@app.post("/api/training/claim")
async def training_claim(payload: dict[str, Any] | None = None) -> dict[str, Any]:
return training.claim(payload)
@app.post("/api/training/jobs/{job_id}/artifacts/chunk")
async def training_artifact_chunk(job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
try:
return training.save_artifact_chunk(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/training/jobs/{job_id}/complete")
async def training_complete(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
return training.complete(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/api/config") @app.get("/api/config")
async def config() -> dict[str, Any]: async def config() -> dict[str, Any]:
+268
View File
@@ -0,0 +1,268 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
import uuid
from datetime import UTC
from datetime import datetime
from datetime import timedelta
from pathlib import Path
from threading import Lock
from typing import Any
ALLOWED_TRAINING_ARTIFACTS = {
"lstm_forecaster.json",
"torch_retrain_guard.json",
"torch_threshold_calibration.json",
}
RUNNING_TIMEOUT = timedelta(hours=12)
ONLINE_WINDOW = timedelta(minutes=3)
class TrainingCoordinator:
def __init__(self, runtime_dir: Path) -> None:
self.runtime_dir = runtime_dir
self.state_path = runtime_dir / "training_coordination.json"
self.upload_root = runtime_dir / ".training_uploads"
self._lock = Lock()
def status(self) -> dict[str, Any]:
with self._lock:
state = self._load_state()
self._expire_stale_jobs(state)
self._save_state(state)
return self._public_status(state)
def request_retrain(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
self._expire_stale_jobs(state)
existing = self._active_job(state)
if existing is not None:
self._save_state(state)
return {"queued": False, "reason": "active_job_exists", "job": existing, "status": self._public_status(state)}
now = _now()
job = {
"id": str(uuid.uuid4()),
"status": "pending",
"requested_at": now,
"requested_by": str(payload.get("source") or "api"),
"parameters": _safe_parameters(payload.get("parameters")),
"message": "",
"artifacts": [],
}
state.setdefault("jobs", []).append(job)
self._trim_jobs(state)
self._save_state(state)
return {"queued": True, "job": job, "status": self._public_status(state)}
def heartbeat(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
worker = self._worker_from_payload(payload)
state["worker"] = worker
self._save_state(state)
return {"ok": True, "worker": worker, "status": self._public_status(state)}
def claim(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
self._expire_stale_jobs(state)
worker = self._worker_from_payload(payload)
state["worker"] = worker
job = self._oldest_pending_job(state)
if job is None:
self._save_state(state)
return {"claimed": False, "job": None, "status": self._public_status(state)}
now = _now()
job["status"] = "running"
job["claimed_at"] = now
job["claimed_by"] = worker["id"]
job["worker"] = worker
self._save_state(state)
return {"claimed": True, "job": job, "status": self._public_status(state)}
def save_artifact_chunk(self, job_id: str, payload: dict[str, Any]) -> dict[str, Any]:
name = Path(str(payload.get("name") or "")).name
if name not in ALLOWED_TRAINING_ARTIFACTS:
raise ValueError(f"artifact is not allowed: {name}")
index = int(payload.get("index", -1))
total = int(payload.get("total", 0))
sha256 = str(payload.get("sha256") or "").strip().lower()
if index < 0 or total <= 0 or index >= total:
raise ValueError("invalid artifact chunk index")
if not sha256:
raise ValueError("artifact sha256 is required")
try:
chunk = base64.b64decode(str(payload.get("data_base64") or ""), validate=True)
except (ValueError, TypeError) as exc:
raise ValueError("invalid artifact chunk payload") from exc
chunk_dir = self.upload_root / job_id / name
chunk_dir.mkdir(parents=True, exist_ok=True)
(chunk_dir / f"{index:06d}.part").write_bytes(chunk)
if not all((chunk_dir / f"{part:06d}.part").is_file() for part in range(total)):
return {"complete": False, "received": index + 1, "total": total}
target_tmp = self.runtime_dir / f".{name}.{job_id}.tmp"
digest = hashlib.sha256()
with target_tmp.open("wb") as output:
for part in range(total):
data = (chunk_dir / f"{part:06d}.part").read_bytes()
digest.update(data)
output.write(data)
if digest.hexdigest().lower() != sha256:
target_tmp.unlink(missing_ok=True)
raise ValueError("artifact sha256 mismatch")
self.runtime_dir.mkdir(parents=True, exist_ok=True)
os.replace(target_tmp, self.runtime_dir / name)
_remove_tree(chunk_dir)
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is not None:
artifacts = job.setdefault("artifacts", [])
artifacts = [item for item in artifacts if item.get("name") != name]
artifacts.append({"name": name, "sha256": sha256, "uploaded_at": _now()})
job["artifacts"] = artifacts
self._save_state(state)
return {"complete": True, "name": name, "sha256": sha256}
def complete(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is None:
raise ValueError(f"training job not found: {job_id}")
success = bool(payload.get("success", payload.get("status") == "completed"))
job["status"] = "completed" if success else "failed"
job["completed_at"] = _now()
job["message"] = str(payload.get("message") or "")
if isinstance(payload.get("summary"), dict):
job["summary"] = payload["summary"]
self._save_state(state)
return {"ok": True, "job": job, "status": self._public_status(state)}
def _load_state(self) -> dict[str, Any]:
try:
data = json.loads(self.state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
if not isinstance(data, dict):
data = {}
data.setdefault("jobs", [])
return data
def _save_state(self, state: dict[str, Any]) -> None:
self.runtime_dir.mkdir(parents=True, exist_ok=True)
tmp = self.state_path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, self.state_path)
def _worker_from_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(payload.get("worker_id") or payload.get("id") or "windows-training-host"),
"name": str(payload.get("name") or "DESKTOP-TMFDL0H"),
"path": str(payload.get("path") or "C:\\Repos\\TradeBot"),
"version": str(payload.get("version") or "1"),
"last_seen_at": _now(),
}
def _public_status(self, state: dict[str, Any]) -> dict[str, Any]:
worker = state.get("worker") if isinstance(state.get("worker"), dict) else {}
last_seen = _parse_time(str(worker.get("last_seen_at") or ""))
active = self._active_job(state)
latest = _latest_job(state)
return {
"available": True,
"agent_online": bool(last_seen and datetime.now(UTC) - last_seen <= ONLINE_WINDOW),
"worker": worker,
"active_job": active,
"latest_job": latest,
"pending_jobs": sum(1 for job in state.get("jobs", []) if job.get("status") == "pending"),
}
def _active_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
for job in reversed(state.get("jobs", [])):
if job.get("status") in {"pending", "running"}:
return job
return None
def _oldest_pending_job(self, state: dict[str, Any]) -> dict[str, Any] | None:
for job in state.get("jobs", []):
if job.get("status") == "pending":
return job
return None
def _job_by_id(self, state: dict[str, Any], job_id: str) -> dict[str, Any] | None:
for job in state.get("jobs", []):
if job.get("id") == job_id:
return job
return None
def _expire_stale_jobs(self, state: dict[str, Any]) -> None:
now = datetime.now(UTC)
for job in state.get("jobs", []):
if job.get("status") != "running":
continue
claimed_at = _parse_time(str(job.get("claimed_at") or ""))
if claimed_at and now - claimed_at > RUNNING_TIMEOUT:
job["status"] = "failed"
job["completed_at"] = _now()
job["message"] = "training worker timeout"
def _trim_jobs(self, state: dict[str, Any]) -> None:
jobs = state.get("jobs", [])
if isinstance(jobs, list) and len(jobs) > 30:
state["jobs"] = jobs[-30:]
def _safe_parameters(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
allowed = {"symbols", "limit", "lookbacks", "architectures", "hidden_sizes", "layers", "dropouts", "epochs"}
return {key: value[key] for key in allowed if key in value}
def _latest_job(state: dict[str, Any]) -> dict[str, Any] | None:
jobs = state.get("jobs", [])
if not jobs:
return None
latest = jobs[-1]
return latest if isinstance(latest, dict) else None
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _parse_time(value: str) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def _remove_tree(path: Path) -> None:
if not path.exists():
return
for child in path.iterdir():
if child.is_dir():
_remove_tree(child)
else:
child.unlink(missing_ok=True)
path.rmdir()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import base64
import hashlib
from crypto_spot_bot.training_coordination import TrainingCoordinator
def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
requested = coordinator.request_retrain({"source": "android"})
job_id = requested["job"]["id"]
heartbeat = coordinator.heartbeat({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
claimed = coordinator.claim({"worker_id": "win-1", "name": "DESKTOP-TMFDL0H"})
assert requested["queued"] is True
assert heartbeat["status"]["agent_online"] is True
assert claimed["claimed"] is True
assert claimed["job"]["id"] == job_id
assert coordinator.status()["active_job"]["status"] == "running"
completed = coordinator.complete(job_id, {"success": True, "message": "ok"})
assert completed["job"]["status"] == "completed"
assert coordinator.status()["active_job"] is None
def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
job = coordinator.request_retrain({"source": "test"})["job"]
payload = b'{"type":"pytorch_recurrent_forecaster","symbols":{}}\n'
sha256 = hashlib.sha256(payload).hexdigest()
first = payload[:20]
second = payload[20:]
part_1 = coordinator.save_artifact_chunk(
job["id"],
{
"name": "lstm_forecaster.json",
"index": 0,
"total": 2,
"sha256": sha256,
"data_base64": base64.b64encode(first).decode("ascii"),
},
)
part_2 = coordinator.save_artifact_chunk(
job["id"],
{
"name": "lstm_forecaster.json",
"index": 1,
"total": 2,
"sha256": sha256,
"data_base64": base64.b64encode(second).decode("ascii"),
},
)
assert part_1["complete"] is False
assert part_2["complete"] is True
assert (tmp_path / "lstm_forecaster.json").read_bytes() == payload
assert coordinator.status()["latest_job"]["artifacts"][0]["sha256"] == sha256
+98
View File
@@ -0,0 +1,98 @@
[CmdletBinding()]
param(
[string]$TaskName = "TradeBot Windows Training Agent",
[string]$ApiBaseUrl = "https://tb.kusoft.xyz",
[string]$ApiAuth = "",
[int]$PollSeconds = 60,
[string]$RepoRoot = "",
[switch]$StartNow,
[switch]$KeepLegacyRetrainer
)
$ErrorActionPreference = "Stop"
if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
}
$Agent = Join-Path $RepoRoot "tools\windows_training_agent.py"
if (-not (Test-Path $Agent)) {
throw "Windows training agent not found: $Agent"
}
function Resolve-Python {
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
return $venvPython
}
$userPython = Join-Path $env:LOCALAPPDATA "Programs\TradeBotPython312\python.exe"
if (Test-Path $userPython) {
return $userPython
}
foreach ($candidate in @("python.exe", "python")) {
$command = Get-Command $candidate -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
}
throw "Python was not found. Create .venv or install Python 3.12."
}
if ($ApiAuth) {
[Environment]::SetEnvironmentVariable("TRADEBOT_API_AUTH", $ApiAuth, "User")
$env:TRADEBOT_API_AUTH = $ApiAuth
}
[Environment]::SetEnvironmentVariable("TRADEBOT_API_BASE_URL", $ApiBaseUrl, "User")
[Environment]::SetEnvironmentVariable("TRADEBOT_TRAINING_WORKER_NAME", $env:COMPUTERNAME, "User")
$env:TRADEBOT_API_BASE_URL = $ApiBaseUrl
$env:TRADEBOT_TRAINING_WORKER_NAME = $env:COMPUTERNAME
if (-not $KeepLegacyRetrainer) {
foreach ($legacyName in @("TradeBot PyTorch Forecaster Retrainer", "TradeBot LSTM Retrainer")) {
$legacyTask = Get-ScheduledTask -TaskName $legacyName -ErrorAction SilentlyContinue
if ($legacyTask) {
Unregister-ScheduledTask -TaskName $legacyName -Confirm:$false
Write-Host "Removed legacy scheduled task '$legacyName'."
}
}
}
$python = Resolve-Python
$arguments = @(
"-u",
"`"$Agent`"",
"--repo-root", "`"$RepoRoot`"",
"--api-base-url", "`"$ApiBaseUrl`"",
"--poll-seconds", $PollSeconds.ToString()
) -join " "
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $RepoRoot
$trigger = New-ScheduledTaskTrigger -AtLogOn -User ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)
$principal = New-ScheduledTaskPrincipal `
-UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) `
-LogonType Interactive `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-MultipleInstances IgnoreNew `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Days 30)
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Description "Keeps the TradeBot Windows training agent online and polls the public bot API for retrain jobs." `
-Force | Out-Null
if ($StartNow) {
Start-ScheduledTask -TaskName $TaskName
}
Write-Host "Registered scheduled task '$TaskName' for Windows logon."
Write-Host "Agent API: $ApiBaseUrl"
Write-Host "Agent script: $Agent"
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import platform
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.error import HTTPError
from urllib.error import URLError
from urllib.request import Request
from urllib.request import urlopen
ARTIFACT_NAMES = (
"lstm_forecaster.json",
"torch_retrain_guard.json",
"torch_threshold_calibration.json",
)
def main() -> None:
args = parse_args()
repo_root = Path(args.repo_root).resolve()
runtime_dir = repo_root / "runtime"
runtime_dir.mkdir(parents=True, exist_ok=True)
log_path = Path(args.log_file).resolve() if args.log_file else runtime_dir / "windows_training_agent.log"
log(log_path, f"TradeBot Windows training agent started for {args.api_base_url}")
while True:
try:
poll_once(args, repo_root, runtime_dir, log_path)
except Exception as exc: # noqa: BLE001 - agent must keep running.
log(log_path, f"ERROR: {exc}")
if args.once:
break
time.sleep(max(5, args.poll_seconds))
def poll_once(args: argparse.Namespace, repo_root: Path, runtime_dir: Path, log_path: Path) -> None:
worker = worker_payload(args, repo_root)
api_json(args, "/api/training/heartbeat", worker)
claim = api_json(args, "/api/training/claim", worker)
if not claim.get("claimed"):
return
job = claim.get("job") if isinstance(claim.get("job"), dict) else {}
job_id = str(job.get("id") or "")
if not job_id:
return
log(log_path, f"Claimed retrain job {job_id}")
success = False
message = ""
summary: dict[str, Any] = {}
try:
run_retrain(job, repo_root, log_path)
summary = read_json(runtime_dir / "torch_retrain_guard.json")
for name in ARTIFACT_NAMES:
path = runtime_dir / name
if path.is_file():
upload_artifact(args, job_id, path, log_path)
success = True
message = "training completed"
log(log_path, f"Completed retrain job {job_id}")
except Exception as exc: # noqa: BLE001 - report failure to the bot.
message = str(exc)
log(log_path, f"Job {job_id} failed: {message}")
finally:
payload = {"success": success, "message": message, "summary": summary}
api_json(args, f"/api/training/jobs/{job_id}/complete", payload)
def run_retrain(job: dict[str, Any], repo_root: Path, log_path: Path) -> None:
script = repo_root / "tools" / "run_torch_retrain.ps1"
if not script.is_file():
raise RuntimeError(f"retrain script not found: {script}")
cmd = [
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
]
parameters = job.get("parameters") if isinstance(job.get("parameters"), dict) else {}
arg_map = {
"symbols": "-Symbols",
"limit": "-Limit",
"lookbacks": "-Lookbacks",
"architectures": "-Architectures",
"hidden_sizes": "-HiddenSizes",
"layers": "-Layers",
"dropouts": "-Dropouts",
"epochs": "-Epochs",
}
for key, ps_arg in arg_map.items():
value = parameters.get(key)
if value not in (None, ""):
cmd.extend([ps_arg, str(value)])
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
with subprocess.Popen(
cmd,
cwd=str(repo_root),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
) as process:
assert process.stdout is not None
for line in process.stdout:
log(log_path, line.rstrip())
code = process.wait()
if code != 0:
raise RuntimeError(f"retrain failed with exit code {code}")
def upload_artifact(args: argparse.Namespace, job_id: str, path: Path, log_path: Path) -> None:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
size = path.stat().st_size
chunk_size = max(64 * 1024, args.chunk_size)
total = max(1, (size + chunk_size - 1) // chunk_size)
log(log_path, f"Uploading {path.name}: {size} bytes, {total} chunks")
with path.open("rb") as source:
for index in range(total):
data = source.read(chunk_size)
payload = {
"name": path.name,
"index": index,
"total": total,
"sha256": digest,
"data_base64": base64.b64encode(data).decode("ascii"),
}
api_json(args, f"/api/training/jobs/{job_id}/artifacts/chunk", payload, timeout=120)
def api_json(args: argparse.Namespace, path: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
url = args.api_base_url.rstrip("/") + path
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json", "Accept": "application/json"}
token = args.api_auth or os.environ.get("TRADEBOT_API_AUTH", "")
headers.update(auth_headers(token))
request = Request(url, data=body, headers=headers, method="POST")
try:
with urlopen(request, timeout=timeout) as response:
text = response.read().decode("utf-8")
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} {path}: {detail[:300]}") from exc
except URLError as exc:
raise RuntimeError(f"network error {path}: {exc.reason}") from exc
return json.loads(text) if text.strip() else {}
def auth_headers(token: str) -> dict[str, str]:
value = token.strip()
if not value:
return {}
headers = {"X-TradeBot-Token": value}
if value.lower().startswith(("basic ", "bearer ")):
headers["Authorization"] = value
elif ":" in value:
encoded = base64.b64encode(value.encode("utf-8")).decode("ascii")
headers["Authorization"] = f"Basic {encoded}"
else:
headers["Authorization"] = f"Bearer {value}"
return headers
def worker_payload(args: argparse.Namespace, repo_root: Path) -> dict[str, Any]:
name = args.worker_name or platform.node() or "Windows training host"
return {
"worker_id": args.worker_id or f"{name}:{repo_root}",
"name": name,
"path": str(repo_root),
"version": "1",
}
def log(path: Path, message: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().astimezone().isoformat(timespec="seconds")
line = f"[{stamp}] {message}"
print(line, flush=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
def read_json(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def quote_for_log(value: str) -> str:
return f'"{value}"' if " " in value else value
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Poll TradeBot for retrain jobs and execute them on Windows.")
parser.add_argument("--api-base-url", default=os.environ.get("TRADEBOT_API_BASE_URL", "https://tb.kusoft.xyz"))
parser.add_argument("--api-auth", default=os.environ.get("TRADEBOT_API_AUTH", ""))
parser.add_argument("--repo-root", default=str(Path(__file__).resolve().parents[1]))
parser.add_argument("--worker-id", default=os.environ.get("TRADEBOT_TRAINING_WORKER_ID", ""))
parser.add_argument("--worker-name", default=os.environ.get("TRADEBOT_TRAINING_WORKER_NAME", ""))
parser.add_argument("--poll-seconds", type=int, default=int(os.environ.get("TRADEBOT_TRAINING_POLL_SECONDS", "60")))
parser.add_argument("--chunk-size", type=int, default=int(os.environ.get("TRADEBOT_TRAINING_CHUNK_SIZE", str(512 * 1024))))
parser.add_argument("--log-file", default=os.environ.get("TRADEBOT_TRAINING_LOG", ""))
parser.add_argument("--once", action="store_true")
return parser.parse_args()
if __name__ == "__main__":
main()