Harden trading, training, and monitoring

This commit is contained in:
Codex
2026-07-10 15:51:53 +03:00
parent 6fb79ee2a9
commit 069d75d2f2
55 changed files with 2658 additions and 2332049 deletions
-93
View File
@@ -1,93 +0,0 @@
TRADING_MODE=paper
HOST=127.0.0.1
PORT=8787
BYBIT_TESTNET=false
BYBIT_API_KEY=
BYBIT_API_SECRET=
STARTING_BALANCE_USDT=100
AUTO_SELECT_SYMBOLS=false
TOP_SYMBOLS_COUNT=12
SYMBOLS=BTCUSDT,ETHUSDT,HYPEUSDT,SOLUSDT,XRPUSDT,XPLUSDT,WLDUSDT,MNTUSDT,HUSDT,XAUTUSDT,IPUSDT,AAVEUSDT
STRATEGY_MODE=torch_forecast
BASE_INTERVAL=60
KLINE_LIMIT=240
TREND_INTERVAL=D
TREND_KLINE_LIMIT=260
LOOP_INTERVAL_SECONDS=5
FAST_TRADING_ENABLED=false
FAST_LOOP_INTERVAL_SECONDS=1
FAST_ENTRY_COOLDOWN_SECONDS=20
MAX_ENTRIES_PER_MINUTE=12
WEBSOCKET_ENABLED=true
MIN_SIGNAL_CONFIDENCE=0.64
MAX_SPREAD_PERCENT=0.18
MIN_24H_TURNOVER_USDT=1000000
PATTERN_ANALYSIS_ENABLED=true
PATTERN_SCORE_WEIGHT=0.18
LEARNING_ENABLED=true
LEARNING_LOOKBACK_TRADES=120
LEARNING_MIN_SAMPLES=3
LEARNING_MAX_ADJUSTMENT=0.12
LEARNING_MAX_POSITION_MULTIPLIER=1.6
MIN_POSITION_USDT=1
MAX_POSITION_USDT=8
MAX_SYMBOL_EXPOSURE_USDT=25
MAX_TOTAL_EXPOSURE_USDT=100
MAX_OPEN_POSITIONS=24
MAX_POSITIONS_PER_SYMBOL=6
GRID_TRADING_ENABLED=false
GRID_ENTRY_CONFIDENCE=0.58
GRID_BUY_ZONE=0.45
GRID_MAX_POSITION_USDT=8
REBOUND_TRADING_ENABLED=true
REBOUND_ENTRY_CONFIDENCE=0.55
REBOUND_MIN_PROBABILITY=0.55
REBOUND_MAX_POSITION_USDT=6
KELLY_SIZING_ENABLED=true
KELLY_FRACTION=0.25
KELLY_MAX_FRACTION=0.20
RISK_PER_TRADE_PERCENT=0.01
RISK_GUARD_ENABLED=true
RISK_SYMBOL_GUARD_ENABLED=false
RISK_RECENT_TRADE_WINDOW=20
RISK_MAX_CONSECUTIVE_LOSSES=4
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
RISK_REDUCE_MULTIPLIER=1.0
ATR_TRAILING_MULTIPLIER=2.2
TREND_RSI_MIN=45
TREND_RSI_MAX=65
TIME_SERIES_FORECAST_ENABLED=true
TIME_SERIES_MIN_CANDLES=120
TIME_SERIES_FORECAST_HORIZON=3
TIME_SERIES_MIN_EDGE_PERCENT=0.10
TIME_SERIES_MIN_PROBABILITY_UP=0.47
TIME_SERIES_MIN_CONFIDENCE=0.4
TIME_SERIES_MAX_ADJUSTMENT=0.08
TIME_SERIES_LSTM_ENABLED=true
TIME_SERIES_LSTM_MODEL_PATH=runtime/lstm_forecaster.json
TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
STOP_LOSS_PERCENT=0.04
STOP_LOSS_EXIT_ENABLED=false
TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015
MIN_HOLD_SECONDS=180
ENTRY_COOLDOWN_SECONDS=180
MAX_DAILY_DRAWDOWN_USDT=6
MIN_CASH_RESERVE_USDT=5
TAKER_FEE_RATE=0.001
SLIPPAGE_RATE=0.0003
# Real trading is locked unless all three values are set explicitly.
ENABLE_LIVE_TRADING=false
LIVE_TRADING_CONFIRM=
LIVE_ORDER_MAX_USDT=10
DATABASE_PATH=runtime/tradebot.sqlite3
LOG_PATH=runtime/tradebot.log
+23 -1
View File
@@ -71,7 +71,13 @@ TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
TIME_SERIES_REBOUND_FALLBACK_ENABLED=false
TIME_SERIES_REQUIRE_QUALITY_GATE=true
# Emergency paper-only override. Keep false unless a failed guard is accepted manually.
TIME_SERIES_MANUAL_QUALITY_OVERRIDE=false
TIME_SERIES_REQUIRE_FRESH_MODEL=true
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
MARKET_TICKER_MAX_AGE_SECONDS=45
STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015
@@ -86,6 +92,22 @@ SLIPPAGE_RATE=0.0003
ENABLE_LIVE_TRADING=false
LIVE_TRADING_CONFIRM=
LIVE_ORDER_MAX_USDT=10
LIVE_ORDER_FILL_TIMEOUT_SECONDS=20
LIVE_RECONCILIATION_INTERVAL_SECONDS=30
LIVE_PROTECTIVE_STOP_ENABLED=true
# Required for direct API access. If a trusted reverse proxy authenticates
# requests, set TRUSTED_PROXY_USER_HEADER to the header injected by that proxy.
TRADEBOT_API_TOKEN=
TRADEBOT_TRAINING_TOKEN=
TRUSTED_PROXY_USER_HEADER=
HOLD_SIGNAL_SAMPLE_SECONDS=60
STORAGE_RETENTION_DAYS=30
STORAGE_PRUNE_INTERVAL_SECONDS=3600
# Windows trainer keeps this final tail untouched by training and early stopping.
TORCH_RETRAIN_HOLDOUT_WINDOW=240
DATABASE_PATH=runtime/tradebot.sqlite3
LOG_PATH=runtime/tradebot.log
+3
View File
@@ -1,5 +1,8 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip \
+20 -4
View File
@@ -9,7 +9,7 @@ Spot-бот для демо-торговли криптовалютой на р
- Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop.
- Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча.
- Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`.
- Основная стратегия `torch_forecast`: входы и forecast-выходы идут только от экспортированной PyTorch LSTM/GRU модели; MACD/RSI/дневная EMA не являются условиями входа в этом режиме. Спред, ликвидность, stop-loss, ATR trailing stop, запрет DCA и лимиты экспозиции остаются защитой исполнения и риска.
- Основная стратегия `torch_forecast`: входы и forecast-выходы идут только от свежей экспортированной PyTorch LSTM/GRU модели с успешным quality gate; MACD/RSI/дневная EMA не являются условиями входа в этом режиме. Rebound fallback без модели выключен по умолчанию. Спред, ликвидность, stop-loss, ATR trailing stop, запрет DCA и лимиты экспозиции остаются защитой исполнения и риска.
- Основная стратегия `trend_macd`: вход на `1h`, дневной фильтр тренда на `1d`, long только если цена выше дневной EMA200 и дневная EMA50 выше EMA200.
- Вход `trend_macd`: MACD на `1h` пересекает signal вверх, цена выше EMA50, RSI в диапазоне `45..65`, спред и ликвидность проходят runtime-фильтры.
- Выход `trend_macd`: MACD пересекает signal вниз, `1h` свеча закрылась ниже EMA50, сработал стоп `4%` или ATR trailing stop `2.2 ATR`.
@@ -20,7 +20,8 @@ Spot-бот для демо-торговли криптовалютой на р
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для просмотра 12 пар, свечей, Torch/Kelly параметров, расписания удалённого retrain и live-чеклиста.
- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`.
- Health endpoint `/api/health` и Prometheus-compatible `/metrics`.
- Liveness `/api/health`, readiness `/api/ready`, объединенный mobile snapshot `/api/mobile/snapshot` и Prometheus-compatible `/metrics`.
- Все приватные API endpoints требуют токен или подтвержденный reverse-proxy user header; health и metrics остаются доступными для локального мониторинга.
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост.
- Live trading guard: live не стартует без `ENABLE_LIVE_TRADING=true`, `LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK` и Bybit API-ключей.
@@ -80,6 +81,8 @@ Dashboard: <http://127.0.0.1:8787/>
Новый artifact версии 4 обучается как probabilistic multi-horizon модель: вход включает доходности, форму свечи, объем, ATR%, realized volatility, RSI/MACD/EMA slopes, 4h/24h rolling trend, дневные EMA-признаки, BTC/ETH cross-asset признаки и числовые признаки текущего шаблона пары. Цель обучается как `future log return - комиссии - проскальзывание`, нормализованная на текущую волатильность. Модель сразу прогнозирует горизонты `1/3/6/12`, quantile-оценки `q10/q50/q90` и `P(up)`.
Последний tail (`--holdout-window`, по умолчанию 240 samples на символ) полностью исключается из training и early stopping. Между train/validation/holdout оставляется purge по максимальному forecast horizon. Threshold walk-forward и guard работают только на этом untouched holdout; calibration и guard криптографически привязаны к SHA-256 конкретного model artifact.
Файл из `TIME_SERIES_LSTM_MODEL_PATH` читается ботом автоматически, если `TIME_SERIES_FORECAST_ENABLED=true`. В стратегии `torch_forecast` экспортированная PyTorch LSTM/GRU модель является единственным направляющим сигналом для входа и forecast-выхода. Экспортированные модели появляются в dashboard как `PyTorch LSTM` или `PyTorch GRU`; старый легкий reservoir LSTM-кандидат и все встроенные не-torch прогнозы удалены.
Автопереобучение на Windows запускает PyTorch trainer, пишет лог в `runtime/torch_retrain.log` и защищается от параллельных запусков:
@@ -185,7 +188,11 @@ TIME_SERIES_PROBE_ENABLED=true
TIME_SERIES_PROBE_MIN_EDGE_PERCENT=0.02
TIME_SERIES_PROBE_MIN_PROBABILITY_UP=0.55
TIME_SERIES_PROBE_SIZE_MULTIPLIER=0.40
TIME_SERIES_REBOUND_FALLBACK_ENABLED=true
TIME_SERIES_REBOUND_FALLBACK_ENABLED=false
TIME_SERIES_REQUIRE_QUALITY_GATE=true
TIME_SERIES_REQUIRE_FRESH_MODEL=true
TIME_SERIES_MODEL_MAX_AGE_HOURS=48
MARKET_TICKER_MAX_AGE_SECONDS=45
STOP_LOSS_PERCENT=0.04
TAKE_PROFIT_PERCENT=0.035
TRAILING_STOP_PERCENT=0.015
@@ -213,9 +220,17 @@ LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK
BYBIT_API_KEY=...
BYBIT_API_SECRET=...
LIVE_ORDER_MAX_USDT=10
LIVE_ORDER_FILL_TIMEOUT_SECONDS=20
LIVE_RECONCILIATION_INTERVAL_SECONDS=30
LIVE_PROTECTIVE_STOP_ENABLED=true
TRADEBOT_API_TOKEN=
TRADEBOT_TRAINING_TOKEN=
TRUSTED_PROXY_USER_HEADER=
HOLD_SIGNAL_SAMPLE_SECONDS=60
STORAGE_RETENTION_DAYS=30
```
Текущее live-исполнение отправляет market buy/sell в Bybit и ведет локальную shadow-позицию для dashboard и правил выхода. Для промышленной торговли реальными средствами следующий обязательный шаг — reconciliation с реальным wallet/order history Bybit, чтобы локальное состояние сверялось с фактическими fills и балансами.
Live-исполнение ведет журнал order intent до отправки, подтверждает фактические fills через Bybit executions/order history, записывает фактическую цену/количество/комиссию, периодически сверяет wallet и открытые ордера и блокирует новые входы при расхождении. После подтвержденной покупки создается биржевой spot TP/SL stop-order; если защитный ордер создать не удалось, позиция немедленно закрывается. Перед первым использованием реальных средств этот контур все равно необходимо проверить на Bybit testnet с API-ключом без права вывода.
## API
@@ -234,5 +249,6 @@ LIVE_ORDER_MAX_USDT=10
## Проверка
```bash
python -m pip install -r requirements-dev.txt
python -m pytest
```
+2 -2
View File
@@ -10,7 +10,7 @@ android {
applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26
targetSdk = 36
versionCode = 17
versionName = "0.2.14"
versionCode = 18
versionName = "0.3.0"
}
}
@@ -11,7 +11,7 @@
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -1,6 +1,12 @@
package xyz.kusoft.tradebotmonitor
import android.content.Context
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class AppPrefs(context: Context) {
private val prefs = context.getSharedPreferences("tradebot_monitor", Context.MODE_PRIVATE)
@@ -20,8 +26,23 @@ class AppPrefs(context: Context) {
set(value) = prefs.edit().putString("api_base_url", normalizeBaseUrl(value)).apply()
var commandToken: String
get() = prefs.getString("command_token", "") ?: ""
set(value) = prefs.edit().putString("command_token", value.trim()).apply()
get() {
val encrypted = prefs.getString("command_token_v2", "").orEmpty()
if (encrypted.isNotBlank()) return decryptToken(encrypted)
val legacy = prefs.getString("command_token", "").orEmpty()
if (legacy.isNotBlank()) {
commandToken = legacy
prefs.edit().remove("command_token").apply()
}
return legacy
}
set(value) {
val clean = value.trim()
prefs.edit()
.putString("command_token_v2", if (clean.isBlank()) "" else encryptToken(clean))
.remove("command_token")
.apply()
}
var selectedSymbol: String
get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT"
@@ -59,17 +80,62 @@ class AppPrefs(context: Context) {
private fun normalizeBaseUrl(value: String): String {
val trimmed = value.trim().trimEnd('/')
if (trimmed.isBlank()) return DEFAULT_API_BASE_URL
return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"https://$trimmed"
return when {
trimmed.startsWith("https://") -> trimmed
trimmed.startsWith("http://") -> "https://${trimmed.removePrefix("http://")}"
else -> "https://$trimmed"
}
}
private fun encryptToken(value: String): String {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, tokenKey())
val iv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP)
val data = Base64.encodeToString(cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
return "$iv:$data"
}
private fun decryptToken(value: String): String {
return try {
val parts = value.split(':', limit = 2)
if (parts.size != 2) {
""
} else {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
tokenKey(),
GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP)),
)
String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), Charsets.UTF_8)
}
} catch (_: Exception) {
""
}
}
private fun tokenKey(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(TOKEN_KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance("AES", "AndroidKeyStore")
generator.init(
android.security.keystore.KeyGenParameterSpec.Builder(
TOKEN_KEY_ALIAS,
android.security.keystore.KeyProperties.PURPOSE_ENCRYPT or
android.security.keystore.KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(android.security.keystore.KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
return generator.generateKey()
}
private companion object {
const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz"
const val LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787"
const val DEFAULT_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
const val DEFAULT_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1"
}
}
@@ -110,9 +110,19 @@ class MainActivity : Activity() {
palette = if (prefs.themeMode == "light") AppPalette.light() else AppPalette.dark()
buildShell()
refreshData(silent = false)
}
override fun onStart() {
super.onStart()
mainHandler.removeCallbacks(refreshRunnable)
mainHandler.postDelayed(refreshRunnable, 5000L)
}
override fun onStop() {
mainHandler.removeCallbacks(refreshRunnable)
super.onStop()
}
override fun onDestroy() {
mainHandler.removeCallbacks(refreshRunnable)
executor.shutdownNow()
@@ -280,7 +290,10 @@ class MainActivity : Activity() {
setText(binding.equity, money(data.account.equity))
setText(binding.openPnl, "P&L открытых позиций ${signedMoney(openPnl)}", colorForSigned(openPnl))
setText(binding.realizedPnl, "Прибыль закрытых сделок ${signedMoney(realizedPnl)}", colorForSigned(realizedPnl))
setText(binding.mode, "Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"}")
setText(
binding.mode,
"Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"} · ${if (data.ready) "готов" else "есть блокировки"}",
)
setText(binding.cash, money(data.account.cash))
setText(binding.exposure, money(data.account.exposure))
setText(binding.positionCount, "Открытые позиции: ${data.positions.size}")
@@ -317,7 +330,15 @@ class MainActivity : Activity() {
setText(binding.price, price(latestPrice(market)), colorForSigned(edge))
setText(binding.edgeLine, "Edge ${signedPercent(edge, 2)} · P(up) ${probability(probability)} · 1h")
setText(binding.equity, money(data.account.equity))
setText(binding.status, if (data.running) "РАБОТАЕТ" else "СТОП", if (data.running) palette.green else palette.amber)
setText(
binding.status,
when {
!data.running -> "СТОП"
data.ready -> "ГОТОВ"
else -> "БЛОКИРОВКА"
},
if (data.ready) palette.green else palette.amber,
)
setText(binding.decision, decision, actionColor(action))
setText(binding.kelly, money(signal?.positionNotionalUsdt ?: 0.0))
setText(binding.reason, reason.ifBlank { "Нет объяснения от модели" })
@@ -1586,6 +1607,8 @@ class MainActivity : Activity() {
return listOf(
data?.mode.orEmpty(),
data?.running?.toString().orEmpty(),
data?.ready?.toString().orEmpty(),
data?.readinessReasons?.joinToString(",").orEmpty(),
config.optBoolean("live_ready", false).toString(),
config.optDouble("live_order_max_usdt", 0.0).toString(),
config.optDouble("risk_per_trade_percent", 0.0).toString(),
@@ -136,6 +136,8 @@ data class ClosedTradesSummary(
data class BotSnapshot(
val ok: Boolean,
val running: Boolean,
val ready: Boolean,
val readinessReasons: List<String>,
val mode: String,
val account: AccountData,
val positions: List<PositionData>,
@@ -39,7 +39,8 @@ object RetrainScheduler {
class RetrainAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
Executors.newSingleThreadExecutor().execute {
val executor = Executors.newSingleThreadExecutor()
executor.execute {
try {
val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled) {
@@ -47,6 +48,7 @@ class RetrainAlarmReceiver : BroadcastReceiver() {
}
} finally {
pending.finish()
executor.shutdown()
}
}
}
@@ -14,16 +14,19 @@ class TradeBotApi(
private val token: String,
) {
fun fetchSnapshot(): BotSnapshot {
val health = getJson("/api/health")
val status = getJson("/api/status")
val markets = getJson("/api/markets")
val signals = getJson("/api/signals?limit=220")
val config = getJson("/api/config")
val trades = getJson("/api/trades?limit=10")
val retrain = getJson("/api/retrain")
val backtest = getJson("/api/backtest")
val snapshot = getJson("/api/mobile/snapshot")
val health = snapshot.optJSONObject("health") ?: JSONObject()
val status = snapshot.optJSONObject("status") ?: JSONObject()
val markets = snapshot.optJSONObject("markets") ?: JSONObject()
val signals = snapshot.optJSONObject("signals") ?: JSONObject()
val config = snapshot.optJSONObject("config") ?: JSONObject()
val trades = snapshot.optJSONObject("trades") ?: JSONObject()
val retrain = snapshot.optJSONObject("retrain") ?: JSONObject()
val backtest = snapshot.optJSONObject("backtest") ?: JSONObject()
val accountJson = status.optJSONObject("account") ?: JSONObject()
val readiness = status.optJSONObject("readiness") ?: JSONObject()
val readinessReasons = readiness.optJSONArray("reasons") ?: JSONArray()
val account = AccountData(
equity = accountJson.optDouble("equity", 0.0),
cash = accountJson.optDouble("cash", 0.0),
@@ -33,6 +36,10 @@ class TradeBotApi(
return BotSnapshot(
ok = health.optBoolean("ok", false),
running = health.optBoolean("running", false),
ready = readiness.optBoolean("ready", false),
readinessReasons = List(readinessReasons.length()) { index ->
readinessReasons.optString(index)
}.filter { it.isNotBlank() },
mode = health.optStringClean("mode"),
account = account,
positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()),
+6 -3
View File
@@ -198,9 +198,12 @@ def _group_stats(trades: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]:
def _active_universe_trades(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
symbols = {symbol.upper() for symbol in settings.symbols}
if not symbols:
return trades
return [trade for trade in trades if str(trade.get("symbol", "")).upper() in symbols]
return [
trade
for trade in trades
if (not symbols or str(trade.get("symbol", "")).upper() in symbols)
and str(trade.get("mode", "paper")) == settings.trading_mode
]
def _symbol_guard_stats(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import base64
import binascii
import hmac
from fastapi import HTTPException, Request, status
from crypto_spot_bot.config import Settings
class ApiAuthorizer:
"""Authenticate API calls either directly or through an authenticated proxy."""
def __init__(self, settings: Settings):
self.settings = settings
async def require(self, request: Request) -> None:
if self._proxy_authenticated(request) or self._token_authenticated(
request, self.settings.api_auth_token
):
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
async def require_training(self, request: Request) -> None:
expected = self.settings.training_worker_token or self.settings.api_auth_token
if self._proxy_authenticated(request) or self._token_authenticated(request, expected):
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="training worker authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
def configured(self) -> bool:
return bool(
self.settings.api_auth_token
or self.settings.training_worker_token
or self.settings.trusted_proxy_user_header
)
def _proxy_authenticated(self, request: Request) -> bool:
header = self.settings.trusted_proxy_user_header
if not header:
return False
return bool(request.headers.get(header, "").strip())
def _token_authenticated(self, request: Request, expected: str) -> bool:
if not expected:
return False
candidates = [request.headers.get("X-TradeBot-Token", "").strip()]
authorization = request.headers.get("Authorization", "").strip()
if authorization.lower().startswith("bearer "):
candidates.append(authorization[7:].strip())
elif authorization.lower().startswith("basic "):
decoded = _decode_basic(authorization[6:].strip())
if decoded:
candidates.append(decoded)
return any(candidate and hmac.compare_digest(candidate, expected) for candidate in candidates)
def _decode_basic(value: str) -> str:
try:
return base64.b64decode(value, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError, ValueError):
return ""
+155 -12
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
import logging
import sqlite3
from datetime import datetime
from crypto_spot_bot.analytics import risk_guard_snapshot
@@ -15,6 +17,9 @@ from crypto_spot_bot.storage import Storage
from crypto_spot_bot.time_series import TimeSeriesForecaster
logger = logging.getLogger(__name__)
class CryptoSpotBot:
def __init__(
self,
@@ -44,6 +49,9 @@ class CryptoSpotBot:
self._entry_cooldown_until: dict[str, datetime] = {}
self._loop_task: asyncio.Task | None = None
self._ws_task: asyncio.Task | None = None
self._last_reconciliation_at: datetime | None = None
self._last_prune_at: datetime | None = None
self._consecutive_loop_errors = 0
async def start(self) -> None:
if self.running:
@@ -51,6 +59,17 @@ class CryptoSpotBot:
self.market.reset_stop()
if not self.market.symbols:
await self.market.bootstrap()
if isinstance(self.broker, LiveBroker):
try:
await asyncio.to_thread(self.broker.reconcile, self.market.instruments)
self._last_reconciliation_at = utc_now()
except Exception as exc:
self.broker.reconciliation_state = {
"status": "error",
"blocking": True,
"discrepancies": [{"code": "initial_reconciliation_failed", "message": str(exc)}],
}
self.storage.event(f"Initial live reconciliation failed: {exc}", "ERROR")
self._close_paper_positions_outside_symbol_universe()
self._update_patterns()
self._update_forecasts()
@@ -58,7 +77,7 @@ class CryptoSpotBot:
self.running = True
self.started_at = utc_now()
self.message = "бот работает"
self.storage.event("Бот запущен")
self._safe_event("Бот запущен")
if self.settings.websocket_enabled:
self._ws_task = asyncio.create_task(self.market.websocket_loop())
self._loop_task = asyncio.create_task(self._run_loop())
@@ -72,7 +91,13 @@ class CryptoSpotBot:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self.storage.event("Бот остановлен")
self._safe_event("Бот остановлен")
def _safe_event(self, message: str, level: str = "INFO") -> None:
try:
self.storage.event(message, level)
except sqlite3.Error:
logger.exception("Could not persist non-critical bot event: %s", message)
async def _run_loop(self) -> None:
while self.running:
@@ -80,6 +105,7 @@ class CryptoSpotBot:
rest_refresh_seconds = self._rest_refresh_seconds()
if self._needs_rest_refresh(rest_refresh_seconds):
await asyncio.to_thread(self.market.refresh_rest)
await self._maintain_runtime()
self.broker.update_highs(self.market.tickers)
self._update_patterns()
self._update_forecasts()
@@ -88,9 +114,11 @@ class CryptoSpotBot:
await self._process_entries()
self.broker.mark_equity(self.market.prices())
self.last_loop_at = utc_now()
self._consecutive_loop_errors = 0
except asyncio.CancelledError:
raise
except Exception as exc:
self._consecutive_loop_errors += 1
self.message = f"ошибка цикла: {exc}"
self.storage.event(self.message, "ERROR")
await asyncio.sleep(self.settings.effective_loop_interval_seconds)
@@ -110,6 +138,18 @@ class CryptoSpotBot:
prices = self.market.prices()
reduction_candidate_id = self._reduction_candidate_id(prices)
for position in list(self.broker.open_positions()):
freshness = self.market.symbol_freshness(position.symbol)
if not freshness["ok"]:
self._record_signal(
Signal(
position.symbol,
"HOLD",
0.0,
"market data is stale; exchange protective stop remains authoritative",
{"market_freshness": freshness},
)
)
continue
ticker = self.market.tickers.get(position.symbol)
candles = self.market.candles.get(position.symbol, [])
forecast = self.market.forecasts.get(position.symbol, {})
@@ -117,25 +157,40 @@ class CryptoSpotBot:
adaptive_rules["reduce_now"] = position.id is not None and position.id == reduction_candidate_id
learning = {"adaptive_rules": adaptive_rules}
signal = self.strategy.exit_signal(position, candles, ticker, learning, forecast)
self.storage.insert_signal(signal)
self._record_signal(signal)
if signal.action == "SELL" and ticker is not None:
self.broker.sell(position, ticker, signal.reason)
await asyncio.to_thread(self.broker.sell, position, ticker, signal.reason)
self._entry_cooldown_until[position.symbol] = utc_now()
async def _process_entries(self) -> None:
prices = self.market.prices()
risk_guard = risk_guard_snapshot(
self.settings,
self.storage.closed_trades(self.settings.learning_lookback_trades),
self.storage.latest_equity(),
self.storage.closed_trades(
self.settings.learning_lookback_trades,
mode=self.settings.trading_mode,
),
self.storage.latest_equity(mode=self.settings.trading_mode),
)
for symbol in self.market.symbols:
freshness = self.market.symbol_freshness(symbol)
if not freshness["ok"]:
self._record_signal(
Signal(
symbol,
"HOLD",
0.0,
"market data is stale; new entries blocked",
{"market_freshness": freshness, "checks": {"market_fresh": False}},
)
)
continue
cooldown_since = self._entry_cooldown_until.get(symbol)
if cooldown_since:
age = (utc_now() - cooldown_since).total_seconds()
cooldown_seconds = self.settings.effective_entry_cooldown_seconds
if age < cooldown_seconds:
self.storage.insert_signal(
self._record_signal(
Signal(
symbol,
"HOLD",
@@ -162,7 +217,7 @@ class CryptoSpotBot:
account["open_positions_for_symbol"] = open_count
account["exchange_min_entry_usdt"] = self.broker.minimum_entry_budget(instrument, ticker)
if risk_guard.get("block_new_entries"):
self.storage.insert_signal(
self._record_signal(
Signal(
symbol,
"HOLD",
@@ -178,7 +233,7 @@ class CryptoSpotBot:
continue
symbol_guard = self._risk_guard_for_symbol(risk_guard, symbol)
if symbol_guard.get("block_new_entries"):
self.storage.insert_signal(
self._record_signal(
Signal(
symbol,
"HOLD",
@@ -224,9 +279,10 @@ class CryptoSpotBot:
account,
trend_candles,
)
self.storage.insert_signal(signal)
self._record_signal(signal)
if signal.action == "BUY" and ticker is not None:
position = self.broker.buy(
position = await asyncio.to_thread(
self.broker.buy,
signal,
ticker,
instrument,
@@ -235,6 +291,41 @@ class CryptoSpotBot:
if position is not None:
self._entry_cooldown_until[symbol] = utc_now()
def _record_signal(self, signal: Signal) -> None:
self.storage.insert_signal(signal, self.settings.hold_signal_sample_seconds)
async def _maintain_runtime(self) -> None:
now = utc_now()
if isinstance(self.broker, LiveBroker):
age = (
(now - self._last_reconciliation_at).total_seconds()
if self._last_reconciliation_at
else float("inf")
)
if age >= self.settings.live_reconciliation_interval_seconds:
try:
await asyncio.to_thread(self.broker.reconcile, self.market.instruments)
except Exception as exc:
self.broker.reconciliation_state = {
"status": "error",
"blocking": True,
"discrepancies": [
{"code": "periodic_reconciliation_failed", "message": str(exc)}
],
"checked_at": utc_now().isoformat(),
}
self.storage.event(f"Periodic live reconciliation failed: {exc}", "ERROR")
finally:
self._last_reconciliation_at = utc_now()
prune_age = (
(now - self._last_prune_at).total_seconds()
if self._last_prune_at
else float("inf")
)
if prune_age >= self.settings.storage_prune_interval_seconds:
await asyncio.to_thread(self.storage.prune, self.settings.storage_retention_days)
self._last_prune_at = utc_now()
@staticmethod
def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict:
rows = risk_guard.get("symbols")
@@ -334,16 +425,68 @@ class CryptoSpotBot:
self.market.forecasts = forecasts
def status(self) -> BotStatus:
live_ready = self.settings.live_ready
if isinstance(self.broker, LiveBroker):
live_ready = live_ready and not self.broker.reconciliation_state.get("blocking", True)
return BotStatus(
running=self.running,
mode=self.settings.trading_mode,
live_trading_ready=self.settings.live_ready,
live_trading_ready=live_ready,
symbols=self.market.symbols,
started_at=self.started_at,
last_loop_at=self.last_loop_at,
message=self.message,
)
def readiness_snapshot(self) -> dict:
reasons: list[str] = []
now = utc_now()
if not self.running:
reasons.append("bot_not_running")
max_loop_age = max(30.0, self.settings.effective_loop_interval_seconds * 4)
loop_age = (now - self.last_loop_at).total_seconds() if self.last_loop_at else None
if loop_age is None or loop_age > max_loop_age:
reasons.append("decision_loop_stale")
stale_symbols = [
symbol for symbol in self.market.symbols if not self.market.symbol_freshness(symbol)["ok"]
]
if stale_symbols:
reasons.append("stale_market_data")
if self._consecutive_loop_errors >= 3:
reasons.append("repeated_loop_errors")
if self.settings.strategy_mode == "torch_forecast":
invalid_models = []
for symbol in self.market.symbols:
forecast = self.market.forecasts.get(symbol, {})
if not forecast.get("usable"):
invalid_models.append(symbol)
continue
if (
self.settings.time_series_require_quality_gate
and not self.settings.time_series_manual_quality_override
and forecast.get("quality_gate_passed") is not True
):
invalid_models.append(symbol)
continue
if self.settings.time_series_require_fresh_model and forecast.get("model_fresh") is not True:
invalid_models.append(symbol)
if invalid_models:
reasons.append("forecast_model_not_ready")
reconciliation: dict = {}
if isinstance(self.broker, LiveBroker):
reconciliation = dict(self.broker.reconciliation_state)
if reconciliation.get("blocking", True):
reasons.append("live_reconciliation_blocking")
return {
"ready": not reasons,
"mode": self.settings.trading_mode,
"reasons": reasons,
"loop_age_seconds": round(loop_age, 3) if loop_age is not None else None,
"stale_symbols": stale_symbols,
"consecutive_loop_errors": self._consecutive_loop_errors,
"reconciliation": reconciliation,
}
def account_snapshot(self) -> dict[str, float]:
prices = self.market.prices()
state = self.broker.account_state(prices)
+158 -3
View File
@@ -9,6 +9,8 @@ from typing import Any
from urllib.parse import urlencode
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from crypto_spot_bot.config import Settings
from crypto_spot_bot.models import Candle, Ticker
@@ -41,6 +43,17 @@ class BybitClient:
def __init__(self, settings: Settings):
self.settings = settings
self.session = requests.Session()
retry = Retry(
total=3,
connect=3,
read=3,
status=3,
backoff_factor=0.4,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=frozenset({"GET"}),
respect_retry_after_header=True,
)
self.session.mount("https://", HTTPAdapter(max_retries=retry))
def public_get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
response = self.session.get(
@@ -208,27 +221,165 @@ class BybitClient:
"symbol": symbol,
"side": side,
"orderType": "Market",
"qty": f"{qty:.8f}".rstrip("0").rstrip("."),
"qty": _decimal_text(qty),
"timeInForce": "IOC",
"isLeverage": 0,
"orderFilter": "Order",
"marketUnit": market_unit,
"orderLinkId": order_link_id,
}
slippage_percent = max(0.01, min(10.0, self.settings.slippage_rate * 100.0))
payload["slippageToleranceType"] = "Percent"
payload["slippageTolerance"] = f"{slippage_percent:.2f}"
return self.private_post("/v5/order/create", payload)
def place_spot_protective_stop(
self,
*,
symbol: str,
qty: float,
trigger_price: float,
order_link_id: str,
) -> dict[str, Any]:
payload = {
"category": "spot",
"symbol": symbol,
"side": "Sell",
"orderType": "Market",
"qty": _decimal_text(qty),
"triggerPrice": _decimal_text(trigger_price),
"timeInForce": "IOC",
"isLeverage": 0,
"orderFilter": "tpslOrder",
"marketUnit": "baseCoin",
"orderLinkId": order_link_id,
}
return self.private_post("/v5/order/create", payload)
def cancel_spot_order(
self,
*,
symbol: str,
order_id: str | None = None,
order_link_id: str | None = None,
order_filter: str = "Order",
) -> dict[str, Any]:
if not order_id and not order_link_id:
raise ValueError("order_id or order_link_id is required")
payload: dict[str, Any] = {
"category": "spot",
"symbol": symbol,
"orderFilter": order_filter,
}
if order_id:
payload["orderId"] = order_id
if order_link_id:
payload["orderLinkId"] = order_link_id
return self.private_post("/v5/order/cancel", payload)
def wallet_balance(self, account_type: str = "UNIFIED", coin: str | None = None) -> dict[str, Any]:
return self.private_get(
"/v5/account/wallet-balance",
{"accountType": account_type, "coin": coin},
)
def realtime_orders(self, *, category: str = "spot", open_only: int = 0, limit: int = 50) -> dict[str, Any]:
def realtime_orders(
self,
*,
category: str = "spot",
open_only: int = 0,
limit: int = 50,
symbol: str | None = None,
order_id: str | None = None,
order_link_id: str | None = None,
order_filter: str | None = None,
) -> dict[str, Any]:
return self.private_get(
"/v5/order/realtime",
{"category": category, "openOnly": open_only, "limit": max(1, min(limit, 50))},
{
"category": category,
"openOnly": open_only,
"limit": max(1, min(limit, 50)),
"symbol": symbol,
"orderId": order_id,
"orderLinkId": order_link_id,
"orderFilter": order_filter,
},
)
def order_history(
self,
*,
symbol: str | None = None,
order_id: str | None = None,
order_link_id: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
return self.private_get(
"/v5/order/history",
{
"category": "spot",
"symbol": symbol,
"orderId": order_id,
"orderLinkId": order_link_id,
"limit": max(1, min(limit, 50)),
},
)
def executions(
self,
*,
symbol: str | None = None,
order_id: str | None = None,
order_link_id: str | None = None,
limit: int = 100,
) -> dict[str, Any]:
return self.private_get(
"/v5/execution/list",
{
"category": "spot",
"symbol": symbol,
"orderId": order_id,
"orderLinkId": order_link_id,
"limit": max(1, min(limit, 100)),
},
)
def wait_for_spot_order(
self,
*,
order_id: str,
symbol: str,
timeout_seconds: float,
poll_seconds: float = 0.5,
) -> dict[str, Any]:
deadline = time.monotonic() + max(1.0, timeout_seconds)
latest: dict[str, Any] = {}
terminal = {
"Filled",
"Cancelled",
"Rejected",
"PartiallyFilledCanceled",
"PartillyFilledCancelled",
"Deactivated",
}
while time.monotonic() < deadline:
realtime = self.realtime_orders(symbol=symbol, order_id=order_id, open_only=1, limit=1)
rows = realtime.get("list") if isinstance(realtime.get("list"), list) else []
if rows and isinstance(rows[0], dict):
latest = rows[0]
if str(latest.get("orderStatus", "")) in terminal:
break
time.sleep(max(0.1, poll_seconds))
if not latest or str(latest.get("orderStatus", "")) not in terminal:
history = self.order_history(symbol=symbol, order_id=order_id, limit=1)
rows = history.get("list") if isinstance(history.get("list"), list) else []
if rows and isinstance(rows[0], dict):
latest = rows[0]
execution_result = self.executions(symbol=symbol, order_id=order_id)
executions = execution_result.get("list") if isinstance(execution_result.get("list"), list) else []
return {"order": latest, "executions": executions}
def websocket_subscribe_message(symbols: list[str], interval: str = "1") -> str:
args: list[str] = []
@@ -254,3 +405,7 @@ def _looks_like_stablecoin(base_coin: str) -> bool:
"PYUSD",
"USD1",
}
def _decimal_text(value: float) -> str:
return f"{value:.12f}".rstrip("0").rstrip(".")
+66 -1
View File
@@ -154,6 +154,20 @@ class Settings:
database_path: Path
log_path: Path
env_file_path: Path
api_auth_token: str = ""
training_worker_token: str = ""
trusted_proxy_user_header: str = ""
time_series_require_quality_gate: bool = False
time_series_manual_quality_override: bool = False
time_series_require_fresh_model: bool = False
time_series_model_max_age_hours: float = 48.0
market_ticker_max_age_seconds: float = 45.0
live_order_fill_timeout_seconds: float = 20.0
live_reconciliation_interval_seconds: float = 30.0
live_protective_stop_enabled: bool = True
hold_signal_sample_seconds: int = 60
storage_retention_days: int = 30
storage_prune_interval_seconds: int = 3600
@property
def rest_base_url(self) -> str:
@@ -289,7 +303,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
time_series_probe_min_edge_percent=_float_env("TIME_SERIES_PROBE_MIN_EDGE_PERCENT", 0.02),
time_series_probe_min_probability_up=_float_env("TIME_SERIES_PROBE_MIN_PROBABILITY_UP", 0.55),
time_series_probe_size_multiplier=_float_env("TIME_SERIES_PROBE_SIZE_MULTIPLIER", 0.40),
time_series_rebound_fallback_enabled=_bool_env("TIME_SERIES_REBOUND_FALLBACK_ENABLED", True),
time_series_rebound_fallback_enabled=_bool_env("TIME_SERIES_REBOUND_FALLBACK_ENABLED", False),
stop_loss_percent=_float_env("STOP_LOSS_PERCENT", 0.04),
stop_loss_exit_enabled=_bool_env("STOP_LOSS_EXIT_ENABLED", True),
take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035),
@@ -307,7 +321,28 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
database_path=Path(os.getenv("DATABASE_PATH", "runtime/tradebot.sqlite3")),
log_path=Path(os.getenv("LOG_PATH", "runtime/tradebot.log")),
env_file_path=env_path,
api_auth_token=os.getenv("TRADEBOT_API_TOKEN", "").strip(),
training_worker_token=os.getenv("TRADEBOT_TRAINING_TOKEN", "").strip(),
trusted_proxy_user_header=os.getenv("TRUSTED_PROXY_USER_HEADER", "").strip(),
time_series_require_quality_gate=_bool_env(
"TIME_SERIES_REQUIRE_QUALITY_GATE", strategy_mode == "torch_forecast"
),
time_series_manual_quality_override=_bool_env(
"TIME_SERIES_MANUAL_QUALITY_OVERRIDE", False
),
time_series_require_fresh_model=_bool_env(
"TIME_SERIES_REQUIRE_FRESH_MODEL", strategy_mode == "torch_forecast"
),
time_series_model_max_age_hours=_float_env("TIME_SERIES_MODEL_MAX_AGE_HOURS", 48.0),
market_ticker_max_age_seconds=_float_env("MARKET_TICKER_MAX_AGE_SECONDS", 45.0),
live_order_fill_timeout_seconds=_float_env("LIVE_ORDER_FILL_TIMEOUT_SECONDS", 20.0),
live_reconciliation_interval_seconds=_float_env("LIVE_RECONCILIATION_INTERVAL_SECONDS", 30.0),
live_protective_stop_enabled=_bool_env("LIVE_PROTECTIVE_STOP_ENABLED", True),
hold_signal_sample_seconds=_int_env("HOLD_SIGNAL_SAMPLE_SECONDS", 60),
storage_retention_days=_int_env("STORAGE_RETENTION_DAYS", 30),
storage_prune_interval_seconds=_int_env("STORAGE_PRUNE_INTERVAL_SECONDS", 3600),
)
_validate_settings(settings)
if settings.trading_mode == "live" and not settings.live_ready:
raise ValueError(
"Live mode is locked. Set ENABLE_LIVE_TRADING=true, "
@@ -316,6 +351,36 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
return settings
def _validate_settings(settings: Settings) -> None:
errors: list[str] = []
if not 1 <= settings.port <= 65535:
errors.append("PORT must be in range 1..65535")
if settings.starting_balance_usdt <= 0:
errors.append("STARTING_BALANCE_USDT must be positive")
if settings.min_position_usdt < 0:
errors.append("MIN_POSITION_USDT must be non-negative")
if settings.max_position_usdt < settings.min_position_usdt:
errors.append("MAX_POSITION_USDT must be >= MIN_POSITION_USDT")
if settings.max_symbol_exposure_usdt < settings.min_position_usdt:
errors.append("MAX_SYMBOL_EXPOSURE_USDT must be >= MIN_POSITION_USDT")
if settings.max_total_exposure_usdt < settings.max_symbol_exposure_usdt:
errors.append("MAX_TOTAL_EXPOSURE_USDT must be >= MAX_SYMBOL_EXPOSURE_USDT")
if settings.max_open_positions < 1 or settings.max_positions_per_symbol < 1:
errors.append("position count limits must be positive")
if settings.taker_fee_rate < 0 or settings.slippage_rate < 0:
errors.append("TAKER_FEE_RATE and SLIPPAGE_RATE must be non-negative")
if settings.market_ticker_max_age_seconds <= 0:
errors.append("MARKET_TICKER_MAX_AGE_SECONDS must be positive")
if settings.time_series_model_max_age_hours <= 0:
errors.append("TIME_SERIES_MODEL_MAX_AGE_HOURS must be positive")
if settings.live_order_fill_timeout_seconds <= 0:
errors.append("LIVE_ORDER_FILL_TIMEOUT_SECONDS must be positive")
if settings.live_reconciliation_interval_seconds <= 0:
errors.append("LIVE_RECONCILIATION_INTERVAL_SECONDS must be positive")
if errors:
raise ValueError("; ".join(errors))
def update_env_value(path: Path, key: str, value: str) -> None:
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
output: list[str] = []
+125 -31
View File
@@ -1,13 +1,16 @@
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException, Response
from fastapi import Depends, FastAPI, HTTPException, Response
from fastapi.responses import JSONResponse, PlainTextResponse
from crypto_spot_bot.analytics import analytics_snapshot
from crypto_spot_bot.auth import ApiAuthorizer
from crypto_spot_bot.bot import CryptoSpotBot
from crypto_spot_bot.bybit import BybitClient
from crypto_spot_bot.config import Settings, load_settings, update_env_value
@@ -23,6 +26,7 @@ from crypto_spot_bot.training_coordination import TrainingCoordinator
WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints."
logger = logging.getLogger(__name__)
def create_app(settings: Settings | None = None) -> FastAPI:
@@ -44,6 +48,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
forecaster = TimeSeriesForecaster(settings)
bot = CryptoSpotBot(settings, storage, market, broker, strategy, pattern_analyzer, learner, forecaster)
training = TrainingCoordinator(settings.time_series_lstm_model_path.parent)
authorizer = ApiAuthorizer(settings)
@asynccontextmanager
async def lifespan(_: FastAPI):
@@ -66,50 +71,62 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {"ok": True, "running": bot.running, "mode": settings.trading_mode}
return {
"ok": True,
"running": bot.running,
"mode": settings.trading_mode,
"auth_configured": authorizer.configured(),
}
@app.get("/api/ready")
async def ready() -> JSONResponse:
payload = bot.readiness_snapshot()
return JSONResponse(payload, status_code=200 if payload["ready"] else 503)
@app.get("/api/status")
async def status() -> dict[str, Any]:
async def status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return {
"status": bot.status().as_dict(),
"account": bot.account_snapshot(),
"positions": bot.positions_snapshot(),
"learning": bot.learning_snapshot(),
"latest_equity": storage.latest_equity(),
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
"readiness": bot.readiness_snapshot(),
}
@app.get("/api/markets")
async def markets() -> dict[str, Any]:
async def markets(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return market.snapshot()
@app.get("/api/trades")
async def trades(limit: int = 80) -> dict[str, Any]:
async def trades(limit: int = 80, _: None = Depends(authorizer.require)) -> dict[str, Any]:
row_limit = _limit(limit)
return {
"items": storage.recent_trades(row_limit),
"closed_items": storage.closed_trades(row_limit),
"closed_summary": storage.closed_trade_summary(),
"items": storage.recent_trades(row_limit, mode=settings.trading_mode),
"closed_items": storage.closed_trades(row_limit, mode=settings.trading_mode),
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
}
@app.get("/api/signals")
async def signals(limit: int = 120) -> dict[str, Any]:
async def signals(limit: int = 120, _: None = Depends(authorizer.require)) -> dict[str, Any]:
return {"items": storage.recent_signals(_limit(limit))}
@app.get("/api/events")
async def events(limit: int = 120) -> dict[str, Any]:
async def events(limit: int = 120, _: None = Depends(authorizer.require)) -> dict[str, Any]:
return {"items": storage.recent_events(_limit(limit))}
@app.get("/api/analytics")
async def analytics() -> dict[str, Any]:
async def analytics(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return analytics_snapshot(settings, storage)
@app.get("/api/quality")
async def quality() -> dict[str, Any]:
async def quality(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return market.snapshot().get("quality", {})
@app.get("/api/reconciliation")
async def reconciliation() -> dict[str, Any]:
return reconciliation_snapshot(
async def reconciliation(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return await asyncio.to_thread(
reconciliation_snapshot,
settings=settings,
storage=storage,
client=client,
@@ -117,58 +134,113 @@ def create_app(settings: Settings | None = None) -> FastAPI:
)
@app.get("/api/backtest")
async def backtest() -> dict[str, Any]:
async def backtest(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return _runtime_json(settings, "torch_threshold_calibration.json")
@app.get("/api/retrain")
async def retrain() -> dict[str, Any]:
async def retrain(_: None = Depends(authorizer.require)) -> dict[str, Any]:
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]:
async def training_status(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return training.status()
@app.post("/api/training/retrain")
async def training_retrain(payload: dict[str, Any] | None = None) -> dict[str, Any]:
async def training_retrain(
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require),
) -> 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]:
async def training_heartbeat(
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require_training),
) -> 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]:
async def training_claim(
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require_training),
) -> 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]:
async def training_artifact_chunk(
job_id: str,
payload: dict[str, Any],
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
try:
return training.save_artifact_chunk(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/training/jobs/{job_id}/progress")
async def training_progress(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
async def training_progress(
job_id: str,
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
try:
return training.progress(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/training/jobs/{job_id}/complete")
async def training_complete(job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
async def training_complete(
job_id: str,
payload: dict[str, Any] | None = None,
_: None = Depends(authorizer.require_training),
) -> dict[str, Any]:
try:
return training.complete(job_id, payload)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/config")
async def config() -> dict[str, Any]:
async def config(_: None = Depends(authorizer.require)) -> dict[str, Any]:
return _safe_config(settings)
@app.get("/api/mobile/snapshot")
async def mobile_snapshot(_: None = Depends(authorizer.require)) -> dict[str, Any]:
row_limit = 220
retrain_data = _runtime_json(settings, "torch_retrain_guard.json")
retrain_data["coordination"] = training.status()
return {
"health": {
"ok": True,
"running": bot.running,
"mode": settings.trading_mode,
},
"status": {
"status": bot.status().as_dict(),
"account": bot.account_snapshot(),
"positions": bot.positions_snapshot(),
"learning": bot.learning_snapshot(),
"latest_equity": storage.latest_equity(mode=settings.trading_mode),
"readiness": bot.readiness_snapshot(),
},
"markets": market.snapshot(),
"signals": {"items": storage.recent_signals(row_limit)},
"config": _safe_config(settings),
"trades": {
"items": storage.recent_trades(10, mode=settings.trading_mode),
"closed_items": storage.closed_trades(10, mode=settings.trading_mode),
"closed_summary": storage.closed_trade_summary(mode=settings.trading_mode),
},
"retrain": retrain_data,
"backtest": _runtime_json(settings, "torch_threshold_calibration.json"),
}
@app.post("/api/config/fast-trading")
async def set_fast_trading(payload: dict[str, Any]) -> dict[str, Any]:
async def set_fast_trading(
payload: dict[str, Any],
_: None = Depends(authorizer.require),
) -> dict[str, Any]:
enabled = _enabled_from_payload(payload)
env_persisted = _apply_fast_trading(settings, storage, enabled)
response = _safe_config(settings)
@@ -176,12 +248,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
return response
@app.post("/api/control/start")
async def start() -> dict[str, Any]:
async def start(_: None = Depends(authorizer.require)) -> dict[str, Any]:
await bot.start()
return bot.status().as_dict()
@app.post("/api/control/stop")
async def stop() -> dict[str, Any]:
async def stop(_: None = Depends(authorizer.require)) -> dict[str, Any]:
await bot.stop()
return bot.status().as_dict()
@@ -207,13 +279,22 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"# HELP tradebot_loop_interval_seconds Effective bot decision loop interval.",
"# TYPE tradebot_loop_interval_seconds gauge",
f"tradebot_loop_interval_seconds {settings.effective_loop_interval_seconds:.4f}",
"# HELP tradebot_ready Whether trading prerequisites are ready.",
"# TYPE tradebot_ready gauge",
f"tradebot_ready {1 if bot.readiness_snapshot()['ready'] else 0}",
"# HELP tradebot_rest_errors_total REST refresh errors observed by market data.",
"# TYPE tradebot_rest_errors_total counter",
f"tradebot_rest_errors_total {market.rest_error_count}",
]
return PlainTextResponse("\n".join(lines) + "\n")
@app.exception_handler(Exception)
async def error_handler(_, exc: Exception) -> JSONResponse:
storage.event(f"API error: {exc}", "ERROR")
return JSONResponse({"error": str(exc)}, status_code=500)
try:
storage.event(f"API error: {exc}", "ERROR")
except Exception:
logger.exception("Could not persist API error event")
return JSONResponse({"error": "internal server error"}, status_code=500)
return app
@@ -317,6 +398,11 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
"time_series_probe_min_probability_up": settings.time_series_probe_min_probability_up,
"time_series_probe_size_multiplier": settings.time_series_probe_size_multiplier,
"time_series_rebound_fallback_enabled": settings.time_series_rebound_fallback_enabled,
"time_series_require_quality_gate": settings.time_series_require_quality_gate,
"time_series_manual_quality_override": settings.time_series_manual_quality_override,
"time_series_require_fresh_model": settings.time_series_require_fresh_model,
"time_series_model_max_age_hours": settings.time_series_model_max_age_hours,
"market_ticker_max_age_seconds": settings.market_ticker_max_age_seconds,
"time_series_model_artifact": _time_series_model_artifact(settings),
"stop_loss_percent": settings.stop_loss_percent,
"stop_loss_exit_enabled": settings.stop_loss_exit_enabled,
@@ -331,6 +417,14 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
"slippage_rate": settings.slippage_rate,
"live_ready": settings.live_ready,
"live_order_max_usdt": settings.live_order_max_usdt,
"live_order_fill_timeout_seconds": settings.live_order_fill_timeout_seconds,
"live_reconciliation_interval_seconds": settings.live_reconciliation_interval_seconds,
"live_protective_stop_enabled": settings.live_protective_stop_enabled,
"api_auth_configured": bool(
settings.api_auth_token
or settings.training_worker_token
or settings.trusted_proxy_user_header
),
}
+495 -15
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import deque
from datetime import timedelta
from decimal import Decimal, ROUND_DOWN, ROUND_UP
from typing import Iterable
from typing import Any, Iterable
from uuid import uuid4
from crypto_spot_bot.bybit import BybitClient, Instrument
@@ -38,9 +38,19 @@ class PaperBroker:
def __init__(self, settings: Settings, storage: Storage):
self.settings = settings
self.storage = storage
self.positions = storage.open_positions()
self.positions = storage.open_positions(settings.trading_mode)
self.cash = float(storage.get_runtime("paper_cash", settings.starting_balance_usdt))
self.peak_equity = float(storage.get_runtime("peak_equity", settings.starting_balance_usdt))
today = utc_now().date().isoformat()
stored_peak_day = str(storage.get_runtime("paper_peak_equity_day", ""))
self.peak_equity_day = today
self.peak_equity = float(
storage.get_runtime("paper_daily_peak_equity", settings.starting_balance_usdt)
if stored_peak_day == today
else settings.starting_balance_usdt
)
self.lifetime_peak_equity = float(
storage.get_runtime("paper_lifetime_peak_equity", settings.starting_balance_usdt)
)
self._entry_timestamps = deque()
def open_positions(self) -> list[Position]:
@@ -64,11 +74,24 @@ class PaperBroker:
def mark_equity(self, prices: dict[str, float]) -> dict[str, float]:
state = self.account_state(prices)
equity = state["equity"]
today = utc_now().date().isoformat()
if today != self.peak_equity_day:
self.peak_equity_day = today
self.peak_equity = equity
self.peak_equity = max(self.peak_equity, equity)
self.lifetime_peak_equity = max(self.lifetime_peak_equity, equity)
state["drawdown"] = max(0.0, self.peak_equity - equity)
self.storage.set_runtime("paper_cash", self.cash)
self.storage.set_runtime("peak_equity", self.peak_equity)
self.storage.insert_equity(equity, self.cash, self.exposure(), state["drawdown"])
self.storage.set_runtime("paper_peak_equity_day", self.peak_equity_day)
self.storage.set_runtime("paper_daily_peak_equity", self.peak_equity)
self.storage.set_runtime("paper_lifetime_peak_equity", self.lifetime_peak_equity)
self.storage.insert_equity(
equity,
self.cash,
self.exposure(),
state["drawdown"],
mode=self.settings.trading_mode,
)
return state
def account_state(self, prices: dict[str, float]) -> dict[str, float]:
@@ -181,6 +204,7 @@ class PaperBroker:
entry_confidence=signal.confidence,
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
entry_diagnostics=signal.diagnostics,
mode=self.settings.trading_mode,
)
position.id = self.storage.insert_position(position)
self.positions.append(position)
@@ -201,6 +225,7 @@ class PaperBroker:
entry_confidence=position.entry_confidence,
entry_diagnostics=position.entry_diagnostics,
opened_at=position.opened_at,
mode=self.settings.trading_mode,
)
)
self.storage.event(
@@ -244,6 +269,7 @@ class PaperBroker:
entry_diagnostics=position.entry_diagnostics,
opened_at=position.opened_at,
closed_at=utc_now(),
mode=self.settings.trading_mode,
)
trade.id = self.storage.insert_trade(trade)
self.storage.event(
@@ -368,11 +394,139 @@ class PaperBroker:
class LiveBroker(PaperBroker):
TERMINAL_ORDER_STATUSES = {
"Filled",
"Cancelled",
"Rejected",
"PartiallyFilledCanceled",
"PartillyFilledCancelled",
"Deactivated",
}
def __init__(self, settings: Settings, storage: Storage, client: BybitClient):
super().__init__(settings, storage)
if not settings.live_ready:
raise BrokerError("Live mode is not unlocked by settings")
self.client = client
self.reconciliation_state: dict[str, Any] = {
"status": "unknown",
"blocking": True,
"discrepancies": ["live account has not been reconciled"],
}
def can_open(
self,
symbol: str,
prices: dict[str, float],
requested_notional: float | None = None,
) -> tuple[bool, str]:
if self.reconciliation_state.get("blocking", True):
return False, "live reconciliation is not clean"
return super().can_open(symbol, prices, requested_notional)
def reconcile(self, instruments: dict[str, Instrument]) -> dict[str, Any]:
coins = {"USDT"}
for symbol in self.settings.symbols:
instrument = instruments.get(symbol)
if instrument and instrument.base_coin:
coins.add(instrument.base_coin.upper())
wallet = self.client.wallet_balance(coin=",".join(sorted(coins)))
balances = _wallet_balances(wallet)
usdt = balances.get("USDT", {})
self.cash = max(0.0, float(usdt.get("wallet_balance", 0.0)) - float(usdt.get("locked", 0.0)))
local_by_coin: dict[str, float] = {}
discrepancies: list[dict[str, Any]] = []
for position in self.positions:
instrument = instruments.get(position.symbol)
coin = instrument.base_coin.upper() if instrument and instrument.base_coin else position.symbol.removesuffix("USDT")
local_by_coin[coin] = local_by_coin.get(coin, 0.0) + position.qty
for coin, local_qty in local_by_coin.items():
remote_qty = float((balances.get(coin) or {}).get("wallet_balance", 0.0))
tolerance = max(1e-8, local_qty * 0.002)
if remote_qty + tolerance < local_qty:
discrepancies.append(
{
"severity": "error",
"code": "remote_balance_below_local_position",
"coin": coin,
"local_qty": round(local_qty, 12),
"remote_qty": round(remote_qty, 12),
}
)
for coin, row in balances.items():
if coin == "USDT" or coin not in coins:
continue
remote_qty = float(row.get("wallet_balance", 0.0))
local_qty = local_by_coin.get(coin, 0.0)
tolerance = max(1e-8, local_qty * 0.002)
if remote_qty > local_qty + tolerance:
discrepancies.append(
{
"severity": "error",
"code": "remote_asset_without_matching_local_position",
"coin": coin,
"local_qty": round(local_qty, 12),
"remote_qty": round(remote_qty, 12),
}
)
normal_orders = self.client.realtime_orders(
category="spot",
open_only=0,
limit=50,
order_filter="Order",
)
unresolved_orders = [
row
for row in normal_orders.get("list", [])
if isinstance(row, dict)
and str(row.get("orderStatus", "")) not in self.TERMINAL_ORDER_STATUSES
]
if unresolved_orders:
discrepancies.append(
{
"severity": "error",
"code": "unresolved_exchange_orders",
"count": len(unresolved_orders),
"order_ids": [str(row.get("orderId", "")) for row in unresolved_orders[:10]],
}
)
protection_rows = self.client.realtime_orders(
category="spot",
open_only=0,
limit=50,
order_filter="tpslOrder",
)
active_protection = {
str(row.get("orderId", ""))
for row in protection_rows.get("list", [])
if isinstance(row, dict)
and str(row.get("orderStatus", "")) not in self.TERMINAL_ORDER_STATUSES
}
if self.settings.live_protective_stop_enabled:
for position in self.positions:
if not position.protective_order_id or position.protective_order_id not in active_protection:
discrepancies.append(
{
"severity": "error",
"code": "missing_exchange_protective_stop",
"position_id": position.id,
"symbol": position.symbol,
}
)
blocking = any(row.get("severity") == "error" for row in discrepancies)
self.reconciliation_state = {
"status": "error" if blocking else ("warn" if discrepancies else "ok"),
"blocking": blocking,
"discrepancies": discrepancies,
"cash_usdt": round(self.cash, 8),
"checked_at": utc_now().isoformat(),
}
self.storage.set_runtime("live_reconciliation", self.reconciliation_state)
return dict(self.reconciliation_state)
def buy(
self,
@@ -400,30 +554,356 @@ class LiveBroker(PaperBroker):
if budget < max(self.settings.min_position_usdt, minimum_budget):
self.storage.event(f"{ticker.symbol}: live BUY skipped, adjusted budget below minimum", "WARN")
return None
signal.diagnostics["position_notional_usdt"] = budget
notional = budget / (1 + self.settings.taker_fee_rate)
response = self.client.place_spot_market_order(
requested_quote = budget / (1 + self.settings.taker_fee_rate)
client_order_id = f"tb-buy-{uuid4().hex[:18]}"
self.storage.upsert_order(
client_order_id=client_order_id,
symbol=ticker.symbol,
side="Buy",
qty=notional,
market_unit="quoteCoin",
order_link_id=f"tb-buy-{uuid4().hex[:18]}",
order_kind="MARKET",
status="PENDING_SUBMIT",
requested_notional=requested_quote,
raw={"signal": signal.as_dict()},
)
self.storage.event(f"{ticker.symbol}: реальная покупка отправлена orderId={response.get('orderId')}")
return self._record_buy(signal, ticker, instrument, "реальная покупка, локальная запись")
try:
response = self.client.place_spot_market_order(
symbol=ticker.symbol,
side="Buy",
qty=requested_quote,
market_unit="quoteCoin",
order_link_id=client_order_id,
)
order_id = str(response.get("orderId", ""))
if not order_id:
raise BrokerError("Bybit did not return orderId for live BUY")
self.storage.upsert_order(
client_order_id=client_order_id,
exchange_order_id=order_id,
symbol=ticker.symbol,
side="Buy",
order_kind="MARKET",
status="ACCEPTED",
requested_notional=requested_quote,
raw=response,
)
result = self.client.wait_for_spot_order(
order_id=order_id,
symbol=ticker.symbol,
timeout_seconds=self.settings.live_order_fill_timeout_seconds,
)
fill = _execution_fill(result, side="Buy", instrument=instrument)
self._save_order_fill(client_order_id, order_id, ticker.symbol, "Buy", requested_quote, result, fill)
if fill["qty"] <= 0 or fill["value"] <= 0:
raise BrokerError(f"live BUY was not filled, status={fill['status']}")
position = self._record_live_buy(signal, ticker, fill)
if self.settings.live_protective_stop_enabled:
try:
self._place_protective_stop(position)
except Exception as exc:
self.storage.event(
f"{ticker.symbol}: protective stop placement failed, closing position: {exc}",
"ERROR",
)
self.sell(position, ticker, "protective stop placement failed")
raise BrokerError("live BUY was unwound because protective stop failed") from exc
return position
except Exception as exc:
self.reconciliation_state["blocking"] = True
self.reconciliation_state["status"] = "error"
self.storage.event(f"{ticker.symbol}: live BUY failed: {exc}", "ERROR")
raise
def sell(self, position: Position, ticker: Ticker, reason: str) -> Trade:
if position.protective_order_id or position.protective_order_link_id:
self.client.cancel_spot_order(
symbol=position.symbol,
order_id=position.protective_order_id or None,
order_link_id=position.protective_order_link_id or None,
order_filter="tpslOrder",
)
if position.protective_order_id:
cancelled = self.client.wait_for_spot_order(
order_id=position.protective_order_id,
symbol=position.symbol,
timeout_seconds=min(10.0, self.settings.live_order_fill_timeout_seconds),
)
status = str((cancelled.get("order") or {}).get("orderStatus", ""))
if status and status != "Cancelled":
raise BrokerError(f"protective order was not cancelled, status={status}")
client_order_id = f"tb-sell-{uuid4().hex[:18]}"
self.storage.upsert_order(
client_order_id=client_order_id,
symbol=position.symbol,
side="Sell",
order_kind="MARKET",
status="PENDING_SUBMIT",
requested_qty=position.qty,
raw={"position_id": position.id, "reason": reason},
)
response = self.client.place_spot_market_order(
symbol=position.symbol,
side="Sell",
qty=position.qty,
market_unit="baseCoin",
order_link_id=f"tb-sell-{uuid4().hex[:18]}",
order_link_id=client_order_id,
)
order_id = str(response.get("orderId", ""))
if not order_id:
raise BrokerError("Bybit did not return orderId for live SELL")
result = self.client.wait_for_spot_order(
order_id=order_id,
symbol=position.symbol,
timeout_seconds=self.settings.live_order_fill_timeout_seconds,
)
fill = _execution_fill(result, side="Sell", instrument=None)
self._save_order_fill(client_order_id, order_id, position.symbol, "Sell", position.qty, result, fill)
if fill["qty"] <= 0 or fill["value"] <= 0:
self.reconciliation_state["blocking"] = True
raise BrokerError(f"live SELL was not filled, status={fill['status']}")
return self._record_live_sell(position, reason, fill)
def _record_live_buy(self, signal: Signal, ticker: Ticker, fill: dict[str, Any]) -> Position:
qty = float(fill["net_qty"])
value = float(fill["value"])
price = value / max(float(fill["qty"]), 1e-12)
fee_usdt = float(fill["fee_usdt"])
stop_loss_percent = self._signal_percent(
signal, "stop_loss_percent", self.settings.stop_loss_percent, 0.003, 0.08
)
take_profit_percent = self._signal_percent(
signal, "take_profit_percent", self.settings.take_profit_percent, 0.003, 0.20
)
position = Position(
id=None,
symbol=ticker.symbol,
qty=qty,
entry_price=price,
notional_usdt=value,
entry_fee_usdt=fee_usdt,
stop_loss=price * (1 - stop_loss_percent),
take_profit=price * (1 + take_profit_percent),
highest_price=price,
entry_reason=signal.reason,
entry_confidence=signal.confidence,
entry_pattern=str(signal.diagnostics.get("pattern", {}).get("label", "")),
entry_diagnostics=signal.diagnostics,
mode="live",
)
position.id = self.storage.insert_position(position)
self.positions.append(position)
self._record_entry_timestamp()
self.cash = max(0.0, self.cash - value - float(fill["quote_fee"]))
self.storage.insert_trade(
Trade(
id=None,
symbol=ticker.symbol,
side="BUY",
qty=qty,
entry_price=price,
fee_usdt=fee_usdt,
net_pnl=-fee_usdt,
reason=signal.reason,
entry_pattern=position.entry_pattern,
entry_confidence=position.entry_confidence,
entry_diagnostics=position.entry_diagnostics,
opened_at=position.opened_at,
mode="live",
)
)
self.storage.event(
f"{position.symbol}: реальная продажа отправлена orderId={response.get('orderId')} причина={reason}"
f"{ticker.symbol}: live BUY filled qty={qty:.8f} avg={price:.8f} value={value:.4f}"
)
return self._record_sell(position, ticker, reason, "реальная продажа, локальная запись")
return position
def _record_live_sell(self, position: Position, reason: str, fill: dict[str, Any]) -> Trade:
sold_qty = min(position.qty, float(fill["qty"]))
value = float(fill["value"])
price = value / max(float(fill["qty"]), 1e-12)
exit_fee = float(fill["fee_usdt"])
ratio = min(1.0, sold_qty / max(position.qty, 1e-12))
allocated_entry_fee = position.entry_fee_usdt * ratio
gross_pnl = (price - position.entry_price) * sold_qty
net_pnl = gross_pnl - allocated_entry_fee - exit_fee
self.cash += value - float(fill["quote_fee"])
remaining_qty = max(0.0, position.qty - sold_qty)
if remaining_qty <= max(1e-12, position.qty * 1e-6):
if position.id is not None:
self.storage.close_position(position.id)
self.positions = [item for item in self.positions if item.id != position.id]
else:
remaining_ratio = remaining_qty / position.qty
position.qty = remaining_qty
position.notional_usdt *= remaining_ratio
position.entry_fee_usdt *= remaining_ratio
position.protective_order_id = ""
position.protective_order_link_id = ""
if position.id is not None:
self.storage.update_position_after_partial_sell(
position.id,
qty=position.qty,
notional_usdt=position.notional_usdt,
entry_fee_usdt=position.entry_fee_usdt,
)
self.reconciliation_state["blocking"] = True
trade = Trade(
id=None,
symbol=position.symbol,
side="SELL",
qty=sold_qty,
entry_price=position.entry_price,
exit_price=price,
gross_pnl=gross_pnl,
fee_usdt=allocated_entry_fee + exit_fee,
net_pnl=net_pnl,
reason=reason,
entry_pattern=position.entry_pattern,
entry_confidence=position.entry_confidence,
entry_diagnostics=position.entry_diagnostics,
opened_at=position.opened_at,
closed_at=utc_now(),
mode="live",
)
trade.id = self.storage.insert_trade(trade)
self.storage.event(
f"{position.symbol}: live SELL filled qty={sold_qty:.8f} avg={price:.8f} pnl={net_pnl:.4f} reason={reason}"
)
return trade
def _place_protective_stop(self, position: Position) -> None:
link_id = f"tb-stop-{uuid4().hex[:17]}"
response = self.client.place_spot_protective_stop(
symbol=position.symbol,
qty=position.qty,
trigger_price=position.stop_loss,
order_link_id=link_id,
)
order_id = str(response.get("orderId", ""))
if not order_id:
raise BrokerError("Bybit did not return orderId for protective stop")
position.protective_order_id = order_id
position.protective_order_link_id = link_id
if position.id is not None:
self.storage.update_position_protective_order(position.id, order_id, link_id)
self.storage.upsert_order(
client_order_id=link_id,
exchange_order_id=order_id,
symbol=position.symbol,
side="Sell",
order_kind="PROTECTIVE_STOP",
status="ACCEPTED",
requested_qty=position.qty,
raw=response,
)
def _save_order_fill(
self,
client_order_id: str,
order_id: str,
symbol: str,
side: str,
requested: float,
result: dict[str, Any],
fill: dict[str, Any],
) -> None:
self.storage.upsert_order(
client_order_id=client_order_id,
exchange_order_id=order_id,
symbol=symbol,
side=side,
order_kind="MARKET",
status=str(fill["status"]),
requested_qty=requested if side == "Sell" else 0.0,
requested_notional=requested if side == "Buy" else 0.0,
executed_qty=float(fill["qty"]),
executed_value=float(fill["value"]),
fee_usdt=float(fill["fee_usdt"]),
raw=result,
)
def _wallet_balances(wallet: dict[str, Any]) -> dict[str, dict[str, float]]:
accounts = wallet.get("list")
if not isinstance(accounts, list) or not accounts:
return {}
coins = accounts[0].get("coin") if isinstance(accounts[0], dict) else None
if not isinstance(coins, list):
return {}
result: dict[str, dict[str, float]] = {}
for row in coins:
if not isinstance(row, dict):
continue
coin = str(row.get("coin", "")).upper()
if not coin:
continue
result[coin] = {
"wallet_balance": _safe_float(row.get("walletBalance")),
"equity": _safe_float(row.get("equity")),
"locked": _safe_float(row.get("locked")),
}
return result
def _execution_fill(
result: dict[str, Any],
*,
side: str,
instrument: Instrument | None,
) -> dict[str, Any]:
order = result.get("order") if isinstance(result.get("order"), dict) else {}
executions = result.get("executions") if isinstance(result.get("executions"), list) else []
qty = 0.0
value = 0.0
quote_fee = 0.0
base_fee = 0.0
fee_usdt = 0.0
base_coin = instrument.base_coin.upper() if instrument and instrument.base_coin else ""
for row in executions:
if not isinstance(row, dict):
continue
exec_qty = _safe_float(row.get("execQty"))
exec_value = _safe_float(row.get("execValue"))
exec_price = _safe_float(row.get("execPrice"))
fee = max(0.0, _safe_float(row.get("execFee")))
fee_currency = str(row.get("feeCurrency", "")).upper()
if not base_coin:
symbol = str(row.get("symbol", ""))
base_coin = symbol.removesuffix("USDT") if symbol.endswith("USDT") else ""
qty += exec_qty
value += exec_value or exec_qty * exec_price
if fee_currency == "USDT" or not fee_currency:
quote_fee += fee
fee_usdt += fee
elif fee_currency == base_coin:
base_fee += fee
fee_usdt += fee * exec_price
else:
fee_usdt += fee * exec_price
if qty <= 0:
qty = _safe_float(order.get("cumExecQty"))
if value <= 0:
value = _safe_float(order.get("cumExecValue"))
if value <= 0 and qty > 0:
value = qty * _safe_float(order.get("avgPrice"))
net_qty = max(0.0, qty - base_fee) if side == "Buy" else qty
return {
"status": str(order.get("orderStatus", "Unknown")),
"qty": qty,
"net_qty": net_qty,
"value": value,
"quote_fee": quote_fee,
"base_fee": base_fee,
"fee_usdt": fee_usdt,
}
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def prices_from_tickers(tickers: Iterable[Ticker]) -> dict[str, float]:
+4 -1
View File
@@ -60,7 +60,10 @@ class TradeLearner:
self.storage.set_runtime("learning_state", self.state.as_dict())
return self.state
trades = self.storage.closed_trades(self.settings.learning_lookback_trades)
trades = self.storage.closed_trades(
self.settings.learning_lookback_trades,
mode=self.settings.trading_mode,
)
total_net = sum(float(trade.get("net_pnl") or 0.0) for trade in trades)
wins = sum(1 for trade in trades if float(trade.get("net_pnl") or 0.0) > 0)
symbol_stats = _group_stats(trades, "symbol")
+7 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
import uvicorn
@@ -15,7 +16,12 @@ def main() -> None:
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
handlers=[
logging.FileHandler(settings.log_path, encoding="utf-8"),
RotatingFileHandler(
settings.log_path,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
),
logging.StreamHandler(),
],
)
+94 -40
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import json
import threading
from dataclasses import asdict
from datetime import datetime
from typing import Any
@@ -56,6 +57,9 @@ class MarketData:
self.last_ws_message_at: datetime | None = None
self.ws_connected = False
self._stop_event = asyncio.Event()
self._refresh_lock = threading.Lock()
self.rest_error_count = 0
self.last_rest_error = ""
async def bootstrap(self) -> None:
self.instruments = await asyncio.to_thread(self.client.instruments)
@@ -76,47 +80,60 @@ class MarketData:
if symbol in self.instruments
]
self.storage.event("Торговые пары: " + ", ".join(self.symbols))
await asyncio.to_thread(self.refresh_rest)
await asyncio.to_thread(self.refresh_rest, True)
def refresh_rest(self) -> None:
ticker_map = {ticker.symbol: ticker for ticker in self.client.spot_tickers()}
for symbol in self.symbols:
ticker = ticker_map.get(symbol)
if ticker:
self.tickers[symbol] = ticker
try:
candles = self.client.klines(
symbol=symbol,
interval=self.settings.base_interval,
limit=self.settings.kline_limit,
)
candles = _closed_candles(candles, self.settings.base_interval)
add_indicators(candles)
self.candles[symbol] = candles
trend_candles = self.client.klines(
symbol=symbol,
interval=self.settings.trend_interval,
limit=self.settings.trend_kline_limit,
)
trend_candles = _closed_candles(trend_candles, self.settings.trend_interval)
add_indicators(trend_candles)
self.trend_candles[symbol] = trend_candles
bid, ask = self.client.orderbook_top(symbol)
self.orderbook_top[symbol] = (bid, ask)
if symbol in self.tickers:
current = self.tickers[symbol]
self.tickers[symbol] = Ticker(
symbol=current.symbol,
last_price=current.last_price,
bid=bid or current.bid,
ask=ask or current.ask,
turnover_24h=current.turnover_24h,
volume_24h=current.volume_24h,
change_24h=current.change_24h,
)
except Exception as exc:
self.storage.event(f"{symbol}: ошибка обновления REST данных: {exc}", "ERROR")
self.last_rest_refresh_at = utc_now()
def refresh_rest(self, force_candles: bool = False) -> None:
if not self._refresh_lock.acquire(blocking=False):
return
try:
ticker_map = {ticker.symbol: ticker for ticker in self.client.spot_tickers()}
for symbol in self.symbols:
ticker = ticker_map.get(symbol)
if ticker:
self.tickers[symbol] = ticker
try:
if force_candles or _candles_due(self.candles.get(symbol, []), self.settings.base_interval):
candles = self.client.klines(
symbol=symbol,
interval=self.settings.base_interval,
limit=self.settings.kline_limit,
)
candles = _closed_candles(candles, self.settings.base_interval)
add_indicators(candles)
self.candles[symbol] = candles
if force_candles or _candles_due(
self.trend_candles.get(symbol, []), self.settings.trend_interval
):
trend_candles = self.client.klines(
symbol=symbol,
interval=self.settings.trend_interval,
limit=self.settings.trend_kline_limit,
)
trend_candles = _closed_candles(trend_candles, self.settings.trend_interval)
add_indicators(trend_candles)
self.trend_candles[symbol] = trend_candles
bid, ask = self.client.orderbook_top(symbol)
self.orderbook_top[symbol] = (bid, ask)
if symbol in self.tickers:
current = self.tickers[symbol]
self.tickers[symbol] = Ticker(
symbol=current.symbol,
last_price=current.last_price,
bid=bid or current.bid,
ask=ask or current.ask,
turnover_24h=current.turnover_24h,
volume_24h=current.volume_24h,
change_24h=current.change_24h,
)
except Exception as exc:
self.rest_error_count += 1
self.last_rest_error = str(exc)
self.storage.event(f"{symbol}: ошибка обновления REST данных: {exc}", "ERROR")
self.last_rest_refresh_at = utc_now()
if ticker_map:
self.last_rest_error = ""
finally:
self._refresh_lock.release()
async def websocket_loop(self) -> None:
if not self.settings.websocket_enabled:
@@ -227,10 +244,36 @@ class MarketData:
def prices(self) -> dict[str, float]:
return {symbol: ticker.last_price for symbol, ticker in self.tickers.items()}
def symbol_freshness(self, symbol: str) -> dict[str, Any]:
ticker = self.tickers.get(symbol)
candles = self.candles.get(symbol, [])
ticker_age = (utc_now() - ticker.updated_at).total_seconds() if ticker else None
interval_ms = _interval_ms(self.settings.base_interval)
candle_age = (
max(0.0, (utc_now().timestamp() * 1000 - candles[-1].timestamp) / 1000)
if candles
else None
)
ticker_ok = ticker_age is not None and ticker_age <= self.settings.market_ticker_max_age_seconds
candle_ok = bool(
candle_age is not None
and interval_ms > 0
and candle_age <= (interval_ms / 1000) * 2.5
)
return {
"ok": bool(ticker_ok and candle_ok),
"ticker_ok": ticker_ok,
"candle_ok": candle_ok,
"ticker_age_seconds": round(ticker_age, 3) if ticker_age is not None else None,
"candle_age_seconds": round(candle_age, 3) if candle_age is not None else None,
}
def snapshot(self) -> dict[str, Any]:
return {
"symbols": self.symbols,
"ws_connected": self.ws_connected,
"rest_error_count": self.rest_error_count,
"last_rest_error": self.last_rest_error,
"quality": market_quality_snapshot(
symbols=self.symbols,
candles_by_symbol=self.candles,
@@ -291,3 +334,14 @@ def _interval_ms(interval: str) -> int:
if normalized.isdigit():
return int(normalized) * 60 * 1000
return 0
def _candles_due(candles: list[Candle], interval: str, now_ms: int | None = None) -> bool:
if not candles:
return True
interval_ms = _interval_ms(interval)
if interval_ms <= 0:
return True
now_ms = now_ms if now_ms is not None else int(utc_now().timestamp() * 1000)
expected_latest_start = (now_ms // interval_ms - 1) * interval_ms
return candles[-1].timestamp < expected_latest_start
+4
View File
@@ -88,6 +88,9 @@ class Position:
entry_confidence: float = 0.0
entry_pattern: str = ""
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
protective_order_id: str = ""
protective_order_link_id: str = ""
mode: str = "paper"
def mark_price(self, price: float) -> float:
return self.qty * price
@@ -131,6 +134,7 @@ class Trade:
entry_diagnostics: dict[str, Any] = field(default_factory=dict)
opened_at: datetime | None = None
closed_at: datetime | None = None
mode: str = "paper"
def as_dict(self) -> dict[str, Any]:
data = asdict(self)
+2 -1
View File
@@ -15,7 +15,7 @@ def reconciliation_snapshot(
client: BybitClient,
instruments: dict[str, Instrument],
) -> dict[str, Any]:
local_positions = storage.open_positions()
local_positions = storage.open_positions(settings.trading_mode)
local = [
{
"id": position.id,
@@ -23,6 +23,7 @@ def reconciliation_snapshot(
"qty": position.qty,
"entry_price": position.entry_price,
"notional_usdt": position.notional_usdt,
"protective_order_id": position.protective_order_id,
}
for position in local_positions
]
+336 -32
View File
@@ -2,23 +2,61 @@ from __future__ import annotations
import json
import sqlite3
import time
from contextlib import contextmanager
from datetime import timedelta
from pathlib import Path
from typing import Any, Iterator
from crypto_spot_bot.models import Position, Signal, Trade, utc_now
MAX_SIGNAL_DIAGNOSTICS_BYTES = 16 * 1024
PRUNE_BATCH_SIZE = 1000
_STORED_FORECAST_KEYS = {
"enabled",
"usable",
"model",
"volatility_model",
"expected_return_percent",
"expected_price",
"volatility_percent",
"probability_up",
"confidence_adjustment",
"block_entry",
"validation_mae_percent",
"baseline_mae_percent",
"skill",
"horizon",
"reason",
"expected_gross_return_percent",
"quantile_10_percent",
"quantile_50_percent",
"quantile_90_percent",
"conservative_return_percent",
"target_transform",
"horizon_forecasts",
"candidates",
"quality_gate_passed",
"model_created_at",
"model_age_hours",
"model_fresh",
}
class Storage:
def __init__(self, path: str | Path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._last_hold_signal: dict[tuple[str, str], float] = {}
self.init_schema()
@contextmanager
def connect(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self.path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
@@ -27,6 +65,7 @@ class Storage:
def init_schema(self) -> None:
with self.connect() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS positions (
@@ -44,6 +83,9 @@ class Storage:
entry_confidence REAL NOT NULL DEFAULT 0,
entry_pattern TEXT NOT NULL DEFAULT '',
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
protective_order_id TEXT NOT NULL DEFAULT '',
protective_order_link_id TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'paper',
status TEXT NOT NULL DEFAULT 'OPEN'
);
CREATE TABLE IF NOT EXISTS trades (
@@ -61,7 +103,8 @@ class Storage:
entry_confidence REAL NOT NULL DEFAULT 0,
entry_diagnostics_json TEXT NOT NULL DEFAULT '{}',
opened_at TEXT,
closed_at TEXT
closed_at TEXT,
mode TEXT NOT NULL DEFAULT 'paper'
);
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -78,7 +121,8 @@ class Storage:
cash REAL NOT NULL,
exposure REAL NOT NULL,
drawdown REAL NOT NULL,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'paper'
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -101,6 +145,35 @@ class Storage:
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_order_id TEXT NOT NULL UNIQUE,
exchange_order_id TEXT NOT NULL DEFAULT '',
symbol TEXT NOT NULL,
side TEXT NOT NULL,
order_kind TEXT NOT NULL DEFAULT 'MARKET',
status TEXT NOT NULL,
requested_qty REAL NOT NULL DEFAULT 0,
requested_notional REAL NOT NULL DEFAULT 0,
executed_qty REAL NOT NULL DEFAULT 0,
executed_value REAL NOT NULL DEFAULT 0,
fee_usdt REAL NOT NULL DEFAULT 0,
raw_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_positions_status_opened
ON positions(status, opened_at);
CREATE INDEX IF NOT EXISTS idx_trades_closed
ON trades(side, closed_at, id DESC);
CREATE INDEX IF NOT EXISTS idx_signals_symbol_created
ON signals(symbol, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_equity_created
ON equity(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_events_created
ON events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_orders_status_updated
ON orders(status, updated_at DESC);
"""
)
columns = {
@@ -116,6 +189,9 @@ class Storage:
"entry_confidence": "REAL NOT NULL DEFAULT 0",
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
"protective_order_id": "TEXT NOT NULL DEFAULT ''",
"protective_order_link_id": "TEXT NOT NULL DEFAULT ''",
"mode": "TEXT NOT NULL DEFAULT 'paper'",
}.items():
if column not in columns:
conn.execute(f"ALTER TABLE positions ADD COLUMN {column} {definition}")
@@ -127,9 +203,19 @@ class Storage:
"entry_pattern": "TEXT NOT NULL DEFAULT ''",
"entry_confidence": "REAL NOT NULL DEFAULT 0",
"entry_diagnostics_json": "TEXT NOT NULL DEFAULT '{}'",
"mode": "TEXT NOT NULL DEFAULT 'paper'",
}.items():
if column not in trade_columns:
conn.execute(f"ALTER TABLE trades ADD COLUMN {column} {definition}")
equity_columns = {
row["name"]
for row in conn.execute("PRAGMA table_info(equity)").fetchall()
}
if "mode" not in equity_columns:
conn.execute("ALTER TABLE equity ADD COLUMN mode TEXT NOT NULL DEFAULT 'paper'")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_equity_mode_created ON equity(mode, created_at DESC)"
)
def insert_position(self, position: Position) -> int:
with self.connect() as conn:
@@ -138,8 +224,9 @@ class Storage:
INSERT INTO positions (
symbol, qty, entry_price, notional_usdt, entry_fee_usdt, stop_loss,
take_profit, highest_price, opened_at, entry_reason,
entry_confidence, entry_pattern, entry_diagnostics_json, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
entry_confidence, entry_pattern, entry_diagnostics_json,
protective_order_id, protective_order_link_id, mode, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN')
""",
(
position.symbol,
@@ -155,6 +242,9 @@ class Storage:
position.entry_confidence,
position.entry_pattern,
json.dumps(position.entry_diagnostics, ensure_ascii=False),
position.protective_order_id,
position.protective_order_link_id,
position.mode,
),
)
return int(cur.lastrowid)
@@ -170,11 +260,52 @@ class Storage:
(highest_price, position_id),
)
def open_positions(self) -> list[Position]:
def update_position_protective_order(
self,
position_id: int,
order_id: str,
order_link_id: str,
) -> None:
with self.connect() as conn:
rows = conn.execute(
"SELECT * FROM positions WHERE status='OPEN' ORDER BY opened_at"
).fetchall()
conn.execute(
"""
UPDATE positions
SET protective_order_id=?, protective_order_link_id=?
WHERE id=? AND status='OPEN'
""",
(order_id, order_link_id, position_id),
)
def update_position_after_partial_sell(
self,
position_id: int,
*,
qty: float,
notional_usdt: float,
entry_fee_usdt: float,
) -> None:
with self.connect() as conn:
conn.execute(
"""
UPDATE positions
SET qty=?, notional_usdt=?, entry_fee_usdt=?,
protective_order_id='', protective_order_link_id=''
WHERE id=? AND status='OPEN'
""",
(qty, notional_usdt, entry_fee_usdt, position_id),
)
def open_positions(self, mode: str | None = None) -> list[Position]:
with self.connect() as conn:
if mode:
rows = conn.execute(
"SELECT * FROM positions WHERE status='OPEN' AND mode=? ORDER BY opened_at",
(mode,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM positions WHERE status='OPEN' ORDER BY opened_at"
).fetchall()
return [
Position(
id=int(row["id"]),
@@ -191,6 +322,9 @@ class Storage:
entry_confidence=float(row["entry_confidence"]),
entry_pattern=row["entry_pattern"],
entry_diagnostics=_json_or_default(row["entry_diagnostics_json"], {}),
protective_order_id=row["protective_order_id"],
protective_order_link_id=row["protective_order_link_id"],
mode=row["mode"],
)
for row in rows
]
@@ -203,7 +337,8 @@ class Storage:
symbol, side, qty, entry_price, exit_price, gross_pnl,
fee_usdt, net_pnl, reason, entry_pattern, entry_confidence,
entry_diagnostics_json, opened_at, closed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
, mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
trade.symbol,
@@ -220,32 +355,41 @@ class Storage:
json.dumps(trade.entry_diagnostics, ensure_ascii=False),
trade.opened_at.isoformat() if trade.opened_at else None,
trade.closed_at.isoformat() if trade.closed_at else None,
trade.mode,
),
)
return int(cur.lastrowid)
def recent_trades(self, limit: int = 50) -> list[dict[str, Any]]:
def recent_trades(self, limit: int = 50, mode: str | None = None) -> list[dict[str, Any]]:
with self.connect() as conn:
rows = conn.execute("SELECT * FROM trades ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
if mode:
rows = conn.execute(
"SELECT * FROM trades WHERE mode=? ORDER BY id DESC LIMIT ?",
(mode, limit),
).fetchall()
else:
rows = conn.execute("SELECT * FROM trades ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
return [dict(row) for row in rows]
def closed_trades(self, limit: int = 200) -> list[dict[str, Any]]:
def closed_trades(self, limit: int = 200, mode: str | None = None) -> list[dict[str, Any]]:
with self.connect() as conn:
rows = conn.execute(
"""
query = """
SELECT * FROM trades
WHERE side='SELL' AND closed_at IS NOT NULL
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
"""
params: tuple[Any, ...]
if mode:
query += " AND mode=?"
params = (mode, limit)
else:
params = (limit,)
query += " ORDER BY id DESC LIMIT ?"
rows = conn.execute(query, params).fetchall()
return [dict(row) for row in rows]
def closed_trade_summary(self) -> dict[str, Any]:
def closed_trade_summary(self, mode: str | None = None) -> dict[str, Any]:
with self.connect() as conn:
row = conn.execute(
"""
query = """
SELECT
COUNT(*) AS trades,
COALESCE(SUM(net_pnl), 0) AS net_pnl,
@@ -255,8 +399,12 @@ class Storage:
COALESCE(SUM(CASE WHEN net_pnl < 0 THEN 1 ELSE 0 END), 0) AS losses
FROM trades
WHERE side='SELL' AND closed_at IS NOT NULL
"""
).fetchone()
"""
params: tuple[Any, ...] = ()
if mode:
query += " AND mode=?"
params = (mode,)
row = conn.execute(query, params).fetchone()
trades = int(row["trades"] if row else 0)
wins = int(row["wins"] if row else 0)
losses = int(row["losses"] if row else 0)
@@ -270,7 +418,15 @@ class Storage:
"win_rate": round(wins / trades, 4) if trades else 0.0,
}
def insert_signal(self, signal: Signal) -> None:
def insert_signal(self, signal: Signal, hold_sample_seconds: int = 0) -> bool:
if signal.action == "HOLD" and hold_sample_seconds > 0:
fingerprint = f"{signal.action}\0{signal.reason}"
now = time.monotonic()
sample_key = (signal.symbol, fingerprint)
previous = self._last_hold_signal.get(sample_key)
if previous is not None and now - previous < hold_sample_seconds:
return False
self._last_hold_signal[sample_key] = now
with self.connect() as conn:
conn.execute(
"""
@@ -282,26 +438,40 @@ class Storage:
signal.action,
signal.confidence,
signal.reason,
json.dumps(signal.diagnostics, ensure_ascii=False),
_signal_diagnostics_json(signal.diagnostics),
signal.created_at.isoformat(),
),
)
return True
def recent_signals(self, limit: int = 80) -> list[dict[str, Any]]:
with self.connect() as conn:
rows = conn.execute("SELECT * FROM signals ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
return [dict(row) for row in rows]
def insert_equity(self, equity: float, cash: float, exposure: float, drawdown: float) -> None:
def insert_equity(
self,
equity: float,
cash: float,
exposure: float,
drawdown: float,
mode: str = "paper",
) -> None:
with self.connect() as conn:
conn.execute(
"INSERT INTO equity (equity, cash, exposure, drawdown, created_at) VALUES (?, ?, ?, ?, ?)",
(equity, cash, exposure, drawdown, utc_now().isoformat()),
"INSERT INTO equity (equity, cash, exposure, drawdown, created_at, mode) VALUES (?, ?, ?, ?, ?, ?)",
(equity, cash, exposure, drawdown, utc_now().isoformat(), mode),
)
def latest_equity(self) -> dict[str, Any] | None:
def latest_equity(self, mode: str | None = None) -> dict[str, Any] | None:
with self.connect() as conn:
row = conn.execute("SELECT * FROM equity ORDER BY id DESC LIMIT 1").fetchone()
if mode:
row = conn.execute(
"SELECT * FROM equity WHERE mode=? ORDER BY id DESC LIMIT 1",
(mode,),
).fetchone()
else:
row = conn.execute("SELECT * FROM equity ORDER BY id DESC LIMIT 1").fetchone()
return dict(row) if row else None
def event(self, message: str, level: str = "INFO") -> None:
@@ -376,12 +546,146 @@ class Storage:
except json.JSONDecodeError:
return default
def upsert_order(
self,
*,
client_order_id: str,
exchange_order_id: str = "",
symbol: str,
side: str,
order_kind: str,
status: str,
requested_qty: float = 0.0,
requested_notional: float = 0.0,
executed_qty: float = 0.0,
executed_value: float = 0.0,
fee_usdt: float = 0.0,
raw: dict[str, Any] | None = None,
) -> None:
now = utc_now().isoformat()
with self.connect() as conn:
conn.execute(
"""
INSERT INTO orders (
client_order_id, exchange_order_id, symbol, side, order_kind,
status, requested_qty, requested_notional, executed_qty,
executed_value, fee_usdt, raw_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(client_order_id) DO UPDATE SET
exchange_order_id=excluded.exchange_order_id,
status=excluded.status,
executed_qty=excluded.executed_qty,
executed_value=excluded.executed_value,
fee_usdt=excluded.fee_usdt,
raw_json=excluded.raw_json,
updated_at=excluded.updated_at
""",
(
client_order_id,
exchange_order_id,
symbol,
side,
order_kind,
status,
requested_qty,
requested_notional,
executed_qty,
executed_value,
fee_usdt,
json.dumps(raw or {}, ensure_ascii=False),
now,
now,
),
)
def recent_orders(self, limit: int = 100) -> list[dict[str, Any]]:
with self.connect() as conn:
rows = conn.execute(
"SELECT * FROM orders ORDER BY id DESC LIMIT ?",
(max(1, min(limit, 500)),),
).fetchall()
items = []
for row in rows:
item = dict(row)
item["raw"] = _json_or_default(item.pop("raw_json", "{}"), {})
items.append(item)
return items
def pending_orders(self) -> list[dict[str, Any]]:
terminal = ("Filled", "Cancelled", "Rejected", "PartiallyFilledCanceled", "Deactivated")
placeholders = ",".join("?" for _ in terminal)
with self.connect() as conn:
rows = conn.execute(
f"SELECT * FROM orders WHERE status NOT IN ({placeholders}) ORDER BY id",
terminal,
).fetchall()
return [dict(row) for row in rows]
def prune(self, retention_days: int) -> dict[str, int]:
if retention_days <= 0:
return {}
cutoff = (utc_now() - timedelta(days=retention_days)).isoformat()
deleted: dict[str, int] = {}
for table in ("signals", "equity", "events", "llm_advice"):
with self.connect() as conn:
# Keep write locks short on large runtime databases. Each maintenance
# cycle removes at most one bounded batch per table.
cursor = conn.execute(
f"""
DELETE FROM {table}
WHERE id IN (
SELECT id FROM {table}
WHERE created_at < ?
ORDER BY id
LIMIT ?
)
""",
(cutoff, PRUNE_BATCH_SIZE),
)
deleted[table] = max(0, int(cursor.rowcount))
return deleted
def clear_all(self) -> None:
with self.connect() as conn:
for table in ("positions", "trades", "signals", "equity", "events", "runtime", "llm_advice"):
for table in ("positions", "trades", "signals", "equity", "events", "runtime", "llm_advice", "orders"):
conn.execute(f"DELETE FROM {table}")
def _signal_diagnostics_json(diagnostics: dict[str, Any]) -> str:
compact = dict(diagnostics)
forecast = compact.get("forecast")
if isinstance(forecast, dict):
compact["forecast"] = {
key: value for key, value in forecast.items() if key in _STORED_FORECAST_KEYS
}
encoded = json.dumps(compact, ensure_ascii=False, separators=(",", ":"))
size = len(encoded.encode("utf-8"))
if size <= MAX_SIGNAL_DIAGNOSTICS_BYTES:
return encoded
fallback = {
"truncated": True,
"original_size_bytes": size,
"strategy_mode": compact.get("strategy_mode"),
"trade_mode": compact.get("trade_mode"),
"checks": compact.get("checks", {}),
"forecast": compact.get("forecast", {}),
}
encoded = json.dumps(fallback, ensure_ascii=False, separators=(",", ":"))
if len(encoded.encode("utf-8")) <= MAX_SIGNAL_DIAGNOSTICS_BYTES:
return encoded
return json.dumps(
{
"truncated": True,
"original_size_bytes": size,
"strategy_mode": compact.get("strategy_mode"),
"trade_mode": compact.get("trade_mode"),
},
ensure_ascii=False,
separators=(",", ":"),
)
def _json_or_default(value: str, default: Any) -> Any:
try:
return json.loads(value)
+43 -1
View File
@@ -678,7 +678,20 @@ def _torch_forecast_entry_signal(
spread_ok = ticker.spread_percent <= settings.max_spread_percent
liquidity_ok = ticker.turnover_24h >= settings.min_24h_turnover_usdt
model_ok = _is_torch_forecast(forecast)
quality_gate_ok = forecast.get("quality_gate_passed") is not False
manual_quality_override = settings.time_series_manual_quality_override
quality_gate_ok = bool(
manual_quality_override
or (
forecast.get("quality_gate_passed") is True
if settings.time_series_require_quality_gate
else forecast.get("quality_gate_passed") is not False
)
)
model_fresh_ok = (
forecast.get("model_fresh") is True
if settings.time_series_require_fresh_model
else True
)
rebound = _torch_rebound_overlay(
settings=settings,
candles=candles or [],
@@ -697,6 +710,7 @@ def _torch_forecast_entry_signal(
rebound.get("active")
and model_ok
and quality_gate_ok
and model_fresh_ok
and bool(forecast.get("usable", False))
and not bool(forecast.get("block_entry", False))
and expected_return >= 0.0
@@ -709,6 +723,7 @@ def _torch_forecast_entry_signal(
and rebound.get("active")
and missing_torch_model
and quality_gate_ok
and model_fresh_ok
and not bool(forecast.get("block_entry", False))
and confidence >= settings.time_series_min_confidence
)
@@ -733,6 +748,7 @@ def _torch_forecast_entry_signal(
checks = {
"torch_model_ok": model_ok,
"quality_gate_ok": quality_gate_ok,
"model_fresh_ok": model_fresh_ok,
"forecast_usable": bool(forecast.get("usable", False)),
"forecast_not_blocked": not bool(forecast.get("block_entry", False)),
"expected_edge_ok": full_edge_ok or probe_edge_ok,
@@ -770,6 +786,10 @@ def _torch_forecast_entry_signal(
"skill": skill,
"quality_gate": forecast.get("quality_gate", {}),
"quality_gate_passed": forecast.get("quality_gate_passed"),
"manual_quality_override": manual_quality_override,
"model_created_at": forecast.get("model_created_at", ""),
"model_age_hours": forecast.get("model_age_hours"),
"model_fresh": forecast.get("model_fresh", False),
"spread_percent": round(ticker.spread_percent, 5),
"turnover_24h": ticker.turnover_24h,
"checks": checks,
@@ -926,6 +946,28 @@ def _torch_forecast_exit_signal(
diagnostics,
)
return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics)
if (
settings.time_series_require_quality_gate
and not settings.time_series_manual_quality_override
and forecast.get("quality_gate_passed") is not True
):
diagnostics["forecast_exit_blocked_by_quality_gate"] = True
return Signal(
position.symbol,
"HOLD",
0.42,
"torch_forecast: hold uses only risk exits while quality gate is unavailable",
diagnostics,
)
if settings.time_series_require_fresh_model and forecast.get("model_fresh") is not True:
diagnostics["forecast_exit_blocked_by_model_age"] = True
return Signal(
position.symbol,
"HOLD",
0.42,
"torch_forecast: hold uses only risk exits while model is stale",
diagnostics,
)
if not _is_torch_forecast(forecast):
if rebound_fallback_position:
hold_seconds = (utc_now() - position.opened_at).total_seconds()
+31
View File
@@ -4,6 +4,7 @@ import json
import math
from bisect import bisect_right
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import Any
from crypto_spot_bot.config import Settings
@@ -156,6 +157,9 @@ class TimeSeriesForecast:
candidates: list[dict[str, Any]] = field(default_factory=list)
quality_gate_passed: bool | None = None
quality_gate: dict[str, Any] = field(default_factory=dict)
model_created_at: str = ""
model_age_hours: float | None = None
model_fresh: bool = False
def as_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -188,6 +192,10 @@ class TimeSeriesForecaster:
return _empty_forecast(True, "not enough returns for PyTorch forecast")
artifact = self._load_lstm_artifact()
model_created_at, model_age_hours, model_fresh = _model_freshness(
artifact,
self.settings.time_series_model_max_age_hours,
)
quality_gate = self._load_quality_gate()
quality_gate_passed = _quality_gate_passed(quality_gate)
entry = _torch_recurrent_entry(symbol, artifact)
@@ -288,6 +296,9 @@ class TimeSeriesForecaster:
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
quality_gate_passed=quality_gate_passed,
quality_gate=quality_gate,
model_created_at=model_created_at,
model_age_hours=model_age_hours,
model_fresh=model_fresh,
)
direct_horizon = _is_direct_horizon(entry)
@@ -350,6 +361,9 @@ class TimeSeriesForecaster:
candidates=[{"model": model, "mae_percent": round(model_mae * 100, 4)}],
quality_gate_passed=quality_gate_passed,
quality_gate=quality_gate,
model_created_at=model_created_at,
model_age_hours=model_age_hours,
model_fresh=model_fresh,
)
def _load_lstm_artifact(self) -> dict[str, Any]:
@@ -420,6 +434,9 @@ def _empty_forecast(enabled: bool, reason: str) -> TimeSeriesForecast:
candidates=[],
quality_gate_passed=None,
quality_gate={},
model_created_at="",
model_age_hours=None,
model_fresh=False,
)
@@ -436,6 +453,20 @@ def _quality_gate_passed(quality_gate: dict[str, Any]) -> bool | None:
return None
def _model_freshness(artifact: dict[str, Any], max_age_hours: float) -> tuple[str, float | None, bool]:
raw = str(artifact.get("created_at", "")).strip() if isinstance(artifact, dict) else ""
if not raw:
return "", None, False
try:
created_at = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return raw, None, False
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=UTC)
age_hours = max(0.0, (datetime.now(UTC) - created_at.astimezone(UTC)).total_seconds() / 3600)
return raw, round(age_hours, 4), age_hours <= max(0.1, max_age_hours)
def _log_returns(closes: list[float]) -> list[float]:
return [math.log(closes[index] / closes[index - 1]) for index in range(1, len(closes))]
+232 -42
View File
@@ -4,6 +4,8 @@ import base64
import hashlib
import json
import os
import re
import shutil
import uuid
from datetime import UTC
from datetime import datetime
@@ -20,6 +22,10 @@ ALLOWED_TRAINING_ARTIFACTS = {
}
RUNNING_TIMEOUT = timedelta(hours=12)
ONLINE_WINDOW = timedelta(minutes=3)
MAX_ARTIFACT_CHUNK_BYTES = 1024 * 1024
MAX_ARTIFACT_BYTES = 64 * 1024 * 1024
MAX_ARTIFACT_CHUNKS = 1024
REQUIRED_MODEL_BUNDLE = set(ALLOWED_TRAINING_ARTIFACTS)
class TrainingCoordinator:
@@ -91,6 +97,7 @@ class TrainingCoordinator:
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]:
job_id = _valid_job_id(job_id)
name = Path(str(payload.get("name") or "")).name
if name not in ALLOWED_TRAINING_ARTIFACTS:
raise ValueError(f"artifact is not allowed: {name}")
@@ -99,58 +106,87 @@ class TrainingCoordinator:
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")
if total > MAX_ARTIFACT_CHUNKS:
raise ValueError("artifact has too many chunks")
if not re.fullmatch(r"[0-9a-f]{64}", sha256):
raise ValueError("artifact sha256 is invalid")
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
if not chunk or len(chunk) > MAX_ARTIFACT_CHUNK_BYTES:
raise ValueError("artifact chunk size is invalid")
chunk_dir = self.upload_root / job_id / name
chunk_dir.mkdir(parents=True, exist_ok=True)
(chunk_dir / f"{index:06d}.part").write_bytes(chunk)
if not all((chunk_dir / f"{part:06d}.part").is_file() for part in range(total)):
return {"complete": False, "received": index + 1, "total": total}
target_tmp = self.runtime_dir / f".{name}.{job_id}.tmp"
digest = hashlib.sha256()
with target_tmp.open("wb") as output:
for part in range(total):
data = (chunk_dir / f"{part:06d}.part").read_bytes()
digest.update(data)
output.write(data)
if digest.hexdigest().lower() != sha256:
target_tmp.unlink(missing_ok=True)
raise ValueError("artifact sha256 mismatch")
self.runtime_dir.mkdir(parents=True, exist_ok=True)
os.replace(target_tmp, self.runtime_dir / name)
_remove_tree(chunk_dir)
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is not None:
artifacts = job.setdefault("artifacts", [])
artifacts = [item for item in artifacts if item.get("name") != name]
artifacts.append({"name": name, "sha256": sha256, "uploaded_at": _now()})
job["artifacts"] = artifacts
self._save_state(state)
return {"complete": True, "name": name, "sha256": sha256}
def progress(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is None:
raise ValueError(f"training job not found: {job_id}")
if job.get("status") != "running" or not job.get("claimed_by"):
raise ValueError("training job is not claimed and running")
uploads = job.setdefault("uploads", {})
upload = uploads.setdefault(name, {"sha256": sha256, "total": total})
if upload.get("sha256") != sha256 or int(upload.get("total", 0)) != total:
raise ValueError("artifact upload metadata changed during upload")
chunk_dir = self.upload_root / job_id / "chunks" / name
chunk_dir.mkdir(parents=True, exist_ok=True)
(chunk_dir / f"{index:06d}.part").write_bytes(chunk)
received = sum(1 for part in range(total) if (chunk_dir / f"{part:06d}.part").is_file())
if received < total:
upload["received"] = received
self._save_state(state)
return {"complete": False, "received": received, "total": total}
ready_dir = self.upload_root / job_id / "ready"
ready_dir.mkdir(parents=True, exist_ok=True)
target_tmp = ready_dir / f".{name}.tmp"
digest = hashlib.sha256()
size = 0
with target_tmp.open("wb") as output:
for part in range(total):
data = (chunk_dir / f"{part:06d}.part").read_bytes()
size += len(data)
if size > MAX_ARTIFACT_BYTES:
target_tmp.unlink(missing_ok=True)
raise ValueError("artifact exceeds maximum size")
digest.update(data)
output.write(data)
if digest.hexdigest().lower() != sha256:
target_tmp.unlink(missing_ok=True)
raise ValueError("artifact sha256 mismatch")
target = ready_dir / name
os.replace(target_tmp, target)
_remove_tree(chunk_dir)
artifacts = job.setdefault("artifacts", [])
artifacts = [item for item in artifacts if item.get("name") != name]
artifacts.append(
{"name": name, "sha256": sha256, "size": size, "staged_at": _now()}
)
job["artifacts"] = artifacts
upload["received"] = total
upload["complete"] = True
self._save_state(state)
return {"complete": True, "staged": True, "name": name, "sha256": sha256}
def progress(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
job_id = _valid_job_id(job_id)
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is None:
raise ValueError(f"training job not found: {job_id}")
if job.get("status") != "running" or not job.get("claimed_by"):
raise ValueError("training job is not claimed and running")
if isinstance(payload.get("worker"), dict):
state["worker"] = self._worker_from_payload(payload["worker"])
job["status"] = str(payload.get("status") or job.get("status") or "running")
job["phase"] = str(payload.get("phase") or job.get("phase") or "running")
job["message"] = str(payload.get("message") or job.get("message") or "")
job["status"] = "running"
job["phase"] = str(payload.get("phase") or job.get("phase") or "running")[:80]
job["message"] = str(payload.get("message") or job.get("message") or "")[:2000]
job["progress_percent"] = _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
job["updated_at"] = _now()
if isinstance(payload.get("details"), dict):
@@ -160,12 +196,18 @@ class TrainingCoordinator:
def complete(self, job_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
payload = payload or {}
job_id = _valid_job_id(job_id)
with self._lock:
state = self._load_state()
job = self._job_by_id(state, job_id)
if job is None:
raise ValueError(f"training job not found: {job_id}")
if job.get("status") != "running" or not job.get("claimed_by"):
raise ValueError("training job is not claimed and running")
success = bool(payload.get("success", payload.get("status") == "completed"))
if success and job.get("artifacts"):
promoted = self._validate_and_promote(job_id, job)
job["promoted_artifacts"] = promoted
job["status"] = "completed" if success else "failed"
job["phase"] = "completed" if success else "failed"
job["progress_percent"] = 100 if success else _coerce_percent(payload.get("progress_percent"), job.get("progress_percent", 0))
@@ -176,6 +218,65 @@ class TrainingCoordinator:
self._save_state(state)
return {"ok": True, "job": job, "status": self._public_status(state)}
def _validate_and_promote(self, job_id: str, job: dict[str, Any]) -> list[dict[str, Any]]:
ready_dir = self.upload_root / job_id / "ready"
staged = {path.name for path in ready_dir.iterdir() if path.is_file()} if ready_dir.is_dir() else set()
missing = REQUIRED_MODEL_BUNDLE - staged
if missing:
raise ValueError("training bundle is incomplete: " + ", ".join(sorted(missing)))
model = _read_json(ready_dir / "lstm_forecaster.json")
guard = _read_json(ready_dir / "torch_retrain_guard.json")
calibration = _read_json(ready_dir / "torch_threshold_calibration.json")
if model.get("type") != "pytorch_recurrent_forecaster":
raise ValueError("candidate model type is invalid")
symbols = model.get("symbols")
if not isinstance(symbols, dict) or not symbols:
raise ValueError("candidate model has no symbol models")
_validate_symbol_models(symbols)
model_sha256 = hashlib.sha256((ready_dir / "lstm_forecaster.json").read_bytes()).hexdigest()
if calibration.get("artifact_sha256") != model_sha256:
raise ValueError("candidate calibration is not bound to the uploaded model")
if not bool(guard.get("accepted")):
raise ValueError("candidate retrain guard did not accept the model")
if guard.get("candidate_artifact_sha256") != model_sha256:
raise ValueError("candidate guard is not bound to the uploaded model")
validation = calibration.get("validation")
if not isinstance(validation, dict) or not _validation_passed(validation):
raise ValueError("candidate quality gate did not pass")
if validation.get("protocol") != "untouched_model_holdout_with_threshold_walk_forward":
raise ValueError("candidate validation protocol is not an untouched holdout")
self.runtime_dir.mkdir(parents=True, exist_ok=True)
backup_dir = self.runtime_dir / ".model_backups" / f"{_compact_now()}-{job_id}"
backup_dir.mkdir(parents=True, exist_ok=True)
for name in sorted(REQUIRED_MODEL_BUNDLE):
current = self.runtime_dir / name
if current.is_file():
shutil.copy2(current, backup_dir / name)
promoted: list[dict[str, Any]] = []
artifact_rows = {
str(item.get("name")): item
for item in job.get("artifacts", [])
if isinstance(item, dict)
}
for name in sorted(REQUIRED_MODEL_BUNDLE):
staged_path = ready_dir / name
target_tmp = self.runtime_dir / f".{name}.{job_id}.promote"
shutil.copy2(staged_path, target_tmp)
os.replace(target_tmp, self.runtime_dir / name)
row = artifact_rows.get(name, {})
promoted.append(
{
"name": name,
"sha256": row.get("sha256", ""),
"promoted_at": _now(),
}
)
_remove_tree(self.upload_root / job_id)
return promoted
def _load_state(self) -> dict[str, Any]:
try:
data = json.loads(self.state_path.read_text(encoding="utf-8"))
@@ -262,8 +363,97 @@ class TrainingCoordinator:
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}
allowed = {
"symbols",
"limit",
"lookbacks",
"architectures",
"hidden_sizes",
"layers",
"dropouts",
"epochs",
"holdout_window",
"resume_candidate",
}
result = {key: value[key] for key in allowed if key in value}
for key, low, high in (
("limit", 500, 5000),
("epochs", 1, 200),
("holdout_window", 64, 1000),
):
if key not in result:
continue
try:
result[key] = max(low, min(high, int(result[key])))
except (TypeError, ValueError):
result.pop(key, None)
if "symbols" in result:
symbols = [
item.strip().upper()
for item in str(result["symbols"]).split(",")
if re.fullmatch(r"[A-Z0-9]{3,20}", item.strip().upper())
]
result["symbols"] = ",".join(symbols[:30])
if "architectures" in result:
architectures = [
item.strip().lower()
for item in str(result["architectures"]).split(",")
if item.strip().lower() in {"lstm", "gru"}
]
result["architectures"] = ",".join(architectures) or "lstm,gru"
for key in ("lookbacks", "hidden_sizes", "layers", "dropouts"):
if key in result:
result[key] = str(result[key])[:200]
if "resume_candidate" in result:
result["resume_candidate"] = result["resume_candidate"] is True
return result
def _valid_job_id(value: str) -> str:
try:
return str(uuid.UUID(str(value)))
except (ValueError, AttributeError, TypeError) as exc:
raise ValueError("invalid training job id") from exc
def _read_json(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"invalid training artifact: {path.name}") from exc
if not isinstance(data, dict):
raise ValueError(f"invalid training artifact: {path.name}")
return data
def _validation_passed(validation: dict[str, Any]) -> bool:
if "passed" in validation:
return bool(validation.get("passed"))
return str(validation.get("status", "")).strip().lower() in {"pass", "passed", "ok"}
def _validate_symbol_models(symbols: dict[str, Any]) -> None:
for symbol, entry in symbols.items():
if not isinstance(entry, dict):
raise ValueError(f"candidate model entry is invalid: {symbol}")
if entry.get("model") not in {"torch_lstm", "torch_gru"}:
raise ValueError(f"candidate model architecture is invalid: {symbol}")
try:
lookback = int(entry.get("lookback", 0))
input_size = int(entry.get("input_size", 0))
hidden_size = int(entry.get("hidden_size", 0))
except (TypeError, ValueError) as exc:
raise ValueError(f"candidate model dimensions are invalid: {symbol}") from exc
if not 4 <= lookback <= 512 or not 1 <= input_size <= 256 or not 1 <= hidden_size <= 1024:
raise ValueError(f"candidate model dimensions are out of range: {symbol}")
if not isinstance(entry.get("state_dict"), dict):
raise ValueError(f"candidate recurrent state is missing: {symbol}")
if not isinstance(entry.get("head_weight"), list) or not isinstance(entry.get("head_bias"), list):
raise ValueError(f"candidate forecast head is missing: {symbol}")
def _compact_now() -> str:
return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
def _latest_job(state: dict[str, Any]) -> dict[str, Any] | None:
+10 -1
View File
@@ -6,12 +6,21 @@ services:
- .env
environment:
HOST: 0.0.0.0
PYTHONDONTWRITEBYTECODE: "1"
user: "1000:1000"
init: true
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
ports:
- "127.0.0.1:8787:8787"
volumes:
- ./.env:/app/.env
- ./.env:/app/.env:ro
- ./runtime:/app/runtime
tmpfs:
- /tmp:size=64m,mode=1777
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=5).read()"]
interval: 30s
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest==8.4.2
-1
View File
@@ -2,4 +2,3 @@ fastapi==0.115.6
uvicorn[standard]==0.34.0
requests==2.32.3
websockets==14.1
pytest==8.4.2
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
BTCUSDT: loaded 2000 60 candles
ETHUSDT: loaded 2000 60 candles
LTCUSDT: loaded 2000 60 candles
SOLUSDT: loaded 2000 60 candles
BTCUSDT: replay records 720
ETHUSDT: replay records 720
SOLUSDT: replay records 720
LTCUSDT: replay records 720
records_by_symbol {"BTCUSDT": 720, "ETHUSDT": 720, "LTCUSDT": 720, "SOLUSDT": 720}
artifact {"created_at": "2026-06-23T19:07:54.434411+00:00", "feature_count": 55, "symbols": {"BTCUSDT": {"directional_accuracy": 0.725, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.15903346077183758}, "ETHUSDT": {"directional_accuracy": 0.6916666666666667, "hidden_size": 64, "lookback": 64, "model": "torch_gru", "skill": 0.09273757527902074}, "LTCUSDT": {"directional_accuracy": 0.6583333333333333, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.11954702418314447}, "SOLUSDT": {"directional_accuracy": 0.6416666666666667, "hidden_size": 96, "lookback": 64, "model": "torch_gru", "skill": 0.03400498728351002}}, "target_horizon": 3, "target_horizons": [1, 3, 6, 12], "target_transform": "net_return_over_volatility", "version": 4}
TOP_RESULTS
edge=0.1000 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.6000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.5600 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.1000 prob=0.6200 conf=0.5000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.6000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.5600 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0800 prob=0.6200 conf=0.5000 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0600 prob=0.6200 conf=0.7200 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0600 prob=0.6200 conf=0.6800 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
edge=0.0600 prob=0.6200 conf=0.6400 trades=9 win=0.333 avg=0.8147% total=7.3321% dd=1.4888% pf=3.912 score=0.6897
RECOMMENDED
edge=0.1000 prob=0.6000 conf=0.6800 trades=16 win=0.562 avg=0.4948% total=7.9171% dd=1.8130% pf=3.629 score=0.5817
FULL_REPLAY
trades=5 win=1.000 avg=1.9149% total=9.5746% dd=0.0000% pf=999.000
WALK_FORWARD
{"avg_net_percent": 0.4783, "max_drawdown_percent": 1.3024, "profit_factor": 3.471, "status": "ok", "total_net_percent": 7.1747, "trades": 15, "win_rate": 0.5333, "wins": 8}
env TIME_SERIES_MIN_EDGE_PERCENT=0.1000 TIME_SERIES_MIN_PROBABILITY_UP=0.6000 TIME_SERIES_MIN_CONFIDENCE=0.6800
File diff suppressed because it is too large Load Diff
-515
View File
@@ -1,515 +0,0 @@
{
"artifact": {
"version": 4,
"created_at": "2026-06-23T19:07:54.434411+00:00",
"feature_count": 55,
"target_horizon": 3,
"target_horizons": [
1,
3,
6,
12
],
"target_transform": "net_return_over_volatility",
"symbols": {
"BTCUSDT": {
"model": "torch_gru",
"lookback": 64,
"hidden_size": 96,
"skill": 0.15903346077183758,
"directional_accuracy": 0.725
},
"ETHUSDT": {
"model": "torch_gru",
"lookback": 64,
"hidden_size": 64,
"skill": 0.09273757527902074,
"directional_accuracy": 0.6916666666666667
},
"SOLUSDT": {
"model": "torch_gru",
"lookback": 64,
"hidden_size": 96,
"skill": 0.03400498728351002,
"directional_accuracy": 0.6416666666666667
},
"LTCUSDT": {
"model": "torch_gru",
"lookback": 64,
"hidden_size": 96,
"skill": 0.11954702418314447,
"directional_accuracy": 0.6583333333333333
}
}
},
"records_by_symbol": {
"BTCUSDT": 720,
"ETHUSDT": 720,
"SOLUSDT": 720,
"LTCUSDT": 720
},
"recommended": {
"edge": 0.1,
"probability": 0.52,
"confidence": 0.72,
"trades": 30,
"wins": 17,
"win_rate": 0.5666666666666667,
"total_net_percent": 12.82679871911413,
"average_net_percent": 0.42755995730380436,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.2963631842987433,
"score": 0.5882388552951857
},
"full_replay": {
"trades": 8,
"wins": 8,
"win_rate": 1.0,
"total_net_percent": 21.0484,
"avg_net_percent": 2.631,
"max_drawdown_percent": 0.0,
"profit_factor": 999.0,
"trades_detail": [
{
"symbol": "ETHUSDT",
"entry_timestamp": 1779832800000,
"exit_timestamp": 1779868800000,
"net_percent": 0.5281,
"reason": "forecast_weak_profit_lock",
"held_bars": 10,
"entry_probability": 0.5747,
"entry_expected_percent": 0.3453
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1779940800000,
"exit_timestamp": 1779984000000,
"net_percent": 1.3185,
"reason": "forecast_weak_profit_lock",
"held_bars": 12,
"entry_probability": 0.6018,
"entry_expected_percent": 0.3155
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1780300800000,
"exit_timestamp": 1780347600000,
"net_percent": 0.2591,
"reason": "forecast_weak_profit_lock",
"held_bars": 13,
"entry_probability": 0.6164,
"entry_expected_percent": 0.3215
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1780768800000,
"exit_timestamp": 1780855200000,
"net_percent": 4.4581,
"reason": "max_hold",
"held_bars": 24,
"entry_probability": 0.5632,
"entry_expected_percent": 0.5685
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1780862400000,
"exit_timestamp": 1780869600000,
"net_percent": 2.4609,
"reason": "forecast_weak_profit_lock",
"held_bars": 2,
"entry_probability": 0.5368,
"entry_expected_percent": 0.4055
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1781139600000,
"exit_timestamp": 1781204400000,
"net_percent": 2.0707,
"reason": "forecast_weak_profit_lock",
"held_bars": 18,
"entry_probability": 0.5775,
"entry_expected_percent": 0.3013
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1781445600000,
"exit_timestamp": 1781532000000,
"net_percent": 9.0305,
"reason": "max_hold",
"held_bars": 24,
"entry_probability": 0.6014,
"entry_expected_percent": 0.2946
},
{
"symbol": "ETHUSDT",
"entry_timestamp": 1781892000000,
"exit_timestamp": 1781942400000,
"net_percent": 0.9224,
"reason": "forecast_weak_profit_lock",
"held_bars": 14,
"entry_probability": 0.5966,
"entry_expected_percent": 0.2647
}
]
},
"walk_forward": {
"summary": {
"trades": 16,
"wins": 8,
"win_rate": 0.5,
"total_net_percent": 6.8682,
"avg_net_percent": 0.4293,
"max_drawdown_percent": 1.3024,
"profit_factor": 3.1396,
"status": "warn"
},
"folds": [
{
"fold": 1,
"train_records": 720,
"test_records": 720,
"thresholds": {
"edge": 0.1,
"probability": 0.6,
"confidence": 0.72,
"trades": 2,
"wins": 1,
"win_rate": 0.5,
"total_net_percent": -0.10348384852443271,
"average_net_percent": -0.051741924262216354,
"max_drawdown_percent": 0.5106090484004788,
"profit_factor": 0.7973325211360753,
"score": -0.0037694524148430344
},
"test": {
"trades": 4,
"wins": 2,
"win_rate": 0.5,
"total_net_percent": 0.0557,
"avg_net_percent": 0.0139,
"max_drawdown_percent": 1.3024,
"profit_factor": 1.0428
}
},
{
"fold": 2,
"train_records": 1440,
"test_records": 720,
"thresholds": {
"edge": 0.1,
"probability": 0.52,
"confidence": 0.72,
"trades": 18,
"wins": 11,
"win_rate": 0.6111111111111112,
"total_net_percent": 6.01434645293809,
"average_net_percent": 0.3341303584965606,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 2.6352078385564366,
"score": 0.3944002502730791
},
"test": {
"trades": 11,
"wins": 6,
"win_rate": 0.5455,
"total_net_percent": 7.119,
"avg_net_percent": 0.6472,
"max_drawdown_percent": 1.0894,
"profit_factor": 5.4462
}
},
{
"fold": 3,
"train_records": 2160,
"test_records": 720,
"thresholds": {
"edge": 0.1,
"probability": 0.52,
"confidence": 0.72,
"trades": 29,
"wins": 17,
"win_rate": 0.5862068965517241,
"total_net_percent": 13.13332018590817,
"average_net_percent": 0.4528731098589024,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.48775770371021,
"score": 0.6189314390475966
},
"test": {
"trades": 1,
"wins": 0,
"win_rate": 0.0,
"total_net_percent": -0.3065,
"avg_net_percent": -0.3065,
"max_drawdown_percent": 0.3065,
"profit_factor": 0.0
}
}
]
},
"probability_calibration": {
"samples": 2880,
"buckets": [
{
"bucket": "0.30-0.35",
"samples": 38,
"avg_probability": 0.336,
"actual_win_rate": 0.1053,
"avg_future_net_percent": -0.6575
},
{
"bucket": "0.35-0.40",
"samples": 418,
"avg_probability": 0.3839,
"actual_win_rate": 0.2225,
"avg_future_net_percent": -0.6359
},
{
"bucket": "0.40-0.45",
"samples": 1065,
"avg_probability": 0.427,
"actual_win_rate": 0.2873,
"avg_future_net_percent": -0.4036
},
{
"bucket": "0.45-0.50",
"samples": 911,
"avg_probability": 0.4746,
"actual_win_rate": 0.3271,
"avg_future_net_percent": -0.3061
},
{
"bucket": "0.50-0.55",
"samples": 290,
"avg_probability": 0.5188,
"actual_win_rate": 0.3966,
"avg_future_net_percent": -0.0583
},
{
"bucket": "0.55-0.60",
"samples": 104,
"avg_probability": 0.5758,
"actual_win_rate": 0.4327,
"avg_future_net_percent": 0.0173
},
{
"bucket": "0.60-0.65",
"samples": 42,
"avg_probability": 0.6138,
"actual_win_rate": 0.4762,
"avg_future_net_percent": -0.0041
},
{
"bucket": "0.65-0.70",
"samples": 6,
"avg_probability": 0.6679,
"actual_win_rate": 0.3333,
"avg_future_net_percent": 0.6587
},
{
"bucket": "0.70-0.75",
"samples": 6,
"avg_probability": 0.7103,
"actual_win_rate": 0.8333,
"avg_future_net_percent": 2.1268
}
]
},
"top_results": [
{
"edge": 0.1,
"probability": 0.52,
"confidence": 0.72,
"trades": 30,
"wins": 17,
"win_rate": 0.5666666666666667,
"total_net_percent": 12.82679871911413,
"average_net_percent": 0.42755995730380436,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.2963631842987433,
"score": 0.5882388552951857
},
{
"edge": 0.1,
"probability": 0.5,
"confidence": 0.72,
"trades": 30,
"wins": 17,
"win_rate": 0.5666666666666667,
"total_net_percent": 12.82679871911413,
"average_net_percent": 0.42755995730380436,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.2963631842987433,
"score": 0.5882388552951857
},
{
"edge": 0.1,
"probability": 0.52,
"confidence": 0.68,
"trades": 38,
"wins": 19,
"win_rate": 0.5,
"total_net_percent": 13.314209250896504,
"average_net_percent": 0.35037392765517117,
"max_drawdown_percent": 2.2311766078638495,
"profit_factor": 2.618335500149353,
"score": 0.5031517681827032
},
{
"edge": 0.1,
"probability": 0.5,
"confidence": 0.68,
"trades": 38,
"wins": 19,
"win_rate": 0.5,
"total_net_percent": 13.314209250896504,
"average_net_percent": 0.35037392765517117,
"max_drawdown_percent": 2.2311766078638495,
"profit_factor": 2.618335500149353,
"score": 0.5031517681827032
},
{
"edge": 0.08,
"probability": 0.52,
"confidence": 0.64,
"trades": 56,
"wins": 28,
"win_rate": 0.5,
"total_net_percent": 17.433321755380405,
"average_net_percent": 0.31130931706036435,
"max_drawdown_percent": 3.6509791332801522,
"profit_factor": 2.1428339375966368,
"score": 0.4832797693926659
},
{
"edge": 0.06,
"probability": 0.52,
"confidence": 0.68,
"trades": 56,
"wins": 28,
"win_rate": 0.5,
"total_net_percent": 17.433321755380405,
"average_net_percent": 0.31130931706036435,
"max_drawdown_percent": 3.6509791332801522,
"profit_factor": 2.1428339375966368,
"score": 0.4832797693926659
},
{
"edge": 0.05,
"probability": 0.52,
"confidence": 0.68,
"trades": 60,
"wins": 29,
"win_rate": 0.48333333333333334,
"total_net_percent": 16.9130381260749,
"average_net_percent": 0.281883968767915,
"max_drawdown_percent": 3.7288680658046136,
"profit_factor": 1.9655257883521706,
"score": 0.44304683201823336
},
{
"edge": 0.08,
"probability": 0.52,
"confidence": 0.72,
"trades": 38,
"wins": 16,
"win_rate": 0.42105263157894735,
"total_net_percent": 12.356704347142244,
"average_net_percent": 0.3251764301879538,
"max_drawdown_percent": 2.826650409116027,
"profit_factor": 2.4329654894966497,
"score": 0.44256958838476457
},
{
"edge": 0.08,
"probability": 0.5,
"confidence": 0.72,
"trades": 38,
"wins": 16,
"win_rate": 0.42105263157894735,
"total_net_percent": 12.356704347142244,
"average_net_percent": 0.3251764301879538,
"max_drawdown_percent": 2.826650409116027,
"profit_factor": 2.4329654894966497,
"score": 0.44256958838476457
},
{
"edge": 0.1,
"probability": 0.52,
"confidence": 0.6,
"trades": 59,
"wins": 28,
"win_rate": 0.4745762711864407,
"total_net_percent": 16.704033568694644,
"average_net_percent": 0.2831192130287228,
"max_drawdown_percent": 3.9030543543860263,
"profit_factor": 2.053895519143829,
"score": 0.4355711367750193
},
{
"edge": 0.1,
"probability": 0.54,
"confidence": 0.72,
"trades": 29,
"wins": 16,
"win_rate": 0.5517241379310345,
"total_net_percent": 9.266858120167019,
"average_net_percent": 0.3195468317298972,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 2.659032178431275,
"score": 0.41557735852998334
},
{
"edge": 0.1,
"probability": 0.55,
"confidence": 0.72,
"trades": 25,
"wins": 14,
"win_rate": 0.56,
"total_net_percent": 9.16979950649479,
"average_net_percent": 0.3667919802597916,
"max_drawdown_percent": 1.812991648733242,
"profit_factor": 3.155095090386423,
"score": 0.41121722668525085
},
{
"edge": 0.08,
"probability": 0.5,
"confidence": 0.64,
"trades": 57,
"wins": 28,
"win_rate": 0.49122807017543857,
"total_net_percent": 15.383105036720245,
"average_net_percent": 0.2698790357319341,
"max_drawdown_percent": 3.6509791332801522,
"profit_factor": 1.8889561886320847,
"score": 0.4107453600913507
},
{
"edge": 0.06,
"probability": 0.5,
"confidence": 0.68,
"trades": 57,
"wins": 28,
"win_rate": 0.49122807017543857,
"total_net_percent": 15.383105036720245,
"average_net_percent": 0.2698790357319341,
"max_drawdown_percent": 3.6509791332801522,
"profit_factor": 1.8889561886320847,
"score": 0.4107453600913507
},
{
"edge": 0.1,
"probability": 0.55,
"confidence": 0.68,
"trades": 32,
"wins": 16,
"win_rate": 0.5,
"total_net_percent": 9.83610967545454,
"average_net_percent": 0.3073784273579544,
"max_drawdown_percent": 2.2311766078638495,
"profit_factor": 2.5019571623752666,
"score": 0.40798477425385704
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
BTCUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.47001% baseline=0.55889% skill=0.1590 dir=0.725 p_brier=0.2443
ETHUSDT: model=torch_gru lookback=64 features=55 hidden=64 layers=2 horizons=1,3,6,12 mae=0.63328% baseline=0.69801% skill=0.0927 dir=0.692 p_brier=0.2239
SOLUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.85491% baseline=0.88500% skill=0.0340 dir=0.642 p_brier=0.2308
LTCUSDT: model=torch_gru lookback=64 features=55 hidden=96 layers=2 horizons=1,3,6,12 mae=0.57185% baseline=0.64949% skill=0.1195 dir=0.658 p_brier=0.2369
saved G:\Repos\TradeBot\runtime\lstm_forecaster.candidate.json
View File
Binary file not shown.
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import asyncio
import base64
import pytest
from fastapi import HTTPException, Request
from crypto_spot_bot.auth import ApiAuthorizer
def _request(**headers: str) -> Request:
encoded = [(key.lower().encode(), value.encode()) for key, value in headers.items()]
return Request({"type": "http", "method": "GET", "path": "/", "headers": encoded})
def test_api_authorizer_accepts_direct_basic_token(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, api_auth_token="user:secret")
auth = ApiAuthorizer(settings)
basic = base64.b64encode(b"user:secret").decode("ascii")
asyncio.run(auth.require(_request(Authorization=f"Basic {basic}")))
def test_api_authorizer_accepts_trusted_proxy_header(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, trusted_proxy_user_header="X-TradeBot-Proxy-User")
auth = ApiAuthorizer(settings)
asyncio.run(auth.require(_request(**{"X-TradeBot-Proxy-User": "sevenhill"})))
def test_api_authorizer_rejects_missing_credentials(make_settings, tmp_path) -> None:
auth = ApiAuthorizer(make_settings(tmp_path))
with pytest.raises(HTTPException) as raised:
asyncio.run(auth.require(_request()))
assert raised.value.status_code == 401
+17
View File
@@ -154,3 +154,20 @@ def test_auto_select_uses_empty_symbol_list(tmp_path, monkeypatch) -> None:
assert settings.auto_select_symbols is True
assert settings.top_symbols_count == 12
assert settings.symbols == ()
def test_load_settings_rejects_inconsistent_exposure_limits(tmp_path, monkeypatch) -> None:
for key in (
"MIN_POSITION_USDT",
"MAX_SYMBOL_EXPOSURE_USDT",
"MAX_TOTAL_EXPOSURE_USDT",
):
monkeypatch.delenv(key, raising=False)
env_file = tmp_path / ".env"
env_file.write_text(
"MIN_POSITION_USDT=10\nMAX_SYMBOL_EXPOSURE_USDT=5\nMAX_TOTAL_EXPOSURE_USDT=20\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="MAX_SYMBOL_EXPOSURE_USDT"):
load_settings(env_file)
+106 -1
View File
@@ -4,7 +4,7 @@ from types import SimpleNamespace
from crypto_spot_bot.bybit import Instrument
from crypto_spot_bot.bot import CryptoSpotBot
from crypto_spot_bot.execution import PaperBroker
from crypto_spot_bot.execution import LiveBroker, PaperBroker
from crypto_spot_bot.models import Signal, Ticker
from crypto_spot_bot.storage import Storage
from crypto_spot_bot.strategy import SpotStrategy
@@ -319,3 +319,108 @@ def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_sett
assert trade["side"] == "SELL"
assert trade["symbol"] == "HYPEUSDT"
assert "trend_macd" in trade["reason"]
def test_live_broker_records_exchange_fill_and_protective_stop(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
trading_mode="live",
enable_live_trading=True,
live_trading_confirm="I_ACCEPT_REAL_RISK",
bybit_api_key="key",
bybit_api_secret="secret",
live_protective_stop_enabled=True,
)
storage = Storage(settings.database_path)
class Client:
def place_spot_market_order(self, **kwargs):
return {"orderId": "buy-1"}
def wait_for_spot_order(self, **kwargs):
return {
"order": {"orderStatus": "Filled"},
"executions": [
{
"symbol": "BTCUSDT",
"execQty": "0.001",
"execValue": "10",
"execPrice": "10000",
"execFee": "0.01",
"feeCurrency": "USDT",
}
],
}
def place_spot_protective_stop(self, **kwargs):
return {"orderId": "stop-1"}
broker = LiveBroker(settings, storage, Client())
broker.reconciliation_state = {"status": "ok", "blocking": False, "discrepancies": []}
ticker = Ticker("BTCUSDT", 10000, 9999, 10001, 10_000_000, 1000, 0)
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 5)
signal = Signal("BTCUSDT", "BUY", 0.8, "test", {"position_notional_usdt": 10})
position = broker.buy(signal, ticker, instrument, {"BTCUSDT": 10000})
assert position is not None
assert position.qty == 0.001
assert position.entry_price == 10000
assert position.protective_order_id == "stop-1"
assert storage.recent_orders()[0]["order_kind"] == "PROTECTIVE_STOP"
def test_live_broker_sell_uses_confirmed_exchange_fill(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
trading_mode="live",
enable_live_trading=True,
live_trading_confirm="I_ACCEPT_REAL_RISK",
bybit_api_key="key",
bybit_api_secret="secret",
live_protective_stop_enabled=False,
)
storage = Storage(settings.database_path)
class Client:
def place_spot_market_order(self, **kwargs):
return {"orderId": "buy-1" if kwargs["side"] == "Buy" else "sell-1"}
def wait_for_spot_order(self, **kwargs):
buying = kwargs["order_id"] == "buy-1"
return {
"order": {"orderStatus": "Filled"},
"executions": [
{
"symbol": "BTCUSDT",
"execQty": "0.001",
"execValue": "10" if buying else "11",
"execPrice": "10000" if buying else "11000",
"execFee": "0.01",
"feeCurrency": "USDT",
}
],
}
broker = LiveBroker(settings, storage, Client())
broker.reconciliation_state = {"status": "ok", "blocking": False, "discrepancies": []}
instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 5)
entry_ticker = Ticker("BTCUSDT", 10000, 9999, 10001, 10_000_000, 1000, 0)
position = broker.buy(
Signal("BTCUSDT", "BUY", 0.8, "test", {"position_notional_usdt": 10}),
entry_ticker,
instrument,
{"BTCUSDT": 10000},
)
assert position is not None
trade = broker.sell(
position,
Ticker("BTCUSDT", 11000, 10999, 11001, 10_000_000, 1000, 0),
"test exit",
)
assert trade.exit_price == 11000
assert trade.qty == 0.001
assert broker.open_positions() == []
assert storage.recent_orders()[0]["status"] == "Filled"
+8 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from crypto_spot_bot.market_data import _closed_candles, _is_closed_kline_row
from crypto_spot_bot.market_data import _candles_due, _closed_candles, _is_closed_kline_row
from crypto_spot_bot.models import Candle
@@ -19,3 +19,10 @@ def test_closed_candles_excludes_current_open_interval() -> None:
def test_websocket_kline_requires_confirmed_candle() -> None:
assert _is_closed_kline_row({"start": 7_200_000, "confirm": False}, "60") is False
assert _is_closed_kline_row({"start": 7_200_000, "confirm": True}, "60") is True
def test_rest_candles_refresh_only_after_next_bar_closes() -> None:
candle = Candle(10 * 60_000, 1, 1, 1, 1, 1)
assert _candles_due([candle], "1", now_ms=11 * 60_000 + 30_000) is False
assert _candles_due([candle], "1", now_ms=12 * 60_000) is True
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
import json
from datetime import timedelta
from crypto_spot_bot.models import Signal, utc_now
from crypto_spot_bot.storage import MAX_SIGNAL_DIAGNOSTICS_BYTES, PRUNE_BATCH_SIZE, Storage
def test_hold_sampling_is_independent_for_each_reason_and_diagnostics_are_bounded(tmp_path) -> None:
storage = Storage(tmp_path / "tradebot.sqlite3")
diagnostics = {
"strategy_mode": "torch_forecast",
"checks": {"model_fresh_ok": False},
"forecast": {
"model": "torch_lstm",
"expected_return_percent": 0.42,
"model_fresh": False,
"feature_snapshot": [
{"name": f"feature-{index}", "interpretation": "x" * 1000}
for index in range(100)
],
},
}
first = Signal("BTCUSDT", "HOLD", 0.2, "entry blocked", diagnostics)
second = Signal("BTCUSDT", "HOLD", 0.2, "position held", diagnostics)
assert storage.insert_signal(first, hold_sample_seconds=60) is True
assert storage.insert_signal(second, hold_sample_seconds=60) is True
assert storage.insert_signal(first, hold_sample_seconds=60) is False
rows = storage.recent_signals(10)
assert len(rows) == 2
stored = json.loads(rows[0]["diagnostics_json"])
assert len(rows[0]["diagnostics_json"].encode("utf-8")) <= MAX_SIGNAL_DIAGNOSTICS_BYTES
assert stored["forecast"]["model"] == "torch_lstm"
assert "feature_snapshot" not in stored["forecast"]
def test_prune_deletes_only_one_bounded_batch_per_table(tmp_path) -> None:
storage = Storage(tmp_path / "tradebot.sqlite3")
old_timestamp = (utc_now() - timedelta(days=90)).isoformat()
rows = [
("BTCUSDT", "HOLD", 0.0, "old", "{}", old_timestamp)
for _ in range(PRUNE_BATCH_SIZE + 5)
]
with storage.connect() as conn:
conn.executemany(
"""
INSERT INTO signals (symbol, action, confidence, reason, diagnostics_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
rows,
)
deleted = storage.prune(30)
assert deleted["signals"] == PRUNE_BATCH_SIZE
assert len(storage.recent_signals(PRUNE_BATCH_SIZE + 10)) == 5
+37
View File
@@ -566,6 +566,43 @@ def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> N
assert signal.diagnostics["checks"]["quality_gate_ok"] is False
def test_torch_forecast_allows_explicit_manual_quality_override(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
time_series_require_quality_gate=True,
time_series_manual_quality_override=True,
time_series_min_edge_percent=0.10,
time_series_min_probability_up=0.57,
max_position_usdt=25,
stop_loss_percent=0.04,
)
strategy = SpotStrategy(settings)
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
signal = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=0,
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.36,
"probability_up": 0.66,
"skill": 0.22,
"block_entry": False,
"quality_gate_passed": False,
"quality_gate": {"status": "fail"},
},
account={"equity": 100.0},
)
assert signal.action == "BUY"
assert signal.diagnostics["checks"]["quality_gate_ok"] is True
assert signal.diagnostics["manual_quality_override"] is True
def test_torch_forecast_probe_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
+5 -1
View File
@@ -14,7 +14,11 @@ def _report(*, validation_passed: bool = True, trades: int = 30, total: float =
"max_drawdown_percent": 1.0,
},
"walk_forward": {"summary": {"trades": trades, "avg_net_percent": 0.3}},
"validation": {"passed": validation_passed, "status": "pass" if validation_passed else "fail"},
"validation": {
"passed": validation_passed,
"status": "pass" if validation_passed else "fail",
"protocol": "untouched_model_holdout_with_threshold_walk_forward",
},
}
+85 -1
View File
@@ -4,6 +4,8 @@ import base64
import hashlib
import json
import pytest
from crypto_spot_bot.training_coordination import TrainingCoordinator
@@ -36,9 +38,20 @@ def test_training_coordinator_claims_and_completes_job(tmp_path) -> None:
assert coordinator.status()["active_job"] is None
def test_training_coordinator_preserves_boolean_resume_candidate_parameter(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
requested = coordinator.request_retrain(
{"source": "recovery", "parameters": {"resume_candidate": True}}
)
assert requested["job"]["parameters"] == {"resume_candidate": True}
def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
job = coordinator.request_retrain({"source": "test"})["job"]
coordinator.claim({"worker_id": "test-worker"})
payload = b'{"type":"pytorch_recurrent_forecaster","symbols":{}}\n'
sha256 = hashlib.sha256(payload).hexdigest()
first = payload[:20]
@@ -67,7 +80,8 @@ def test_training_coordinator_accepts_chunked_artifact_upload(tmp_path) -> None:
assert part_1["complete"] is False
assert part_2["complete"] is True
assert (tmp_path / "lstm_forecaster.json").read_bytes() == payload
assert not (tmp_path / "lstm_forecaster.json").exists()
assert (tmp_path / ".training_uploads" / job["id"] / "ready" / "lstm_forecaster.json").read_bytes() == payload
assert coordinator.status()["latest_job"]["artifacts"][0]["sha256"] == sha256
@@ -86,3 +100,73 @@ def test_running_claimed_job_keeps_agent_online_when_heartbeat_is_stale(tmp_path
assert status["agent_recently_seen"] is False
assert status["agent_busy"] is True
assert status["agent_online"] is True
def test_training_upload_rejects_unknown_job(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
payload = b"{}"
with pytest.raises(ValueError, match="not found"):
coordinator.save_artifact_chunk(
"11111111-1111-4111-8111-111111111111",
{
"name": "lstm_forecaster.json",
"index": 0,
"total": 1,
"sha256": hashlib.sha256(payload).hexdigest(),
"data_base64": base64.b64encode(payload).decode("ascii"),
},
)
def test_training_bundle_promotes_only_after_successful_guard(tmp_path) -> None:
coordinator = TrainingCoordinator(tmp_path)
job = coordinator.request_retrain({"source": "test"})["job"]
coordinator.claim({"worker_id": "worker-1"})
model = {
"type": "pytorch_recurrent_forecaster",
"symbols": {
"BTCUSDT": {
"model": "torch_gru",
"lookback": 4,
"input_size": 1,
"hidden_size": 1,
"state_dict": {"weight_ih_l0": [[0.0]]},
"head_weight": [[0.0]],
"head_bias": [0.0],
}
},
}
model_payload = (json.dumps(model) + "\n").encode()
model_sha256 = hashlib.sha256(model_payload).hexdigest()
artifacts = {
"lstm_forecaster.json": model,
"torch_retrain_guard.json": {
"accepted": True,
"candidate_artifact_sha256": model_sha256,
},
"torch_threshold_calibration.json": {
"artifact_sha256": model_sha256,
"validation": {
"passed": True,
"protocol": "untouched_model_holdout_with_threshold_walk_forward",
}
},
}
for name, data in artifacts.items():
payload = model_payload if name == "lstm_forecaster.json" else (json.dumps(data) + "\n").encode()
coordinator.save_artifact_chunk(
job["id"],
{
"name": name,
"index": 0,
"total": 1,
"sha256": hashlib.sha256(payload).hexdigest(),
"data_base64": base64.b64encode(payload).decode("ascii"),
},
)
completed = coordinator.complete(job["id"], {"success": True})
assert completed["job"]["status"] == "completed"
assert json.loads((tmp_path / "lstm_forecaster.json").read_text())["symbols"]["BTCUSDT"]
+25 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
from pathlib import Path
@@ -11,19 +12,25 @@ def main() -> None:
args = _parse_args()
current = _read_json(args.current_report)
candidate = _read_json(args.candidate_report)
decision = _decision(
current,
candidate,
min_trades=args.min_trades,
min_profit_factor=args.min_profit_factor,
min_avg_net_percent=args.min_avg_net_percent,
max_score_regression=args.max_score_regression,
)
candidate_artifact = Path(args.candidate_artifact)
candidate_sha256 = _sha256(candidate_artifact)
if candidate.get("artifact_sha256") != candidate_sha256:
decision = {"accepted": False, "reason": "candidate_report_artifact_hash_mismatch"}
else:
decision = _decision(
current,
candidate,
min_trades=args.min_trades,
min_profit_factor=args.min_profit_factor,
min_avg_net_percent=args.min_avg_net_percent,
max_score_regression=args.max_score_regression,
)
payload = {
"accepted": decision["accepted"],
"reason": decision["reason"],
"current": _summary(current),
"candidate": _summary(candidate),
"candidate_artifact_sha256": candidate_sha256,
}
if args.report:
Path(args.report).write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
@@ -31,7 +38,6 @@ def main() -> None:
if not decision["accepted"]:
raise SystemExit(2)
target = Path(args.target_artifact)
candidate_artifact = Path(args.candidate_artifact)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(candidate_artifact, target)
@@ -83,6 +89,8 @@ def _validation_passed(report: dict[str, Any]) -> bool:
validation = report.get("validation")
if not isinstance(validation, dict):
return False
if validation.get("protocol") != "untouched_model_holdout_with_threshold_walk_forward":
return False
if "passed" in validation:
return bool(validation.get("passed"))
return str(validation.get("status", "")).strip().lower() in {"pass", "passed", "ok"}
@@ -125,5 +133,13 @@ def _read_json(path: str) -> dict[str, Any]:
return data if isinstance(data, dict) else {}
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
if __name__ == "__main__":
main()
+68 -17
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import hashlib
import json
import math
import sys
@@ -49,6 +50,10 @@ class ForecastRecord:
index: int
timestamp: int
close: float
high: float
low: float
next_open: float
next_timestamp: int
atr: float
expected_percent: float
probability_up: float
@@ -85,7 +90,9 @@ def main() -> None:
symbols = _symbols(args.symbols, settings.symbols)
context_symbols = sorted(set(symbols + _symbols(args.context_symbols, ())))
artifact_path = Path(args.artifact or settings.time_series_lstm_model_path)
artifact = json.loads(artifact_path.read_text(encoding="utf-8"))
artifact_bytes = artifact_path.read_bytes()
artifact_sha256 = hashlib.sha256(artifact_bytes).hexdigest()
artifact = json.loads(artifact_bytes.decode("utf-8"))
horizon = args.horizon if args.horizon > 0 else settings.time_series_forecast_horizon
round_trip_cost = _artifact_round_trip_cost(artifact, settings)
@@ -196,6 +203,7 @@ def main() -> None:
if args.output:
payload = {
"artifact_sha256": artifact_sha256,
"artifact": _artifact_summary(artifact),
"records_by_symbol": per_symbol_counts,
"recommended": _result_dict(recommended),
@@ -273,6 +281,11 @@ def _forecast_records(
decision_horizon = _entry_horizon(entry, horizon)
start = max(min_candles, int(float(entry.get("lookback", 64))))
end = len(candles) - decision_horizon - 1
holdout_start_timestamp = int(float(entry.get("holdout_start_timestamp", 0) or 0))
if holdout_start_timestamp <= 0:
return []
while start < end and candles[start].timestamp < holdout_start_timestamp:
start += 1
if calibration_window > 0:
start = max(start, end - calibration_window)
batched_records = _batch_forecast_records(
@@ -313,7 +326,10 @@ def _forecast_records(
q50 = float(selected.get("q50", expected_return))
expected_percent = (math.exp(expected_return) - 1.0) * 100.0
q50_percent = (math.exp(q50) - 1.0) * 100.0
future_log_return = math.log(closes[index + decision_horizon] / closes[index]) - round_trip_cost
next_open = float(candles[index + 1].open)
if next_open <= 0:
continue
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
records.append(
ForecastRecord(
@@ -321,6 +337,10 @@ def _forecast_records(
index=index,
timestamp=candles[index].timestamp,
close=closes[index],
high=float(candles[index].high),
low=float(candles[index].low),
next_open=next_open,
next_timestamp=candles[index + 1].timestamp,
atr=float(candles[index].atr_14 or 0.0),
expected_percent=expected_percent,
probability_up=probability_up,
@@ -409,7 +429,10 @@ def _batch_forecast_records(
q50 = float(selected["q50"])
expected_percent = (math.exp(expected_return) - 1.0) * 100.0
q50_percent = (math.exp(q50) - 1.0) * 100.0
future_log_return = math.log(closes[index + decision_horizon] / closes[index]) - round_trip_cost
next_open = float(candles[index + 1].open)
if next_open <= 0:
continue
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
records.append(
ForecastRecord(
@@ -417,6 +440,10 @@ def _batch_forecast_records(
index=index,
timestamp=candles[index].timestamp,
close=closes[index],
high=float(candles[index].high),
low=float(candles[index].low),
next_open=next_open,
next_timestamp=candles[index + 1].timestamp,
atr=float(candles[index].atr_14 or 0.0),
expected_percent=expected_percent,
probability_up=probability_up,
@@ -569,12 +596,13 @@ def _full_backtest(
rows: list[dict[str, Any]] = []
max_hold = max(12, horizon * 8)
stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
take_profit_percent = max(0.003, min(0.20, float(settings.take_profit_percent))) * 100.0
stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True))
atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier)))
for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)):
position = positions.get(record.symbol)
if position is not None:
position["highest"] = max(position["highest"], record.close)
position["highest"] = max(position["highest"], record.high)
net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost)
held = record.index - int(position["entry_index"])
atr_stop_level = (
@@ -584,7 +612,7 @@ def _full_backtest(
)
atr_stop = bool(
atr_stop_level is not None
and record.close <= atr_stop_level
and record.low <= atr_stop_level
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
)
weak_forecast = (
@@ -593,10 +621,18 @@ def _full_backtest(
or record.skill <= 0.0
)
exit_reason = ""
if stop_loss_exit_enabled and net_percent <= -stop_loss_percent:
exit_price = record.close
stop_level = position["entry_price"] * (1.0 - stop_loss_percent / 100.0)
take_level = position["entry_price"] * (1.0 + take_profit_percent / 100.0)
if stop_loss_exit_enabled and record.low <= stop_level:
exit_reason = "stop_loss"
exit_price = stop_level
elif record.high >= take_level:
exit_reason = "take_profit"
exit_price = take_level
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)):
exit_reason = "forecast_negative"
elif weak_forecast and net_percent >= 0:
@@ -604,6 +640,7 @@ def _full_backtest(
elif held >= max_hold:
exit_reason = "max_hold"
if exit_reason:
net_percent = _net_percent(position["entry_price"], exit_price, round_trip_cost)
trades.append(net_percent)
rows.append(
{
@@ -624,10 +661,10 @@ def _full_backtest(
continue
if _candidate_allows(record, thresholds.edge, thresholds.probability, thresholds.confidence):
positions[record.symbol] = {
"entry_price": record.close,
"entry_index": record.index,
"timestamp": record.timestamp,
"highest": record.close,
"entry_price": record.next_open,
"entry_index": record.index + 1,
"timestamp": record.next_timestamp,
"highest": record.next_open,
"probability_up": record.probability_up,
"expected_percent": record.expected_percent,
}
@@ -669,12 +706,13 @@ def _benchmark_backtest(
rows: list[dict[str, Any]] = []
max_hold = max(12, horizon * 8)
stop_loss_percent = max(0.003, min(0.08, float(settings.stop_loss_percent))) * 100.0
take_profit_percent = max(0.003, min(0.20, float(settings.take_profit_percent))) * 100.0
stop_loss_exit_enabled = bool(getattr(settings, "stop_loss_exit_enabled", True))
atr_multiplier = max(0.5, min(10.0, float(settings.atr_trailing_multiplier)))
for record in sorted(records, key=lambda item: (item.timestamp, item.symbol)):
position = positions.get(record.symbol)
if position is not None:
position["highest"] = max(position["highest"], record.close)
position["highest"] = max(position["highest"], record.high)
net_percent = _net_percent(position["entry_price"], record.close, round_trip_cost)
held = record.index - int(position["entry_index"])
atr_stop_level = (
@@ -684,19 +722,28 @@ def _benchmark_backtest(
)
atr_stop = bool(
atr_stop_level is not None
and record.close <= atr_stop_level
and record.low <= atr_stop_level
and (stop_loss_exit_enabled or atr_stop_level > position["entry_price"])
)
exit_reason = ""
if stop_loss_exit_enabled and net_percent <= -stop_loss_percent:
exit_price = record.close
stop_level = position["entry_price"] * (1.0 - stop_loss_percent / 100.0)
take_level = position["entry_price"] * (1.0 + take_profit_percent / 100.0)
if stop_loss_exit_enabled and record.low <= stop_level:
exit_reason = "stop_loss"
exit_price = stop_level
elif record.high >= take_level:
exit_reason = "take_profit"
exit_price = take_level
elif atr_stop:
exit_reason = "atr_trailing_stop"
exit_price = float(atr_stop_level)
elif record.benchmark_exit:
exit_reason = "benchmark_exit"
elif held >= max_hold:
exit_reason = "max_hold"
if exit_reason:
net_percent = _net_percent(position["entry_price"], exit_price, round_trip_cost)
trades.append(net_percent)
rows.append(
{
@@ -715,10 +762,10 @@ def _benchmark_backtest(
continue
if record.benchmark_entry:
positions[record.symbol] = {
"entry_price": record.close,
"entry_index": record.index,
"timestamp": record.timestamp,
"highest": record.close,
"entry_price": record.next_open,
"entry_index": record.index + 1,
"timestamp": record.next_timestamp,
"highest": record.next_open,
}
for symbol, position in list(positions.items()):
tail = next((record for record in reversed(records) if record.symbol == symbol), None)
@@ -888,6 +935,7 @@ def _quality_gate(
return {
"status": "pass" if passed else "fail",
"passed": passed,
"protocol": "untouched_model_holdout_with_threshold_walk_forward",
"checks": checks,
"oos_summary": summary,
"benchmark_summary": benchmark_summary,
@@ -1216,6 +1264,9 @@ def _artifact_summary(artifact: dict[str, Any]) -> dict[str, Any]:
"lookback": row.get("lookback"),
"hidden_size": row.get("hidden_size"),
"skill": row.get("skill"),
"validation_skill": row.get("validation_skill"),
"holdout_skill": row.get("holdout_skill"),
"holdout_start_timestamp": row.get("holdout_start_timestamp"),
"directional_accuracy": row.get("directional_accuracy"),
}
for symbol, row in (artifact.get("symbols") or {}).items()
+81 -32
View File
@@ -14,6 +14,7 @@ param(
[int]$Seed = 0,
[int]$Epochs = 0,
[int]$Patience = 0,
[int]$HoldoutWindow = 0,
[string]$Interval = "",
[string]$EnvFile = "",
[switch]$DeployToPi,
@@ -22,7 +23,8 @@ param(
[string]$PiRoot = "",
[string]$PiSshKeyPath = "",
[switch]$NoPiRestart,
[switch]$SkipGuard
[switch]$SkipGuard,
[switch]$ResumeCandidate
)
$ErrorActionPreference = "Stop"
@@ -38,6 +40,30 @@ function Write-RetrainLog {
"[$timestamp] $Message" | Tee-Object -FilePath $LogFile -Append
}
function Invoke-LoggedNativeCommand {
param(
[string]$FilePath,
[object[]]$ArgumentList,
[string]$LogPath
)
# Windows PowerShell converts redirected native stderr into PowerShell error
# records. With the script-wide Stop preference an expected non-zero exit
# would jump to catch before callers can inspect LASTEXITCODE.
$previousErrorActionPreference = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
& $FilePath @ArgumentList 2>&1 |
Tee-Object -FilePath $LogPath -Append |
Out-Host
$exitCode = $LASTEXITCODE
}
finally {
$ErrorActionPreference = $previousErrorActionPreference
}
return [int]$exitCode
}
function Resolve-Python {
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
@@ -120,6 +146,7 @@ if (-not $ContextSymbols -and $env:TORCH_RETRAIN_CONTEXT_SYMBOLS) { $ContextSymb
if ($Seed -le 0 -and $env:TORCH_RETRAIN_SEED) { $Seed = [int]$env:TORCH_RETRAIN_SEED }
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 } }
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
if (-not $EnvFile -and (Test-Path (Join-Path $RepoRoot ".env"))) { $EnvFile = Join-Path $RepoRoot ".env" }
@@ -154,6 +181,7 @@ try {
"--dropouts", $Dropouts,
"--epochs", $Epochs.ToString(),
"--patience", $Patience.ToString(),
"--holdout-window", $HoldoutWindow.ToString(),
"--output", $CandidateFile
)
if ($Symbols) { $trainerArgs += @("--symbols", $Symbols) }
@@ -167,24 +195,28 @@ try {
Push-Location $RepoRoot
$pushedLocation = $true
Write-RetrainLog "Starting PyTorch recurrent retrain: $python $($trainerArgs -join ' ')"
& $python @trainerArgs 2>&1 | Tee-Object -FilePath $LogFile -Append
$trainerExitCode = $LASTEXITCODE
if ($trainerExitCode -ne 0) {
if (Test-TorchArtifactFile $CandidateFile) {
Write-RetrainLog "WARNING: Trainer exited with code $trainerExitCode after writing a valid candidate artifact; continuing to guard."
}
else {
throw "Trainer failed with exit code $trainerExitCode."
if ($ResumeCandidate) {
if (-not (Test-TorchArtifactFile $CandidateFile)) {
throw "ResumeCandidate requested, but no valid candidate artifact exists: $CandidateFile"
}
Write-RetrainLog "Resuming guard from existing candidate artifact: $CandidateFile"
}
else {
Write-RetrainLog "Starting PyTorch recurrent retrain: $python $($trainerArgs -join ' ')"
$trainerExitCode = Invoke-LoggedNativeCommand -FilePath $python -ArgumentList $trainerArgs -LogPath $LogFile
if ($trainerExitCode -ne 0) {
if (Test-TorchArtifactFile $CandidateFile) {
Write-RetrainLog "WARNING: Trainer exited with code $trainerExitCode after writing a valid candidate artifact; continuing to guard."
}
else {
throw "Trainer failed with exit code $trainerExitCode."
}
}
Write-RetrainLog "Finished PyTorch recurrent retrain candidate: $CandidateFile"
}
Write-RetrainLog "Finished PyTorch recurrent retrain candidate: $CandidateFile"
if ($SkipGuard -or -not (Test-Path $ModelFile)) {
Move-Item -Force -LiteralPath $CandidateFile -Destination $ModelFile
Write-RetrainLog "Accepted candidate without guard. Active artifact: $ModelFile"
Sync-AcceptedArtifactsToPi
exit 0
if ($SkipGuard) {
throw "SkipGuard is disabled: every candidate must pass untouched-holdout validation."
}
$calibrationBaseArgs = @(
@@ -199,31 +231,48 @@ try {
if ($Symbols) { $calibrationBaseArgs += @("--symbols", $Symbols) }
if ($EnvFile) { $calibrationBaseArgs += @("--env", $EnvFile) }
Write-RetrainLog "Calibrating current artifact for guard."
& $python @($calibrationBaseArgs + @("--artifact", $ModelFile, "--output", $CurrentCalibration)) 2>&1 | Tee-Object -FilePath $LogFile -Append
if ($LASTEXITCODE -ne 0) {
throw "Current artifact calibration failed with exit code $LASTEXITCODE."
if (Test-Path $ModelFile) {
Write-RetrainLog "Calibrating current artifact for guard."
$currentCalibrationExitCode = Invoke-LoggedNativeCommand `
-FilePath $python `
-ArgumentList ($calibrationBaseArgs + @("--artifact", $ModelFile, "--output", $CurrentCalibration)) `
-LogPath $LogFile
if ($currentCalibrationExitCode -ne 0) {
Write-RetrainLog "Current artifact has no compatible untouched holdout; comparing candidate against an empty current report."
"{}" | Set-Content -LiteralPath $CurrentCalibration -Encoding utf8
}
}
else {
Write-RetrainLog "No active artifact yet; candidate still must pass the full guard."
"{}" | Set-Content -LiteralPath $CurrentCalibration -Encoding utf8
}
Write-RetrainLog "Calibrating candidate artifact for guard."
& $python @($calibrationBaseArgs + @("--artifact", $CandidateFile, "--output", $CandidateCalibration)) 2>&1 | Tee-Object -FilePath $LogFile -Append
if ($LASTEXITCODE -ne 0) {
throw "Candidate artifact calibration failed with exit code $LASTEXITCODE."
$candidateCalibrationExitCode = Invoke-LoggedNativeCommand `
-FilePath $python `
-ArgumentList ($calibrationBaseArgs + @("--artifact", $CandidateFile, "--output", $CandidateCalibration)) `
-LogPath $LogFile
if ($candidateCalibrationExitCode -ne 0) {
throw "Candidate artifact calibration failed with exit code $candidateCalibrationExitCode."
}
Write-RetrainLog "Running retrain guard."
& $python -u "tools\accept_torch_candidate.py" `
--current-report $CurrentCalibration `
--candidate-report $CandidateCalibration `
--candidate-artifact $CandidateFile `
--target-artifact $ModelFile `
--report $GuardReport 2>&1 | Tee-Object -FilePath $LogFile -Append
if ($LASTEXITCODE -eq 2) {
$guardArgs = @(
"-u",
"tools\accept_torch_candidate.py",
"--current-report", $CurrentCalibration,
"--candidate-report", $CandidateCalibration,
"--candidate-artifact", $CandidateFile,
"--target-artifact", $ModelFile,
"--report", $GuardReport
)
$guardExitCode = Invoke-LoggedNativeCommand -FilePath $python -ArgumentList $guardArgs -LogPath $LogFile
if ($guardExitCode -eq 2) {
Write-RetrainLog "Candidate rejected by guard; keeping active artifact: $ModelFile"
exit 0
}
if ($LASTEXITCODE -ne 0) {
throw "Retrain guard failed with exit code $LASTEXITCODE."
if ($guardExitCode -ne 0) {
throw "Retrain guard failed with exit code $guardExitCode."
}
if (Test-Path $CandidateCalibration) {
Copy-Item -Force -LiteralPath $CandidateCalibration -Destination (Join-Path $RuntimeDir "torch_threshold_calibration.json")
+114 -12
View File
@@ -45,6 +45,12 @@ class PreparedData:
validation_up: torch.Tensor
validation_targets: list[list[float]]
validation_volatility_scales: list[list[float]]
holdout_x: torch.Tensor
holdout_y: torch.Tensor
holdout_up: torch.Tensor
holdout_targets: list[list[float]]
holdout_volatility_scales: list[list[float]]
holdout_start_timestamp: int
feature_names: list[str]
feature_means: list[float]
feature_scales: list[float]
@@ -55,6 +61,7 @@ class PreparedData:
decision_horizon_index: int
train_samples: int
validation_samples: int
holdout_samples: int
@dataclass(slots=True)
@@ -63,6 +70,7 @@ class TrainingSample:
normalized_targets: list[float]
raw_targets: list[float]
volatility_scales: list[float]
timestamp: int
class RecurrentReturnModel(nn.Module):
@@ -131,6 +139,7 @@ def main() -> None:
"interval": interval,
"limit": args.limit,
"validation_window": args.validation_window,
"holdout_window": args.holdout_window,
"target_horizon": decision_horizon,
"target_horizons": target_horizons,
"direct_horizon": True,
@@ -154,6 +163,7 @@ def main() -> None:
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,
@@ -207,6 +217,7 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--interval", default="", help="Bybit kline interval. Defaults to BASE_INTERVAL.")
parser.add_argument("--limit", type=int, default=1000, help="Kline limit per symbol.")
parser.add_argument("--validation-window", type=int, default=120, help="Held-out tail targets used for validation.")
parser.add_argument("--holdout-window", type=int, default=240, help="Final untouched samples reserved for model/threshold evaluation.")
parser.add_argument("--horizon", type=int, default=0, help="Direct forecast horizon in candles. Defaults to TIME_SERIES_FORECAST_HORIZON.")
parser.add_argument("--horizons", default="1,3,6,12", help="Comma-separated direct forecast horizons.")
parser.add_argument("--features", default=",".join(DEFAULT_TORCH_FEATURES), help="Comma-separated feature names.")
@@ -246,6 +257,7 @@ def _train_symbol(
interval: str,
limit: int,
validation_window: int,
holdout_window: int,
target_horizons: list[int],
decision_horizon: int,
feature_names: list[str],
@@ -272,7 +284,10 @@ def _train_symbol(
closes = [float(candle.close) for candle in candles if candle.close > 0]
returns = _log_returns(closes)
max_horizon = max(target_horizons)
if len(candles) < max(180, validation_window + max(lookbacks) + max_horizon + 16):
if len(candles) < max(
240,
validation_window + holdout_window + max(lookbacks) + max_horizon * 2 + 32,
):
return None
market_candles: dict[str, list[Candle]] = {symbol.upper(): candles}
for context_symbol in context_symbols:
@@ -301,6 +316,7 @@ def _train_symbol(
market_candles=market_candles,
trend_candles=trend_candles,
validation_window=validation_window,
holdout_window=holdout_window,
clip=clip,
device=device,
)
@@ -376,17 +392,23 @@ def _train_symbol(
"clip": clip,
"validation_mae_percent": validation_mae * 100,
"baseline_mae_percent": baseline_mae * 100,
"holdout_mae_percent": float(candidate.get("holdout_mae", 0.0)) * 100,
"holdout_baseline_mae_percent": float(candidate.get("holdout_baseline_mae", 0.0)) * 100,
"skill": skill,
"candles": len(candles),
"returns": len(returns),
"train_samples": prepared.train_samples,
"validation_samples": prepared.validation_samples,
"holdout_samples": prepared.holdout_samples,
"holdout_start_timestamp": prepared.holdout_start_timestamp,
}
score = _candidate_score(row)
if best is None or score < _candidate_score(best):
best = row
if best is None:
return None
best["validation_skill"] = best.get("skill", 0.0)
best["skill"] = best.get("holdout_skill", 0.0)
best.pop("validation_mae", None)
return best
@@ -402,6 +424,7 @@ def _prepare_data(
market_candles: dict[str, list[Candle]],
trend_candles: list[Candle],
validation_window: int,
holdout_window: int,
clip: float,
device: torch.device,
) -> PreparedData | None:
@@ -436,14 +459,29 @@ def _prepare_data(
volatility_scales.append(volatility_scale)
normalized_targets.append(net_return / max(volatility_scale, 1e-8))
if valid:
samples.append(TrainingSample(window, normalized_targets, raw_targets, volatility_scales))
samples.append(
TrainingSample(
window,
normalized_targets,
raw_targets,
volatility_scales,
candles[end_index].timestamp,
)
)
if len(samples) < 48:
return None
validation_window = min(max(16, validation_window), max(16, len(samples) // 3))
train_samples = samples[:-validation_window]
validation_samples = samples[-validation_window:]
if len(train_samples) < 24 or len(validation_samples) < 8:
max_horizon = max(target_horizons)
holdout_window = min(max(32, holdout_window), max(32, len(samples) // 4))
holdout_start = len(samples) - holdout_window
validation_end = holdout_start - max_horizon
validation_window = min(max(16, validation_window), max(16, validation_end // 3))
validation_start = validation_end - validation_window
train_end = validation_start - max_horizon
train_samples = samples[:train_end]
validation_samples = samples[validation_start:validation_end]
holdout_samples = samples[holdout_start:]
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))
@@ -470,6 +508,14 @@ def _prepare_data(
target_scales=target_scales,
clip=clip,
)
holdout_x, holdout_y, holdout_up = _normalize_samples(
holdout_samples,
feature_means=feature_means,
feature_scales=feature_scales,
target_means=target_means,
target_scales=target_scales,
clip=clip,
)
return PreparedData(
train_x=torch.tensor(train_x, dtype=torch.float32, device=device),
train_y=torch.tensor(train_y, dtype=torch.float32, device=device),
@@ -479,6 +525,12 @@ def _prepare_data(
validation_up=torch.tensor(validation_up, dtype=torch.float32, device=device),
validation_targets=[sample.raw_targets for sample in validation_samples],
validation_volatility_scales=[sample.volatility_scales for sample in validation_samples],
holdout_x=torch.tensor(holdout_x, dtype=torch.float32, device=device),
holdout_y=torch.tensor(holdout_y, dtype=torch.float32, device=device),
holdout_up=torch.tensor(holdout_up, dtype=torch.float32, device=device),
holdout_targets=[sample.raw_targets for sample in holdout_samples],
holdout_volatility_scales=[sample.volatility_scales for sample in holdout_samples],
holdout_start_timestamp=holdout_samples[0].timestamp,
feature_names=feature_names,
feature_means=feature_means,
feature_scales=feature_scales,
@@ -489,6 +541,7 @@ def _prepare_data(
decision_horizon_index=decision_horizon_index,
train_samples=len(train_x),
validation_samples=len(validation_x),
holdout_samples=len(holdout_x),
)
@@ -635,8 +688,10 @@ def _fit_candidate(
if best_state:
model.load_state_dict(best_state)
holdout_metrics = _holdout_metrics(model, prepared, clip)
return {
**best_metrics,
**holdout_metrics,
"best_epoch": best_epoch,
"epochs_trained": best_epoch + stale_epochs,
"state_dict": _export_recurrent_state(model),
@@ -647,10 +702,57 @@ def _fit_candidate(
def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, float]:
return _evaluation_metrics(
model,
values=prepared.validation_x,
targets=prepared.validation_targets,
volatility_scales=prepared.validation_volatility_scales,
prepared=prepared,
clip=clip,
)
def _holdout_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, Any]:
metrics = _evaluation_metrics(
model,
values=prepared.holdout_x,
targets=prepared.holdout_targets,
volatility_scales=prepared.holdout_volatility_scales,
prepared=prepared,
clip=clip,
)
baseline_by_horizon = metrics.get("baseline_mae_by_horizon", {})
holdout_baseline = float(
baseline_by_horizon.get(str(prepared.decision_horizon), metrics["validation_mae"])
)
holdout_mae = float(metrics["validation_mae"])
return {
"holdout_mae": holdout_mae,
"holdout_baseline_mae": holdout_baseline,
"holdout_skill": (
(holdout_baseline - holdout_mae) / holdout_baseline
if holdout_baseline > 0
else 0.0
),
"holdout_directional_accuracy": metrics["directional_accuracy"],
"holdout_buy_precision": metrics["buy_precision"],
"holdout_probability_brier": metrics["probability_brier"],
}
def _evaluation_metrics(
model: nn.Module,
*,
values: torch.Tensor,
targets: list[list[float]],
volatility_scales: list[list[float]],
prepared: PreparedData,
clip: float,
) -> dict[str, float]:
model.eval()
with torch.no_grad():
raw_outputs = model(prepared.validation_x).detach().cpu()
outputs = raw_outputs.view(len(prepared.validation_targets), len(prepared.target_horizons), len(OUTPUT_LAYOUT))
raw_outputs = model(values).detach().cpu()
outputs = raw_outputs.view(len(targets), len(prepared.target_horizons), len(OUTPUT_LAYOUT))
mean_predictions = outputs[:, :, 0].tolist()
logit_predictions = outputs[:, :, 4].tolist()
predictions: list[list[float]] = []
@@ -664,13 +766,13 @@ def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -
* prepared.target_scales[horizon_index]
+ prepared.target_means[horizon_index]
)
predicted_row.append(transformed * prepared.validation_volatility_scales[row_index][horizon_index])
predicted_row.append(transformed * volatility_scales[row_index][horizon_index])
probability_row.append(_sigmoid(float(logit_predictions[row_index][horizon_index])))
predictions.append(predicted_row)
probabilities.append(probability_row)
decision = prepared.decision_horizon_index
decision_predictions = [row[decision] for row in predictions]
decision_targets = [row[decision] for row in prepared.validation_targets]
decision_targets = [row[decision] for row in targets]
errors = [abs(prediction - actual) for prediction, actual in zip(decision_predictions, decision_targets)]
correct = [
1.0
@@ -693,9 +795,9 @@ def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -
for horizon_index, horizon in enumerate(prepared.target_horizons):
horizon_errors = [
abs(row[horizon_index] - actual[horizon_index])
for row, actual in zip(predictions, prepared.validation_targets)
for row, actual in zip(predictions, targets)
]
horizon_baseline = [abs(actual[horizon_index]) for actual in prepared.validation_targets]
horizon_baseline = [abs(actual[horizon_index]) for actual in targets]
by_horizon[str(horizon)] = sum(horizon_errors) / len(horizon_errors) if horizon_errors else math.inf
baseline_by_horizon[str(horizon)] = (
sum(horizon_baseline) / len(horizon_baseline)
+8
View File
@@ -64,6 +64,11 @@ 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
@@ -102,11 +107,14 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
"layers": "-Layers",
"dropouts": "-Dropouts",
"epochs": "-Epochs",
"holdout_window": "-HoldoutWindow",
}
for key, ps_arg in arg_map.items():
value = parameters.get(key)
if value not in (None, ""):
cmd.extend([ps_arg, str(value)])
if parameters.get("resume_candidate") is True:
cmd.append("-ResumeCandidate")
log(log_path, "Running retrain: " + " ".join(quote_for_log(part) for part in cmd))
report_progress(args, job_id, "running", "training", 8, "PyTorch retrain запущен")
line_count = 0