diff --git a/android/TradeBotMonitor/app/build.gradle.kts b/android/TradeBotMonitor/app/build.gradle.kts index 7e310c7..43b928d 100644 --- a/android/TradeBotMonitor/app/build.gradle.kts +++ b/android/TradeBotMonitor/app/build.gradle.kts @@ -10,7 +10,7 @@ android { applicationId = "xyz.kusoft.tradebotmonitor" minSdk = 26 targetSdk = 36 - versionCode = 2 - versionName = "0.1.1" + versionCode = 3 + versionName = "0.2.0" } } diff --git a/android/TradeBotMonitor/app/src/main/AndroidManifest.xml b/android/TradeBotMonitor/app/src/main/AndroidManifest.xml index 1bc8e2d..2d4cc43 100644 --- a/android/TradeBotMonitor/app/src/main/AndroidManifest.xml +++ b/android/TradeBotMonitor/app/src/main/AndroidManifest.xml @@ -5,13 +5,17 @@ + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPalette.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPalette.kt index 04327e9..5ae386e 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPalette.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPalette.kt @@ -20,18 +20,18 @@ data class AppPalette( companion object { fun dark() = AppPalette( isDark = true, - page = Color.rgb(10, 14, 20), - panel = Color.rgb(18, 24, 32), - panelAlt = Color.rgb(24, 31, 41), - line = Color.rgb(49, 60, 75), - text = Color.rgb(237, 242, 247), - muted = Color.rgb(145, 158, 175), - green = Color.rgb(26, 188, 121), - red = Color.rgb(235, 85, 85), - amber = Color.rgb(236, 173, 74), - blue = Color.rgb(91, 141, 239), - chartBg = Color.rgb(9, 13, 19), - chartGrid = Color.rgb(38, 48, 61), + page = Color.rgb(7, 8, 12), + panel = Color.rgb(17, 20, 26), + panelAlt = Color.rgb(21, 25, 34), + line = Color.rgb(36, 42, 54), + text = Color.rgb(244, 246, 251), + muted = Color.rgb(119, 127, 142), + green = Color.rgb(22, 199, 132), + red = Color.rgb(255, 80, 102), + amber = Color.rgb(245, 166, 35), + blue = Color.rgb(109, 131, 255), + chartBg = Color.rgb(8, 10, 15), + chartGrid = Color.rgb(27, 32, 41), ) fun light() = AppPalette( diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt index 695ac47..a370130 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt @@ -1,6 +1,7 @@ package xyz.kusoft.tradebotmonitor import android.app.Activity +import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Bundle @@ -12,8 +13,6 @@ import android.view.View import android.view.ViewGroup import android.widget.EditText import android.widget.FrameLayout -import android.widget.GridLayout -import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView @@ -28,18 +27,18 @@ import kotlin.math.abs class MainActivity : Activity() { private lateinit var prefs: AppPrefs private lateinit var root: LinearLayout - private lateinit var tabBar: LinearLayout private lateinit var contentHost: FrameLayout - private lateinit var headline: TextView - private lateinit var statusView: TextView + private lateinit var bottomNav: LinearLayout private val executor = Executors.newSingleThreadExecutor() private val mainHandler = Handler(Looper.getMainLooper()) + private val scrollPositions = mutableMapOf() + private val clockFormat = SimpleDateFormat("HH:mm", Locale("ru", "RU")) + private var snapshot: BotSnapshot? = null private var lastError: String = "" - private var activeTab: String = "markets" + private var activeTab: String = "ai" private var palette: AppPalette = AppPalette.dark() - private val scrollPositions = mutableMapOf() private val refreshRunnable = object : Runnable { override fun run() { @@ -64,36 +63,38 @@ class MainActivity : Activity() { } private fun buildShell() { + updateSystemBars() root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setBackgroundColor(palette.page) } setContentView(root) - val top = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(14), dp(12), dp(14), dp(8)) + contentHost = FrameLayout(this).apply { + setBackgroundColor(palette.page) } - val titleBox = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - titleBox.addView(text("TradeBot", 20f, Typeface.BOLD)) - headline = text("Подключаюсь к API бота...", 12f, Typeface.NORMAL, palette.muted) - titleBox.addView(headline) - top.addView(titleBox, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - statusView = label("Проверка", "warn") - top.addView(statusView) - root.addView(top) - - tabBar = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - setPadding(dp(10), 0, dp(10), dp(8)) - } - root.addView(tabBar) - - contentHost = FrameLayout(this) root.addView(contentHost, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) - renderTabs() - render() + + bottomNav = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + setPadding(dp(10), dp(7), dp(10), dp(9)) + background = boxDrawable(palette.panel, palette.line, 0) + } + root.addView(bottomNav, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(64))) + + renderBottomNav() + render(saveScroll = false) + } + + private fun updateSystemBars() { + window.statusBarColor = palette.page + window.navigationBarColor = palette.page + window.decorView.systemUiVisibility = if (palette.isDark) { + 0 + } else { + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + } } private fun refreshData(silent: Boolean) { @@ -104,56 +105,50 @@ class MainActivity : Activity() { snapshot = data lastError = "" ensureSelectedSymbol() - updateHeader() - render() + if (shouldRenderAfterRefresh()) { + render() + } } } catch (error: Exception) { mainHandler.post { lastError = error.message ?: "ошибка подключения" - updateHeader() if (!silent) toast(lastError) - render() + if (shouldRenderAfterRefresh()) { + render() + } } } } } - private fun renderTabs() { - tabBar.removeAllViews() + private fun shouldRenderAfterRefresh(): Boolean { + val focused = contentHost.findFocus() + return activeTab != "settings" || focused !is EditText + } + + private fun renderBottomNav() { + bottomNav.removeAllViews() listOf( - "overview" to "Обзор", "markets" to "Рынки", - "training" to "Обучение", + "ai" to "AI", + "risk" to "Риск", + "assets" to "Активы", "settings" to "Настройки", ).forEach { (id, title) -> - tabBar.addView( - button(title, selected = activeTab == id) { + bottomNav.addView( + navItem(title, selected = activeTab == id) { if (activeTab != id) { saveCurrentScroll() activeTab = id - renderTabs() + renderBottomNav() render(saveScroll = false) } }, - LinearLayout.LayoutParams(0, dp(38), 1f).apply { setMargins(dp(3), 0, dp(3), 0) }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f), ) } } - private fun updateHeader() { - val data = snapshot - if (data == null) { - headline.text = if (lastError.isBlank()) "Нет данных" else "Ошибка: $lastError" - statusView.text = "Нет связи" - styleLabel(statusView, "bad") - return - } - val symbols = data.markets.joinToString(", ") { it.symbol } - headline.text = "${modeLabel(data.mode)} · ${symbols.ifBlank { "пары не загружены" }}" - statusView.text = if (data.running) "Работает" else "Остановлен" - styleLabel(statusView, if (data.ok && data.running) "ok" else "warn") - } - private fun render(saveScroll: Boolean = true) { val tabForRender = activeTab if (saveScroll) { @@ -161,10 +156,11 @@ class MainActivity : Activity() { } contentHost.removeAllViews() val view = when (activeTab) { - "overview" -> overviewScreen() - "training" -> trainingScreen() + "markets" -> marketsScreen() + "risk" -> riskScreen() + "assets" -> assetsScreen() "settings" -> settingsScreen() - else -> marketsScreen() + else -> aiScreen() } contentHost.addView(view) restoreScroll(view, tabForRender) @@ -186,110 +182,118 @@ class MainActivity : Activity() { } } - private fun overviewScreen(): View { + private fun aiScreen(): View { + val box = screenContent() + box.addView(statusLine("AI Торговля", modeBadgeText())) val data = snapshot - val box = scrollContent() if (data == null) { - box.addView(emptyState()) + box.addView(emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}").top(dp(18))) return wrapScroll(box) } - val pnl = data.positions.sumOf { it.unrealizedPnl } - box.addView( - metricGrid( - listOf( - Metric("Equity", money(data.account.equity), "Оценка депозита"), - Metric("Свободно USDT", money(data.account.cash), "Деньги без позиций"), - Metric("Экспозиция", money(data.account.exposure), "${data.positions.size} открытых позиций"), - Metric("PnL открыт.", signedMoney(pnl), "Нереализованный результат"), - Metric("Режим", modeLabel(data.mode), if (data.running) "Бот принимает решения" else "Цикл остановлен"), - Metric("Live готов", if (data.config.optBoolean("live_ready", false)) "да" else "нет", "Статус серверной защиты"), - ), - ), - ) - box.addView(panel("Открытые позиции", positionsList(data.positions))) - box.addView(panel("Последние решения", signalList(data.signalsBySymbol.values.take(12).toList()))) + + val market = selectedMarket(data) + if (market == null) { + box.addView(emptyState("Пары еще не загружены").top(dp(18))) + return wrapScroll(box) + } + val signal = data.signalsBySymbol[market.symbol] + + box.addView(aiHero(data, market, signal).top(dp(14))) + box.addView(chartTabs().top(dp(18))) + box.addView(chartPanel(market).top(dp(10))) + box.addView(decisionPanel(market, signal).top(dp(14))) + box.addView(torchFeaturePanel(market.forecast).top(dp(14))) return wrapScroll(box) } private fun marketsScreen(): View { + val box = screenContent() + box.addView(statusLine("Рынки", "12 spot-пар")) val data = snapshot - val box = scrollContent() if (data == null) { - box.addView(emptyState()) + box.addView(emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}").top(dp(18))) return wrapScroll(box) } - if (data.markets.isEmpty()) { - box.addView(emptyState("Рынки ещё не загружены")) - return wrapScroll(box) - } - ensureSelectedSymbol() - val selected = data.markets.firstOrNull { it.symbol == prefs.selectedSymbol } ?: data.markets.first() - val signal = data.signalsBySymbol[selected.symbol] - box.addView(pairSelector(data.markets, selected.symbol)) - val chart = CandleChartView(this).apply { - palette = this@MainActivity.palette - candles = selected.candles + box.addView(searchPlaceholder("Поиск пары или сигнала").top(dp(16))) + box.addView(tableHeader(listOf("AI-рейтинг" to 1.7f, "Цена" to 1f, "Edge" to 0.9f, "Статус" to 0.9f)).top(dp(16))) + + val rows = rankedMarkets(data) + val list = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + rows.forEach { market -> + list.addView(marketRow(market, data.signalsBySymbol[market.symbol], selected = market.symbol == prefs.selectedSymbol)) } - box.addView(panel("${selected.symbol} · 1h свечи", chart, fixedHeight = dp(332))) - box.addView(panel("Рынок сейчас", keyValueBlock(marketRows(selected)))) - box.addView(panel("Torch прогноз", keyValueBlock(forecastRows(selected.forecast, signal)))) - box.addView(panel("Размер позиции и Kelly", keyValueBlock(kellyRows(signal)))) - box.addView(panel("Параметры, которые видит нейросеть", featureList(selected.forecast?.features.orEmpty()))) + box.addView(list.top(dp(8))) return wrapScroll(box) } - private fun trainingScreen(): View { + private fun riskScreen(): View { + val box = screenContent() + box.addView(statusLine("Сделка и риск", prefs.selectedSymbol)) val data = snapshot - val box = scrollContent() - box.addView(panel("Состояние переобучения", keyValueBlock(retrainRows(data?.retrain ?: JSONObject())))) + if (data == null) { + box.addView(emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}").top(dp(18))) + return wrapScroll(box) + } - val webhookInput = input("URL запуска retrain на Windows", prefs.retrainWebhookUrl) - box.addView(panel("Удалённый запуск обучения", LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - addView(webhookInput) - addView(text("Телефон отправляет POST-команду. Само обучение остаётся на Windows-машине, а не на телефоне.", 12f, Typeface.NORMAL, palette.muted).withTopMargin(8)) - addView(horizontalButtons(listOf( - "Сохранить URL" to { - prefs.retrainWebhookUrl = webhookInput.text.toString() - toast("URL retrain сохранён") - }, - "Запустить сейчас" to { - prefs.retrainWebhookUrl = webhookInput.text.toString() - triggerRetrainNow() - }, - )).withTopMargin(10)) - })) + val market = selectedMarket(data) + if (market == null) { + box.addView(emptyState("Пары еще не загружены").top(dp(18))) + return wrapScroll(box) + } + val signal = data.signalsBySymbol[market.symbol] + box.addView(tradeSummaryCard(data, signal).top(dp(16))) + box.addView(kellyPanel(signal).top(dp(16))) + box.addView(reasonPanel(market, signal).top(dp(16))) + box.addView(liveReadinessPanel(data).top(dp(16))) + box.addView(controlButtons().top(dp(16))) + return wrapScroll(box) + } - box.addView(panel("Расписание retrain", scheduleBlock())) - box.addView(panel("Gate модели", keyValueBlock(gateRows(data?.backtest ?: JSONObject())))) + private fun assetsScreen(): View { + val box = screenContent() + box.addView(statusLine("Активы", modeBadgeText())) + val data = snapshot + if (data == null) { + box.addView(emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}").top(dp(18))) + return wrapScroll(box) + } + + val pnl = data.positions.sumOf { it.unrealizedPnl } + box.addView(assetHero(data, pnl).top(dp(14))) + box.addView(twoColumnMetrics( + "Доступно" to money(data.account.cash), + "В позициях" to money(data.account.exposure), + ).top(dp(14))) + box.addView(positionsPanel(data.positions).top(dp(16))) return wrapScroll(box) } private fun settingsScreen(): View { - val data = snapshot - val box = scrollContent() + val box = screenContent() + box.addView(statusLine("Настройки", prefs.apiBaseUrl)) + val apiInput = input("Адрес API бота", prefs.apiBaseUrl) val tokenInput = input("API auth: token, Bearer, Basic или login:password", prefs.commandToken).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD } - box.addView(panel("Подключение", LinearLayout(this).apply { + box.addView(section("Подключение", LinearLayout(this).apply { orientation = LinearLayout.VERTICAL addView(apiInput) - addView(tokenInput.withTopMargin(8)) - addView(horizontalButtons(listOf( + addView(tokenInput.top(dp(8))) + addView(actionRow( "Сохранить" to { prefs.apiBaseUrl = apiInput.text.toString() prefs.commandToken = tokenInput.text.toString() + toast("Подключение сохранено") refreshData(silent = false) - toast("Настройки подключения сохранены") }, "Обновить" to { refreshData(silent = false) }, - )).withTopMargin(10)) - })) + ).top(dp(10))) + }).top(dp(16))) - box.addView(panel("Тема приложения", horizontalButtons(listOf( - "Тёмная" to { + box.addView(section("Тема приложения", actionRow( + "Темная" to { prefs.themeMode = "dark" palette = AppPalette.dark() buildShell() @@ -301,160 +305,584 @@ class MainActivity : Activity() { buildShell() refreshData(silent = true) }, - )))) + )).top(dp(14))) - box.addView(panel("Переход к live-торговле", liveTransitionBlock(data))) + box.addView(section("Переобучение Torch", retrainSettingsBlock(snapshot?.retrain ?: JSONObject(), snapshot?.backtest ?: JSONObject())).top(dp(14))) + box.addView(section("Переход к live-торговле", liveSettingsBlock(snapshot)).top(dp(14))) return wrapScroll(box) } - private fun pairSelector(markets: List, selected: String): View { - val row = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - setPadding(0, 0, 0, dp(8)) - } - markets.forEach { market -> - val edge = market.forecast?.expectedReturnPercent ?: 0.0 - val view = button("${market.symbol}\n${signedPercent(edge, 3)}", selected = market.symbol == selected) { - prefs.selectedSymbol = market.symbol - render() - }.apply { - gravity = Gravity.CENTER - textSize = 12f + private fun aiHero(data: BotSnapshot, market: MarketItem, signal: SignalData?): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, 0, 0, 0) + + val top = LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.TOP } - row.addView(view, LinearLayout.LayoutParams(dp(104), dp(54)).apply { setMargins(0, 0, dp(7), 0) }) + top.addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.VERTICAL + addView(text(market.symbol, 20f, Typeface.BOLD)) + addView(text(price(latestPrice(market)), 32f, Typeface.BOLD, colorForSigned(signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0)).top(dp(6))) + val edge = signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0 + val probability = signal?.probabilityUp ?: market.forecast?.probabilityUp ?: 0.0 + addView(text("Edge ${signedPercent(edge, 2)} · P(up) ${probability(probability)} · 1h", 13f, Typeface.NORMAL, palette.muted).top(dp(2))) + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + + top.addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.END + addView(text("Equity", 12f, Typeface.NORMAL, palette.muted)) + addView(text(money(data.account.equity), 18f, Typeface.BOLD).top(dp(5))) + addView(statusBadge(if (data.running) "Работает" else "Стоп", if (data.running) "ok" else "warn").top(dp(8))) + }, LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT)) + + addView(top) } - return HorizontalScrollView(this).apply { - isHorizontalScrollBarEnabled = false - addView(row) + + private fun chartTabs(): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(thinDivider()) + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(0, dp(12), 0, dp(6)) + addView(tabText("График", true)) + addView(tabText("Torch", false)) + addView(tabText("Риск", false)) + addView(tabText("История", false)) + }) } + + private fun chartPanel(market: MarketItem): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + background = boxDrawable(palette.chartBg, Color.TRANSPARENT, 2) + val chart = CandleChartView(this@MainActivity).apply { + palette = this@MainActivity.palette + candles = market.candles + } + addView(chart, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(278))) + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + setPadding(0, dp(4), 0, 0) + addView(text("EMA50", 11f, Typeface.NORMAL, palette.muted)) + addView(text("EMA200", 11f, Typeface.NORMAL, palette.amber).left(dp(16))) + addView(text(lastCandleTime(market), 11f, Typeface.NORMAL, palette.muted).apply { + gravity = Gravity.END + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + }) + } + + private fun decisionPanel(market: MarketItem, signal: SignalData?): View { + val action = normalizedAction(signal?.action) + val color = actionColor(action) + val decision = when (action) { + "BUY" -> "Покупка разрешена" + "SELL" -> "Выход из позиции" + else -> "Ожидание" + } + val reason = signal?.reason?.ifBlank { market.forecast?.reason.orEmpty() } ?: market.forecast?.reason.orEmpty() + return section(null, LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.VERTICAL + addView(text("Решение Torch", 12f, Typeface.NORMAL, palette.muted)) + addView(text(decision, 20f, Typeface.BOLD, color).top(dp(8))) + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.END + addView(text("Kelly", 12f, Typeface.NORMAL, palette.muted)) + addView(text(money(signal?.positionNotionalUsdt ?: 0.0), 20f, Typeface.BOLD).top(dp(8))) + }, LinearLayout.LayoutParams(dp(120), ViewGroup.LayoutParams.WRAP_CONTENT)) + }) + addView(thinDivider().top(dp(12))) + addView(keyValueLine("Причина", reason.ifBlank { "Нет объяснения от модели" }).top(dp(8))) + }) } - private fun marketRows(market: MarketItem): List> { - val ticker = market.ticker - val candle = market.candles.lastOrNull() - return listOf( - "Цена" to price(ticker?.lastPrice ?: candle?.close ?: 0.0), - "Изменение 24ч" to signedPercent(ticker?.change24h ?: 0.0, 2), - "Спред" to percent(ticker?.spreadPercent ?: 0.0, 4), - "Оборот 24ч" to money(ticker?.turnover24h ?: 0.0, 0), - "RSI 14" to number(candle?.rsi14 ?: 0.0, 2), - "ATR 14" to price(candle?.atr14 ?: 0.0), - "EMA50" to price(candle?.ema50 ?: 0.0), - "EMA200" to price(candle?.ema200 ?: 0.0), - "Качество данных" to "${qualityLabel(market.qualityStatus)} · ${percent(market.qualityScore * 100.0, 0)}", - ) - } - - private fun forecastRows(forecast: ForecastData?, signal: SignalData?): List> { + private fun torchFeaturePanel(forecast: ForecastData?): View { if (forecast == null) { - return listOf("Статус" to "Нет прогноза Torch") + return section("Что видит нейросеть", text("Torch еще не отдал параметры для этой пары", 13f, Typeface.NORMAL, palette.muted)) } - return listOf( - "Модель" to modelLabel(forecast.model), - "Горизонт" to if (forecast.horizon > 0) "${forecast.horizon} свеч. вперёд" else "нет данных", - "Edge" to signedPercent(signal?.expectedReturnPercent ?: forecast.expectedReturnPercent, 4), - "P(up)" to probability(forecast.probabilityUp), - "Confidence" to probability(signal?.confidence ?: 0.0), - "Skill" to number(forecast.skill, 4), - "Волатильность" to percent(forecast.volatilityPercent, 4), - "Q10 / Q50 / Q90" to "${signedPercent(forecast.q10Percent, 3)} / ${signedPercent(forecast.q50Percent, 3)} / ${signedPercent(forecast.q90Percent, 3)}", - "Gate" to gateLabel(forecast.qualityGatePassed), - "Причина" to forecast.reason.ifBlank { signal?.reason.orEmpty() }, - ) + val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + box.addView(keyValueLine("Модель", modelLabel(forecast.model))) + box.addView(keyValueLine("Горизонт", if (forecast.horizon > 0) "${forecast.horizon} свеч. вперед" else "нет данных")) + box.addView(keyValueLine("Q10 / Q50 / Q90", "${signedPercent(forecast.q10Percent, 3)} / ${signedPercent(forecast.q50Percent, 3)} / ${signedPercent(forecast.q90Percent, 3)}")) + box.addView(keyValueLine("Skill", number(forecast.skill, 4))) + box.addView(thinDivider().top(dp(10))) + forecast.features.take(14).forEach { feature -> + box.addView(featureRow(feature).top(dp(8))) + } + return section("Что видит нейросеть", box) } - private fun kellyRows(signal: SignalData?): List> { + private fun marketRow(market: MarketItem, signal: SignalData?, selected: Boolean): View { + val action = normalizedAction(signal?.action) + val edge = signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0 + val probability = signal?.probabilityUp ?: market.forecast?.probabilityUp ?: 0.0 + return LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(0, dp(8), 0, dp(8)) + background = if (selected) boxDrawable(palette.panelAlt, Color.TRANSPARENT, 2) else null + setOnClickListener { + prefs.selectedSymbol = market.symbol + activeTab = "ai" + renderBottomNav() + render(saveScroll = false) + } + isClickable = true + isFocusable = false + + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.VERTICAL + addView(text(market.symbol, 15f, Typeface.BOLD)) + addView(text("P(up) ${probability(probability)} · Kelly ${number(signal?.positionNotionalUsdt ?: 0.0, 1)}", 11f, Typeface.NORMAL, palette.muted).top(dp(3))) + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.7f)) + + addView(text(price(latestPrice(market)), 14f, Typeface.BOLD).apply { + gravity = Gravity.END + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(text(signedPercent(edge, 2), 14f, Typeface.NORMAL, colorForSigned(edge)).apply { + gravity = Gravity.END + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 0.9f)) + addView(text(action, 12f, Typeface.BOLD, actionColor(action)).apply { + gravity = Gravity.END + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 0.9f)) + } + } + + private fun tradeSummaryCard(data: BotSnapshot, signal: SignalData?): View { + val action = normalizedAction(signal?.action) + val risk = data.config.optDouble("risk_per_trade_percent", 0.0) * 100.0 + return LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(16), dp(16), dp(16), dp(16)) + background = boxDrawable(withAlpha(actionColor(action), if (palette.isDark) 24 else 16), actionColor(action), 8) + addView(valueStack("Действие", action, actionColor(action), 32f), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(valueStack("Размер сейчас", money(signal?.positionNotionalUsdt ?: 0.0), palette.text, 18f), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(valueStack("Риск", percent(risk, 2), palette.amber, 18f), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 0.8f)) + } + } + + private fun kellyPanel(signal: SignalData?): View { val sizing = signal?.diagnostics?.optJSONObject("position_sizing") ?: JSONObject() - return listOf( - "Действие" to actionLabel(signal?.action.orEmpty()), - "Размер сейчас" to money(signal?.positionNotionalUsdt ?: 0.0), - "Цель Kelly по паре" to money(sizing.optDouble("kelly_target_notional_usdt", 0.0)), - "Уже занято в паре" to money(sizing.optDouble("symbol_exposure_usdt", 0.0)), - "Остаток Kelly" to money(sizing.optDouble("kelly_remaining_notional_usdt", 0.0)), - "Множитель edge" to number(sizing.optDouble("torch_edge_multiplier", 0.0), 4), - "Множитель P(up)" to number(sizing.optDouble("torch_probability_multiplier", 0.0), 4), - "Множитель skill" to number(sizing.optDouble("torch_skill_multiplier", 0.0), 4), - ) + val target = sizing.optDouble("kelly_target_notional_usdt", 0.0) + val exposure = sizing.optDouble("symbol_exposure_usdt", 0.0) + val remaining = sizing.optDouble("kelly_remaining_notional_usdt", signal?.positionNotionalUsdt ?: 0.0) + return section("Kelly распределение", LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(allocationBar(if (target > 0.0) exposure / target else 0.0)) + addView(keyValueLine("Цель по паре", money(target)).top(dp(10))) + addView(keyValueLine("Уже занято", money(exposure)).top(dp(4))) + addView(keyValueLine("Можно добавить", money(remaining), palette.green).top(dp(4))) + }) } - private fun retrainRows(retrain: JSONObject): List> = - listOf( - "Доступно" to yesNo(retrain.optBoolean("available", false)), - "Принята новая модель" to yesNo(retrain.optBoolean("accepted", false)), - "Причина guard" to retrain.optStringClean("reason").ifBlank { "нет данных" }, - "Файл отчёта" to retrain.optStringClean("path"), - ) - - private fun gateRows(backtest: JSONObject): List> { - val validation = backtest.optJSONObject("validation") ?: JSONObject() - val replay = backtest.optJSONObject("full_replay") ?: JSONObject() - return listOf( - "Доступно" to yesNo(backtest.optBoolean("available", false)), - "Validation" to if (validation.optBoolean("passed", false)) "пройден" else validation.optStringClean("status").ifBlank { "нет данных" }, - "Replay сделок" to replay.optInt("trades", 0).toString(), - "Replay PnL" to signedMoney(replay.optDouble("net_pnl", 0.0)), - "Файл отчёта" to backtest.optStringClean("path"), - ) + private fun reasonPanel(market: MarketItem, signal: SignalData?): View { + val forecast = market.forecast + return section("Почему модель так решила", LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + addView(text("Edge: ${signedPercent(signal?.expectedReturnPercent ?: forecast?.expectedReturnPercent ?: 0.0, 2)}", 13f, Typeface.NORMAL, palette.green), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(text("P(up): ${probability(signal?.probabilityUp ?: forecast?.probabilityUp ?: 0.0)}", 13f, Typeface.NORMAL, palette.green), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + }) + addView(text(signal?.reason?.ifBlank { forecast?.reason.orEmpty() }?.ifBlank { "Нет объяснения от модели" } ?: "Нет объяснения от модели", 13f, Typeface.BOLD).top(dp(12))) + val candle = market.candles.lastOrNull() + addView(text("RSI ${number(candle?.rsi14 ?: 0.0, 1)}, ATR ${price(candle?.atr14 ?: 0.0)}, спред ${percent(market.ticker?.spreadPercent ?: 0.0, 4)}", 12f, Typeface.NORMAL, palette.muted).top(dp(10))) + }) } - private fun liveTransitionBlock(data: BotSnapshot?): View { - val config = data?.config ?: JSONObject() - val running = data?.running ?: false + private fun liveReadinessPanel(data: BotSnapshot): View { + val config = data.config + val running = data.running val steps = listOf( - "Остановить бота перед переключением" to !running, - "На сервере выставить TRADING_MODE=live" to (config.optStringClean("trading_mode") == "live"), - "Работать с mainnet, не testnet" to !config.optBoolean("bybit_testnet", true), - "ENABLE_LIVE_TRADING=true и LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK" to config.optBoolean("live_ready", false), - "Bybit API key/secret настроены на сервере" to config.optBoolean("live_ready", false), + "Бот остановлен перед переключением" to !running, + "TRADING_MODE=live" to (config.optStringClean("trading_mode") == "live"), + "Mainnet, не testnet" to !config.optBoolean("bybit_testnet", true), + "Подтвержден реальный риск" to config.optBoolean("live_ready", false), "Лимит live-ордера задан" to (config.optDouble("live_order_max_usdt", 0.0) > 0.0), - "Риск и Kelly-лимиты проверены" to (config.optDouble("risk_per_trade_percent", 0.0) > 0.0 && config.optDouble("max_total_exposure_usdt", 0.0) > 0.0), ) - val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - box.addView(keyValueBlock(listOf( - "Текущий режим" to modeLabel(data?.mode.orEmpty()), - "Live готов" to yesNo(config.optBoolean("live_ready", false)), - "Максимум live-ордера" to money(config.optDouble("live_order_max_usdt", 0.0)), - "Риск на сделку" to percent(config.optDouble("risk_per_trade_percent", 0.0) * 100.0, 2), - ))) - steps.forEach { (title, passed) -> - box.addView(stepRow(title, passed).withTopMargin(6)) - } - box.addView(text("Приложение показывает готовность и может остановить/запустить цикл, но не включает реальные ордера одной кнопкой. Переключение live остаётся через серверный .env и явное подтверждение риска.", 12f, Typeface.NORMAL, palette.muted).withTopMargin(10)) - box.addView(horizontalButtons(listOf( - "Остановить бота" to { sendControl(stop = true) }, - "Запустить цикл" to { sendControl(stop = false) }, - )).withTopMargin(10)) - return box + val failed = steps.count { !it.second } + return section(null, LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(text("Live готовность", 12f, Typeface.NORMAL, palette.muted)) + addView(text(if (failed == 0) "Все шаги пройдены" else "$failed шага не пройдены", 16f, Typeface.BOLD, if (failed == 0) palette.green else palette.amber).top(dp(8))) + steps.forEach { (title, ok) -> + addView(keyValueLine(title, if (ok) "OK" else "Нужно", if (ok) palette.green else palette.amber).top(dp(7))) + } + }) } - private fun scheduleBlock(): View { + private fun controlButtons(): View = + actionRow( + "Стоп бот" to { sendControl(stop = true) }, + "Запустить цикл" to { sendControl(stop = false) }, + ) + + private fun assetHero(data: BotSnapshot, pnl: Double): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(text("Общие активы", 13f, Typeface.NORMAL, palette.muted)) + addView(text(money(data.account.equity), 34f, Typeface.BOLD).top(dp(8))) + addView(text("P&L открытых позиций ${signedMoney(pnl)}", 15f, Typeface.NORMAL, colorForSigned(pnl)).top(dp(8))) + addView(text("Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"}", 12f, Typeface.NORMAL, palette.muted).top(dp(8))) + } + + private fun positionsPanel(positions: List): View { + if (positions.isEmpty()) { + return section("Открытые позиции", text("Открытых позиций нет", 13f, Typeface.NORMAL, palette.muted)) + } val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - box.addView(keyValueBlock(listOf( - "Статус" to if (prefs.retrainScheduleEnabled) "включено" else "выключено", - "Интервал" to "${prefs.retrainIntervalHours} ч", - "Webhook" to prefs.retrainWebhookUrl.ifBlank { "не задан" }, - ))) - box.addView(horizontalButtons(listOf( - "1ч" to { setRetrainInterval(1) }, - "3ч" to { setRetrainInterval(3) }, - "6ч" to { setRetrainInterval(6) }, - "12ч" to { setRetrainInterval(12) }, - )).withTopMargin(8)) - box.addView(horizontalButtons(listOf( - "Включить расписание" to { - prefs.retrainScheduleEnabled = true - RetrainScheduler.schedule(this, prefs.retrainIntervalHours) - toast("Расписание retrain включено") - render() - }, - "Отключить" to { - prefs.retrainScheduleEnabled = false - RetrainScheduler.cancel(this) - toast("Расписание retrain отключено") - render() - }, - )).withTopMargin(8)) - return box + positions.forEachIndexed { index, position -> + if (index > 0) box.addView(thinDivider().top(dp(8))) + box.addView(LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(keyValueLine(position.symbol, "${money(position.marketValue)} · ${signedPercent(position.unrealizedPnlPercent, 2)}", colorForSigned(position.unrealizedPnlPercent))) + addView(keyValueLine("Вход", price(position.entryPrice)).top(dp(4))) + addView(keyValueLine("Текущая", price(position.markPrice)).top(dp(4))) + addView(keyValueLine("PnL", signedMoney(position.unrealizedPnl), colorForSigned(position.unrealizedPnl)).top(dp(4))) + }.top(dp(8))) + } + return section("Открытые позиции", box) + } + + private fun retrainSettingsBlock(retrain: JSONObject, backtest: JSONObject): View { + val webhookInput = input("URL запуска retrain на Windows", prefs.retrainWebhookUrl) + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(keyValueLine("Доступно", yesNo(retrain.optBoolean("available", false)))) + addView(keyValueLine("Принята новая модель", yesNo(retrain.optBoolean("accepted", false))).top(dp(4))) + val replay = backtest.optJSONObject("full_replay") ?: JSONObject() + addView(keyValueLine("Replay сделок", replay.optInt("trades", 0).toString()).top(dp(4))) + addView(keyValueLine("Replay PnL", signedMoney(replay.optDouble("net_pnl", 0.0)), colorForSigned(replay.optDouble("net_pnl", 0.0))).top(dp(4))) + addView(webhookInput.top(dp(12))) + addView(actionRow( + "Сохранить URL" to { + prefs.retrainWebhookUrl = webhookInput.text.toString() + toast("URL retrain сохранен") + }, + "Запустить сейчас" to { + prefs.retrainWebhookUrl = webhookInput.text.toString() + triggerRetrainNow() + }, + ).top(dp(10))) + addView(actionRow( + "1ч" to { setRetrainInterval(1) }, + "3ч" to { setRetrainInterval(3) }, + "6ч" to { setRetrainInterval(6) }, + "12ч" to { setRetrainInterval(12) }, + ).top(dp(10))) + addView(keyValueLine("Расписание", if (prefs.retrainScheduleEnabled) "включено, ${prefs.retrainIntervalHours}ч" else "выключено").top(dp(8))) + addView(actionRow( + "Включить" to { + prefs.retrainScheduleEnabled = true + RetrainScheduler.schedule(this@MainActivity, prefs.retrainIntervalHours) + toast("Расписание retrain включено") + render() + }, + "Отключить" to { + prefs.retrainScheduleEnabled = false + RetrainScheduler.cancel(this@MainActivity) + toast("Расписание retrain отключено") + render() + }, + ).top(dp(10))) + } + } + + private fun liveSettingsBlock(data: BotSnapshot?): View { + val config = data?.config ?: JSONObject() + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(keyValueLine("Текущий режим", modeLabel(data?.mode.orEmpty()))) + addView(keyValueLine("Live готов", yesNo(config.optBoolean("live_ready", false))).top(dp(4))) + addView(keyValueLine("Максимум live-ордера", money(config.optDouble("live_order_max_usdt", 0.0))).top(dp(4))) + addView(keyValueLine("Риск на сделку", percent(config.optDouble("risk_per_trade_percent", 0.0) * 100.0, 2)).top(dp(4))) + addView(text("Переключение live остается через серверный .env и явное подтверждение риска. Приложение показывает готовность и может остановить или запустить цикл.", 12f, Typeface.NORMAL, palette.muted).top(dp(12))) + addView(controlButtons().top(dp(10))) + } + } + + private fun twoColumnMetrics(left: Pair, right: Pair): View = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + addView(metricCell(left.first, left.second), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { setMargins(0, 0, dp(6), 0) }) + addView(metricCell(right.first, right.second), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { setMargins(dp(6), 0, 0, 0) }) + } + + private fun metricCell(title: String, value: String): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(14), dp(12), dp(14), dp(12)) + background = boxDrawable(palette.panel, palette.line, 8) + addView(text(title, 12f, Typeface.NORMAL, palette.muted)) + addView(text(value, 17f, Typeface.BOLD, colorForSigned(value)).top(dp(6))) + } + + private fun tableHeader(columns: List>): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + columns.forEach { (title, weight) -> + addView(text(title, 13f, Typeface.BOLD, palette.muted).apply { + gravity = if (title == columns.first().first) Gravity.START else Gravity.END + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, weight)) + } + }) + addView(thinDivider().top(dp(8))) + } + + private fun section(title: String?, body: View): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(14), dp(13), dp(14), dp(13)) + background = boxDrawable(palette.panel, palette.line, 8) + if (!title.isNullOrBlank()) { + addView(text(title, 18f, Typeface.BOLD)) + addView(body.top(dp(12))) + } else { + addView(body) + } + } + + private fun statusLine(title: String, badge: String): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(text(clockFormat.format(Date()), 13f, Typeface.BOLD), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(statusBadge(badge, if (snapshot?.running == true) "ok" else "warn")) + }) + addView(text(title, 21f, Typeface.BOLD).top(dp(18))) + if (lastError.isNotBlank() && snapshot == null) { + addView(text(lastError.take(120), 12f, Typeface.NORMAL, palette.red).top(dp(6))) + } + } + + private fun searchPlaceholder(value: String): View = + TextView(this).apply { + text = value + textSize = 13f + setTextColor(palette.muted) + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(14), 0, dp(14), 0) + background = boxDrawable(palette.panel, palette.line, 8) + }.also { + it.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(44)) + } + + private fun statusBadge(title: String, kind: String): TextView = + TextView(this).apply { + text = title.uppercase(Locale.US) + textSize = 12f + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + setPadding(dp(12), dp(5), dp(12), dp(5)) + val color = when (kind) { + "ok" -> palette.green + "bad" -> palette.red + else -> palette.amber + } + setTextColor(color) + background = boxDrawable(withAlpha(color, if (palette.isDark) 28 else 18), color, 7) + } + + private fun tabText(title: String, selected: Boolean): View = + TextView(this).apply { + text = title + textSize = 15f + typeface = Typeface.DEFAULT_BOLD + setTextColor(if (selected) palette.text else palette.muted) + gravity = Gravity.CENTER + if (selected) { + background = GradientDrawable().apply { + setColor(Color.TRANSPARENT) + setStroke(0, Color.TRANSPARENT) + } + } + }.also { + it.layoutParams = LinearLayout.LayoutParams(0, dp(34), 1f) + } + + private fun valueStack(title: String, value: String, valueColor: Int, valueSize: Float): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(text(title, 12f, Typeface.NORMAL, palette.muted)) + addView(text(value, valueSize, Typeface.BOLD, valueColor).top(dp(8))) + } + + private fun featureRow(feature: FeatureItem): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(text(feature.label.ifBlank { "Параметр" }, 13f, Typeface.BOLD)) + val details = listOf(feature.group, feature.rawDisplay, feature.modelDisplay).filter { it.isNotBlank() }.joinToString(" · ") + if (details.isNotBlank()) addView(text(details, 12f, Typeface.NORMAL, palette.muted).top(dp(3))) + if (feature.interpretation.isNotBlank()) addView(text(feature.interpretation, 12f, Typeface.NORMAL, palette.text).top(dp(3))) + } + + private fun keyValueLine(key: String, value: String, valueColor: Int = colorForSigned(value)): View = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(text(key, 13f, Typeface.NORMAL, palette.muted), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(text(value.ifBlank { "нет данных" }, 13f, Typeface.BOLD, valueColor).apply { + gravity = Gravity.END + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.05f)) + } + + private fun allocationBar(fractionRaw: Double): View { + val fraction = fractionRaw.coerceIn(0.0, 1.0).toFloat() + return LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + background = boxDrawable(palette.panelAlt, Color.TRANSPARENT, 3) + addView(View(this@MainActivity).apply { + background = boxDrawable(palette.green, Color.TRANSPARENT, 3) + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, fraction)) + addView(View(this@MainActivity), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f - fraction)) + }.also { + it.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(14)) + } + } + + private fun actionRow(vararg items: Pair Unit>): View = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + items.forEachIndexed { index, (title, action) -> + addView(actionButton(title, action), LinearLayout.LayoutParams(0, dp(48), 1f).apply { + if (index > 0) setMargins(dp(6), 0, 0, 0) + if (index < items.lastIndex) setMargins(leftMargin, 0, dp(6), 0) + }) + } + } + + private fun actionButton(title: String, action: () -> Unit): TextView = + TextView(this).apply { + text = title + textSize = 13f + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + val danger = title.contains("Стоп", ignoreCase = true) || title.contains("Отключ", ignoreCase = true) + val color = if (danger) palette.red else palette.green + setTextColor(color) + background = boxDrawable(withAlpha(color, if (palette.isDark) 16 else 10), color, 7) + setOnClickListener { action() } + isClickable = true + isFocusable = false + isFocusableInTouchMode = false + } + + private fun navItem(title: String, selected: Boolean, action: () -> Unit): TextView = + TextView(this).apply { + text = title + textSize = 12f + typeface = Typeface.DEFAULT_BOLD + gravity = Gravity.CENTER + setTextColor(if (selected) palette.amber else palette.muted) + setOnClickListener { action() } + isClickable = true + isFocusable = false + isFocusableInTouchMode = false + } + + private fun input(hint: String, value: String): EditText = + EditText(this).apply { + setText(value) + this.hint = hint + textSize = 13f + setSingleLine(true) + setTextColor(palette.text) + setHintTextColor(palette.muted) + setPadding(dp(12), 0, dp(12), 0) + background = boxDrawable(palette.panelAlt, palette.line, 7) + minHeight = dp(46) + inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI + } + + private fun text(value: String, size: Float, style: Int, color: Int = palette.text): TextView = + TextView(this).apply { + text = value + textSize = size + setTextColor(color) + typeface = Typeface.create(Typeface.DEFAULT, style) + includeFontPadding = true + } + + private fun thinDivider(): View = + View(this).apply { + setBackgroundColor(palette.line) + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(1)) + } + + private fun screenContent(): LinearLayout = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(18), dp(16), dp(18), dp(22)) + } + + private fun wrapScroll(content: View): View = + ScrollView(this).apply { + setBackgroundColor(palette.page) + isFillViewport = false + addView(content, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + + private fun emptyState(message: String): View = + section("Состояние", text(message, 13f, Typeface.NORMAL, palette.muted)) + + private fun rankedMarkets(data: BotSnapshot): List = + orderedMarkets(data.markets).sortedWith( + compareByDescending { marketRankScore(it, data.signalsBySymbol[it.symbol]) } + .thenBy { fixedSymbolIndex(it.symbol) } + .thenBy { it.symbol }, + ) + + private fun orderedMarkets(markets: List): List = + markets.sortedWith(compareBy({ fixedSymbolIndex(it.symbol) }, { it.symbol })) + + private fun marketRankScore(market: MarketItem, signal: SignalData?): Double { + val actionScore = when (normalizedAction(signal?.action)) { + "BUY" -> 10_000.0 + "SELL" -> 5_000.0 + "WAIT" -> 1_000.0 + else -> 0.0 + } + val edge = signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0 + val probability = signal?.probabilityUp ?: market.forecast?.probabilityUp ?: 0.0 + return actionScore + edge * 100.0 + normalizedProbability(probability) + } + + private fun selectedMarket(data: BotSnapshot): MarketItem? { + ensureSelectedSymbol() + return data.markets.firstOrNull { it.symbol == prefs.selectedSymbol } + ?: orderedMarkets(data.markets).firstOrNull() + } + + private fun ensureSelectedSymbol() { + val data = snapshot ?: return + if (data.markets.none { it.symbol == prefs.selectedSymbol }) { + prefs.selectedSymbol = orderedMarkets(data.markets).firstOrNull()?.symbol ?: "BTCUSDT" + } + } + + private fun latestPrice(market: MarketItem): Double = + market.ticker?.lastPrice ?: market.candles.lastOrNull()?.close ?: 0.0 + + private fun lastCandleTime(market: MarketItem): String { + val timestamp = market.candles.lastOrNull()?.timestamp ?: return "нет свечей" + val millis = if (timestamp > 100_000_000_000L) timestamp else timestamp * 1000L + return SimpleDateFormat("dd.MM HH:mm", Locale("ru", "RU")).format(Date(millis)) } private fun setRetrainInterval(hours: Int) { @@ -492,234 +920,122 @@ class MainActivity : Activity() { } } - private fun ensureSelectedSymbol() { - val data = snapshot ?: return - if (data.markets.none { it.symbol == prefs.selectedSymbol }) { - prefs.selectedSymbol = data.markets.firstOrNull()?.symbol ?: "BTCUSDT" + private fun fixedSymbolIndex(symbol: String): Int { + val index = FIXED_SYMBOLS.indexOf(symbol.uppercase(Locale.US)) + return if (index >= 0) index else FIXED_SYMBOLS.size + 1 + } + + private fun normalizedAction(raw: String?): String { + val value = raw.orEmpty().uppercase(Locale.US) + return when { + value.contains("BUY") -> "BUY" + value.contains("SELL") || value.contains("EXIT") -> "SELL" + value.contains("HOLD") || value.contains("WAIT") -> "WAIT" + value.contains("SKIP") || value.contains("BLOCK") -> "SKIP" + else -> "WAIT" } } - private fun metricGrid(metrics: List): View { - val grid = GridLayout(this).apply { - columnCount = 2 - rowCount = (metrics.size + 1) / 2 - } - metrics.forEach { item -> - grid.addView(metricBox(item), GridLayout.LayoutParams().apply { - width = 0 - height = ViewGroup.LayoutParams.WRAP_CONTENT - columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f) - setMargins(dp(4), dp(4), dp(4), dp(4)) - }) - } - return grid - } - - private fun metricBox(metric: Metric): View = - LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(10), dp(9), dp(10), dp(9)) - background = boxDrawable(palette.panelAlt, palette.line, 6) - addView(text(metric.name, 12f, Typeface.NORMAL, palette.muted)) - addView(text(metric.value, 19f, Typeface.BOLD, colorForSigned(metric.value)).withTopMargin(4)) - addView(text(metric.hint, 11f, Typeface.NORMAL, palette.muted).withTopMargin(4)) + private fun actionColor(action: String): Int = + when (action) { + "BUY" -> palette.green + "SELL" -> palette.red + "WAIT" -> palette.amber + else -> palette.muted } - private fun positionsList(rows: List): View { - if (rows.isEmpty()) return text("Открытых позиций нет", 13f, Typeface.NORMAL, palette.muted) - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - rows.forEach { row -> - addView(keyValueBlock(listOf( - row.symbol to "${money(row.marketValue)} · ${signedPercent(row.unrealizedPnlPercent, 2)}", - "Вход" to price(row.entryPrice), - "Текущая" to price(row.markPrice), - "PnL" to signedMoney(row.unrealizedPnl), - )).withBottomMargin(8)) - } - } - } - - private fun signalList(rows: List): View { - if (rows.isEmpty()) return text("Сигналов пока нет", 13f, Typeface.NORMAL, palette.muted) - return LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - rows.forEach { signal -> - addView(keyValueBlock(listOf( - signal.symbol to "${actionLabel(signal.action)} · ${probability(signal.confidence)}", - "Edge" to signedPercent(signal.expectedReturnPercent, 4), - "P(up)" to probability(signal.probabilityUp), - "Размер" to money(signal.positionNotionalUsdt), - "Причина" to signal.reason, - )).withBottomMargin(8)) - } - } - } - - private fun featureList(features: List): View { - if (features.isEmpty()) return text("Torch ещё не отдал параметры для этой пары", 13f, Typeface.NORMAL, palette.muted) - val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - features.take(18).forEach { feature -> - val title = feature.label.ifBlank { "Параметр" } - val subtitle = listOf(feature.group, feature.rawDisplay, feature.modelDisplay) - .filter { it.isNotBlank() } - .joinToString(" · ") - box.addView(LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(0, dp(7), 0, dp(7)) - addView(text(title, 13f, Typeface.BOLD)) - addView(text(subtitle, 12f, Typeface.NORMAL, palette.muted).withTopMargin(2)) - if (feature.interpretation.isNotBlank()) { - addView(text(feature.interpretation, 12f, Typeface.NORMAL, palette.text).withTopMargin(3)) - } - }) - } - return box - } - - private fun keyValueBlock(rows: List>): View = - LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - rows.forEach { (key, value) -> - addView(LinearLayout(this@MainActivity).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, dp(6), 0, dp(6)) - addView(text(key, 12f, Typeface.NORMAL, palette.muted), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) - addView(text(value.ifBlank { "нет данных" }, 13f, Typeface.BOLD, colorForSigned(value)).apply { - gravity = Gravity.END - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.05f)) - }) - } + private fun modeBadgeText(): String = + when { + snapshot == null && lastError.isNotBlank() -> "Нет связи" + snapshot?.running == true -> "Активно" + snapshot != null -> "Пауза" + else -> "Проверка" } - private fun stepRow(title: String, passed: Boolean): View = - LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(9), dp(7), dp(9), dp(7)) - background = boxDrawable(palette.panelAlt, if (passed) palette.green else palette.line, 5) - addView(text(if (passed) "OK" else "НУЖНО", 11f, Typeface.BOLD, if (passed) palette.green else palette.amber), LinearLayout.LayoutParams(dp(58), ViewGroup.LayoutParams.WRAP_CONTENT)) - addView(text(title, 13f, Typeface.NORMAL, palette.text), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + private fun modeLabel(value: String): String = + when (value.lowercase(Locale.US)) { + "live" -> "live" + "paper" -> "paper" + "backtest" -> "backtest" + else -> value.ifBlank { "нет данных" } } - private fun panel(title: String, body: View, fixedHeight: Int? = null): View = - LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - background = boxDrawable(palette.panel, palette.line, 8) - setPadding(dp(11), dp(10), dp(11), dp(11)) - addView(text(title, 15f, Typeface.BOLD)) - val params = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, fixedHeight ?: ViewGroup.LayoutParams.WRAP_CONTENT) - addView(body.withTopMargin(9), params) - }.withBottomMargin(10) - - private fun horizontalButtons(items: List Unit>>): View = - LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - items.forEach { (title, action) -> - addView(button(title, onClick = action), LinearLayout.LayoutParams(0, dp(40), 1f).apply { setMargins(dp(3), 0, dp(3), 0) }) - } + private fun modelLabel(value: String): String = + when { + value.contains("gru", ignoreCase = true) -> "PyTorch GRU" + value.contains("lstm", ignoreCase = true) -> "PyTorch LSTM" + value.isBlank() -> "нет данных" + else -> value } - private fun button( - title: String, - selected: Boolean = false, - onClick: () -> Unit, - ): TextView = - TextView(this).apply { - text = title - textSize = 13f - typeface = Typeface.DEFAULT_BOLD - gravity = Gravity.CENTER - setTextColor(if (selected) android.graphics.Color.WHITE else palette.text) - background = boxDrawable(if (selected) palette.blue else palette.panelAlt, if (selected) palette.blue else palette.line, 5) - setOnClickListener { onClick() } - isClickable = true - isFocusable = false - isFocusableInTouchMode = false + private fun yesNo(value: Boolean): String = if (value) "да" else "нет" + + private fun colorForSigned(value: String): Int = + when { + value.trim().startsWith("-") -> palette.red + value.trim().startsWith("+") -> palette.green + else -> palette.text } - private fun label(title: String, kind: String): TextView = - TextView(this).apply { - text = title - textSize = 12f - typeface = Typeface.DEFAULT_BOLD - gravity = Gravity.CENTER - setPadding(dp(8), dp(5), dp(8), dp(5)) - styleLabel(this, kind) + private fun colorForSigned(value: Double): Int = + when { + value > 0.0 -> palette.green + value < 0.0 -> palette.red + else -> palette.text } - private fun styleLabel(view: TextView, kind: String) { - val color = when (kind) { - "ok" -> palette.green - "bad" -> palette.red - else -> palette.amber - } - view.setTextColor(color) - view.background = boxDrawable(withAlpha(color, if (palette.isDark) 34 else 22), color, 4) - } + private fun money(value: Double, digits: Int = 2): String = + "%,.${digits}f USDT".format(Locale.US, value) - private fun text( - value: String, - size: Float, - style: Int, - color: Int = palette.text, - ): TextView = - TextView(this).apply { - text = value - textSize = size - setTextColor(color) - typeface = Typeface.create(Typeface.DEFAULT, style) - includeFontPadding = true + private fun signedMoney(value: Double): String = + "${if (value >= 0) "+" else ""}${money(value)}" + + private fun price(value: Double): String = + when { + value >= 1000.0 -> "%,.1f".format(Locale.US, value) + value >= 1.0 -> "%,.4f".format(Locale.US, value) + value > 0.0 -> "%.8f".format(Locale.US, value).trimEnd('0').trimEnd('.') + else -> "0" } - private fun input(hint: String, value: String): EditText = - EditText(this).apply { - setText(value) - this.hint = hint - textSize = 13f - setSingleLine(true) - setTextColor(palette.text) - setHintTextColor(palette.muted) - setPadding(dp(10), 0, dp(10), 0) - background = boxDrawable(palette.panelAlt, palette.line, 5) - minHeight = dp(44) - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI - } + private fun number(value: Double, digits: Int = 2): String = + "%,.${digits}f".format(Locale.US, value) - private fun scrollContent(): LinearLayout = - LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setPadding(dp(12), dp(8), dp(12), dp(18)) - } + private fun percent(value: Double, digits: Int = 2): String = + "%.${digits}f%%".format(Locale.US, value) - private fun wrapScroll(content: View): View = - ScrollView(this).apply { - setBackgroundColor(palette.page) - addView(content) - } + private fun signedPercent(value: Double, digits: Int = 2): String = + "${if (value >= 0) "+" else ""}${percent(value, digits)}" - private fun emptyState(message: String = lastError.ifBlank { "Нет данных от API" }): View = - panel("Состояние", text(message, 13f, Typeface.NORMAL, palette.muted)) + private fun probability(value: Double): String = + percent(normalizedProbability(value), 0) + + private fun normalizedProbability(value: Double): Double = + if (abs(value) <= 1.0) value * 100.0 else value private fun boxDrawable(fill: Int, stroke: Int, radiusDp: Int): GradientDrawable = GradientDrawable().apply { setColor(fill) cornerRadius = dp(radiusDp).toFloat() - setStroke(dp(1), stroke) + if (stroke != Color.TRANSPARENT) { + setStroke(dp(1), stroke) + } } - private fun View.withTopMargin(value: Int): View = + private fun withAlpha(color: Int, alpha: Int): Int = + Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + + private fun View.top(value: Int): View = apply { layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { setMargins(0, value, 0, 0) } } - private fun View.withBottomMargin(value: Int): View = + private fun View.left(value: Int): View = apply { - layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { - setMargins(0, 0, 0, value) + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + setMargins(value, 0, 0, 0) } } @@ -730,84 +1046,20 @@ class MainActivity : Activity() { Toast.makeText(this, message, Toast.LENGTH_LONG).show() } - private fun money(value: Double, digits: Int = 2): String = - "%,.${digits}f USDT".format(Locale.US, value) - - private fun signedMoney(value: Double): String = - "${if (value >= 0.0) "+" else ""}${money(value)}" - - private fun price(value: Double): String { - if (value == 0.0) return "нет данных" - val digits = when { - abs(value) >= 100.0 -> 2 - abs(value) >= 1.0 -> 4 - else -> 8 - } - return "%,.${digits}f".format(Locale.US, value) + private companion object { + val FIXED_SYMBOLS = listOf( + "BTCUSDT", + "ETHUSDT", + "HYPEUSDT", + "SOLUSDT", + "XRPUSDT", + "XPLUSDT", + "WLDUSDT", + "MNTUSDT", + "HUSDT", + "XAUTUSDT", + "IPUSDT", + "AAVEUSDT", + ) } - - private fun number(value: Double, digits: Int = 2): String = - "%,.${digits}f".format(Locale.US, value) - - private fun percent(value: Double, digits: Int = 2): String = - "${number(value, digits)}%" - - private fun signedPercent(value: Double, digits: Int = 2): String = - "${if (value >= 0.0) "+" else ""}${percent(value, digits)}" - - private fun probability(value: Double): String = - percent(value * 100.0, 1) - - private fun yesNo(value: Boolean): String = - if (value) "да" else "нет" - - private fun modeLabel(value: String): String = - when (value) { - "paper" -> "paper, симуляция" - "live" -> "live, реальные ордера" - else -> value.ifBlank { "нет данных" } - } - - private fun modelLabel(value: String): String = - when (value) { - "torch_lstm" -> "PyTorch LSTM" - "torch_gru" -> "PyTorch GRU" - "none" -> "нет валидной Torch-модели" - else -> value.ifBlank { "нет данных" } - } - - private fun gateLabel(value: Boolean?): String = - when (value) { - true -> "пройден" - false -> "не пройден" - null -> "нет данных" - } - - private fun qualityLabel(value: String): String = - when (value) { - "ok" -> "норма" - "warn" -> "предупреждение" - "error" -> "ошибка" - else -> value.ifBlank { "нет данных" } - } - - private fun actionLabel(value: String): String = - when (value) { - "BUY" -> "Покупка" - "SELL" -> "Продажа" - "HOLD" -> "Ждать" - else -> value.ifBlank { "нет данных" } - } - - private fun colorForSigned(value: String): Int = - when { - value.startsWith("+") -> palette.green - value.startsWith("-") -> palette.red - else -> palette.text - } - - private fun withAlpha(color: Int, alpha: Int): Int = - android.graphics.Color.argb(alpha, android.graphics.Color.red(color), android.graphics.Color.green(color), android.graphics.Color.blue(color)) - - data class Metric(val name: String, val value: String, val hint: String) } diff --git a/android/TradeBotMonitor/app/src/main/res/drawable/ic_launcher.xml b/android/TradeBotMonitor/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..a2b9a57 --- /dev/null +++ b/android/TradeBotMonitor/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + diff --git a/android/TradeBotMonitor/design/option2-ai-console-main.png b/android/TradeBotMonitor/design/option2-ai-console-main.png new file mode 100644 index 0000000..e0079c7 Binary files /dev/null and b/android/TradeBotMonitor/design/option2-ai-console-main.png differ diff --git a/android/TradeBotMonitor/design/option2-ai-console-markets.png b/android/TradeBotMonitor/design/option2-ai-console-markets.png new file mode 100644 index 0000000..7fb9e36 Binary files /dev/null and b/android/TradeBotMonitor/design/option2-ai-console-markets.png differ diff --git a/android/TradeBotMonitor/design/option2-ai-console-mockups.png b/android/TradeBotMonitor/design/option2-ai-console-mockups.png new file mode 100644 index 0000000..17445c2 Binary files /dev/null and b/android/TradeBotMonitor/design/option2-ai-console-mockups.png differ diff --git a/android/TradeBotMonitor/design/option2-ai-console-mockups.svg b/android/TradeBotMonitor/design/option2-ai-console-mockups.svg new file mode 100644 index 0000000..c9999ca --- /dev/null +++ b/android/TradeBotMonitor/design/option2-ai-console-mockups.svg @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + Вариант 2: AI Trading Console + Фокус не на ручной торговле, а на решении Torch, риске Kelly и контроле бота. Стиль: строгий биржевой интерфейс без пузырей. + + + + + + 22:18 + + 91% + + AI Торговля + + PAPER + + BTC/USDT + 67 240.5 + +0.84% + Edge +0.82% · P(up) 64% · 1h + Equity + 108.42 USDT + + + График + Torch + Риск + История + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 67 240 + EMA50 + EMA200 + 26.06 22:00 + + + + + Решение Torch + Покупка разрешена + Kelly + 18.4 USDT + + Причина + Тренд 1D выше EMA200, MACD вверх + + + + + Рынки + AI + Риск + Активы + Настройки + + + + + + + + 22:18 + + 91% + Рынки + 12 фиксированных spot-пар + + Поиск пары или сигнала + + AI-рейтинг + Цена + Edge + Статус + + + + + BTCUSDTP(up) 64% · Kelly 18.4 + 67 240 + +0.82% + BUY + + ETHUSDTP(up) 57% · Kelly 6.13 524+0.31%WAIT + HYPEUSDTP(up) 49% · Kelly 0.039.18-0.14%BLOCK + SOLUSDTP(up) 61% · Kelly 12.7144.62+0.54%BUY + XRPUSDTP(up) 53% · Kelly 2.22.18+0.08%WAIT + XPLUSDTP(up) 46% · Kelly 0.00.923-0.22%SKIP + WLDUSDTP(up) 58% · Kelly 5.41.12+0.27%WAIT + MNTUSDTP(up) 52% · Kelly 1.41.05+0.05%WAIT + HUSDTP(up) 44% · Kelly 0.00.073-0.35%SKIP + + + + + Рынки + AI + Риск + Активы + Настройки + + + + + + + + 22:18 + + 91% + + Сделка и риск + BTCUSDT · решение Torch + + Действие + BUY + Размер сейчас + 18.4 USDT + Риск + 0.7% + + + Kelly распределение + + Цель по паре31.0 USDT + Уже занято12.6 USDT + Можно добавить18.4 USDT + + + + + Почему модель так решила + Edge: +0.82% + P(up): 64% + EMA50 выше EMA200 на 1D + MACD пересёк signal вверх на 1h + RSI 58, ATR в норме, спред 0.03% + + + + + Live готовность + 2 шага не пройдены + API ключи · лимит ордера + + + + + Стоп бот + + Запустить цикл + + + + Все действия требуют серверного подтверждения. Приложение показывает риск до сделки. + + + diff --git a/android/TradeBotMonitor/design/option2-ai-console-risk.png b/android/TradeBotMonitor/design/option2-ai-console-risk.png new file mode 100644 index 0000000..f8a35d3 Binary files /dev/null and b/android/TradeBotMonitor/design/option2-ai-console-risk.png differ diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index a25bd60..8c8c4fa 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -5,7 +5,7 @@ from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI, Response -from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse +from fastapi.responses import JSONResponse, PlainTextResponse from crypto_spot_bot.analytics import analytics_snapshot from crypto_spot_bot.bot import CryptoSpotBot @@ -21,6 +21,9 @@ from crypto_spot_bot.strategy import SpotStrategy from crypto_spot_bot.time_series import TimeSeriesForecaster +WEB_UI_REMOVED_MESSAGE = "Web UI removed. Use the Android TradeBot AI app and /api/* endpoints." + + def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or load_settings() storage = Storage(settings.database_path) @@ -54,9 +57,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.state.bot = bot app.state.market = market - @app.get("/", response_class=HTMLResponse) + @app.get("/", response_class=PlainTextResponse, status_code=410) async def index() -> str: - return HTML + return WEB_UI_REMOVED_MESSAGE @app.get("/api/health") async def health() -> dict[str, Any]: @@ -390,1475 +393,3 @@ def _forecast_model_label(model: str, *, torch_artifact: bool = False) -> str: if normalized == "gru": return "устаревший артефакт" return model - - - -HTML = r""" - - - - - - TradeBot - русская панель - - - -
-
-
-

TradeBot: торговая панель

-
Загружаю состояние бота, рынки и прогнозы Torch...
-
-
- Проверка - - - -
-
- -
- -
- - - - -""" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 1473add..2724dd8 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -4,7 +4,7 @@ import json from crypto_spot_bot.dashboard import _apply_fast_trading from crypto_spot_bot.dashboard import _safe_config -from crypto_spot_bot.dashboard import HTML +from crypto_spot_bot.dashboard import WEB_UI_REMOVED_MESSAGE from crypto_spot_bot.storage import Storage @@ -58,9 +58,6 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path) } -def test_dashboard_html_is_russian_torch_workspace() -> None: - assert "Рынки и Torch" in HTML - assert "Что видит нейросеть перед прогнозом" in HTML - assert "Свечи" in HTML - assert "class=\"pill" not in HTML - assert ".pill" not in HTML +def test_web_ui_is_removed_from_api_service() -> None: + assert "Web UI removed" in WEB_UI_REMOVED_MESSAGE + assert "/api/*" in WEB_UI_REMOVED_MESSAGE