Fix remote training and model validation pipeline

This commit is contained in:
Курнат Андрей
2026-07-12 22:56:49 +03:00
parent 18936cf8b1
commit da53483164
22 changed files with 1691 additions and 246 deletions
+256 -16
View File
@@ -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,
+128 -79
View File
@@ -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"
+19 -7
View File
@@ -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"
+82
View File
@@ -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()
}
+451 -43
View File
@@ -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]:
+17 -12
View File
@@ -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)