Harden trading, training, and monitoring
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -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>,
|
||||
|
||||
+3
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-8
@@ -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()),
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
@@ -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
@@ -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(".")
|
||||
|
||||
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))]
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pytest==8.4.2
|
||||
@@ -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
-1162397
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user