diff --git a/.gitignore b/.gitignore
index e43b44c..55ce3cc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,7 @@ venv/
.env
runtime/
*.log
+android/**/.gradle/
+android/**/build/
+android/**/*.apk
+android/**/*.aab
diff --git a/README.md b/README.md
index 44e3ba7..c938102 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Spot-бот для демо-торговли криптовалютой на р
- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`.
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
+- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для просмотра 12 пар, свечей, Torch/Kelly параметров, расписания удалённого retrain и live-чеклиста.
- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`.
- Health endpoint `/api/health` и Prometheus-compatible `/metrics`.
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост.
diff --git a/android/TradeBotMonitor/README.md b/android/TradeBotMonitor/README.md
new file mode 100644
index 0000000..55800f6
--- /dev/null
+++ b/android/TradeBotMonitor/README.md
@@ -0,0 +1,57 @@
+# TradeBot Monitor для Android
+
+Нативное Android-приложение для наблюдения за ботом и удалённого управления переобучением.
+
+## Что есть в приложении
+
+- Русский интерфейс без bubble/pill-оформления.
+- Современная биржевая компоновка: список пар, один выбранный график, компактные параметры ниже.
+- Свечной график 1h: тела свечей, фитили, объём, EMA50, EMA200, последняя цена.
+- Параметры Torch: edge, P(up), confidence, skill, quantiles, gate, причина решения.
+- Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill.
+- Обзор equity/cash/exposure/PnL и последних решений.
+- Удалённый запуск retrain через настраиваемый webhook.
+- Расписание retrain на телефоне: Android отправляет команду по расписанию, но обучение идёт на Windows-машине.
+- Настройки API, токена команд, тёмной/светлой темы.
+- Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой.
+
+## Сборка
+
+```powershell
+cd C:\Repos\TradeBot\android\TradeBotMonitor
+gradle :app:assembleDebug
+```
+
+APK появится в:
+
+```text
+android/TradeBotMonitor/app/build/outputs/apk/debug/app-debug.apk
+```
+
+## Подключение к боту
+
+В настройках приложения укажи адрес API, например:
+
+```text
+http://192.168.0.185:8787
+```
+
+Важно: сейчас Docker на Pi пробрасывает порт как `127.0.0.1:8787:8787`, то есть API доступен только на самом Pi. Для телефона нужен безопасный доступ по LAN/VPN/reverse proxy или осознанное изменение проброса порта.
+
+## Переобучение
+
+Телефон не обучает модель локально. Вкладка `Обучение` отправляет POST на указанный webhook Windows-машины. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере.
+
+## Live-торговля
+
+Вкладка `Настройки` показывает live-чеклист:
+
+- бот остановлен перед переключением;
+- на сервере выставлен `TRADING_MODE=live`;
+- используется mainnet, не testnet;
+- задано `ENABLE_LIVE_TRADING=true`;
+- задано `LIVE_TRADING_CONFIRM=I_ACCEPT_REAL_RISK`;
+- Bybit API key/secret настроены на сервере;
+- лимиты live-ордера, risk и Kelly проверены.
+
+Приложение может отправить команды остановки/запуска цикла, но не включает реальные ордера без серверного `.env` и подтверждения риска.
diff --git a/android/TradeBotMonitor/app/build.gradle.kts b/android/TradeBotMonitor/app/build.gradle.kts
new file mode 100644
index 0000000..c1e9d4b
--- /dev/null
+++ b/android/TradeBotMonitor/app/build.gradle.kts
@@ -0,0 +1,16 @@
+plugins {
+ id("com.android.application")
+}
+
+android {
+ namespace = "xyz.kusoft.tradebotmonitor"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "xyz.kusoft.tradebotmonitor"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+}
diff --git a/android/TradeBotMonitor/app/src/main/AndroidManifest.xml b/android/TradeBotMonitor/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1bc8e2d
--- /dev/null
+++ b/android/TradeBotMonitor/app/src/main/AndroidManifest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..04327e9
--- /dev/null
+++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPalette.kt
@@ -0,0 +1,53 @@
+package xyz.kusoft.tradebotmonitor
+
+import android.graphics.Color
+
+data class AppPalette(
+ val isDark: Boolean,
+ val page: Int,
+ val panel: Int,
+ val panelAlt: Int,
+ val line: Int,
+ val text: Int,
+ val muted: Int,
+ val green: Int,
+ val red: Int,
+ val amber: Int,
+ val blue: Int,
+ val chartBg: Int,
+ val chartGrid: Int,
+) {
+ 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),
+ )
+
+ fun light() = AppPalette(
+ isDark = false,
+ page = Color.rgb(243, 245, 247),
+ panel = Color.WHITE,
+ panelAlt = Color.rgb(247, 249, 251),
+ line = Color.rgb(216, 224, 232),
+ text = Color.rgb(20, 27, 36),
+ muted = Color.rgb(92, 106, 122),
+ green = Color.rgb(18, 134, 88),
+ red = Color.rgb(194, 65, 63),
+ amber = Color.rgb(173, 116, 24),
+ blue = Color.rgb(37, 99, 235),
+ chartBg = Color.rgb(13, 18, 25),
+ chartGrid = Color.rgb(44, 56, 70),
+ )
+ }
+}
diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPrefs.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPrefs.kt
new file mode 100644
index 0000000..1fb7bd1
--- /dev/null
+++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/AppPrefs.kt
@@ -0,0 +1,35 @@
+package xyz.kusoft.tradebotmonitor
+
+import android.content.Context
+
+class AppPrefs(context: Context) {
+ private val prefs = context.getSharedPreferences("tradebot_monitor", Context.MODE_PRIVATE)
+
+ var apiBaseUrl: String
+ get() = prefs.getString("api_base_url", "http://192.168.0.185:8787") ?: "http://192.168.0.185:8787"
+ set(value) = prefs.edit().putString("api_base_url", value.trim().trimEnd('/')).apply()
+
+ var commandToken: String
+ get() = prefs.getString("command_token", "") ?: ""
+ set(value) = prefs.edit().putString("command_token", value.trim()).apply()
+
+ var retrainWebhookUrl: String
+ get() = prefs.getString("retrain_webhook_url", "") ?: ""
+ set(value) = prefs.edit().putString("retrain_webhook_url", value.trim()).apply()
+
+ var selectedSymbol: String
+ get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT"
+ set(value) = prefs.edit().putString("selected_symbol", value).apply()
+
+ var themeMode: String
+ get() = prefs.getString("theme_mode", "dark") ?: "dark"
+ set(value) = prefs.edit().putString("theme_mode", value).apply()
+
+ var retrainScheduleEnabled: Boolean
+ get() = prefs.getBoolean("retrain_schedule_enabled", false)
+ set(value) = prefs.edit().putBoolean("retrain_schedule_enabled", value).apply()
+
+ var retrainIntervalHours: Int
+ get() = prefs.getInt("retrain_interval_hours", 6)
+ set(value) = prefs.edit().putInt("retrain_interval_hours", value.coerceAtLeast(1)).apply()
+}
diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/CandleChartView.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/CandleChartView.kt
new file mode 100644
index 0000000..27b3160
--- /dev/null
+++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/CandleChartView.kt
@@ -0,0 +1,230 @@
+package xyz.kusoft.tradebotmonitor
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.view.View
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import kotlin.math.max
+import kotlin.math.min
+
+class CandleChartView(context: Context) : View(context) {
+ var candles: List = emptyList()
+ set(value) {
+ field = value.takeLast(90)
+ invalidate()
+ }
+
+ var palette: AppPalette = AppPalette.dark()
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ textSize = 11f * resources.displayMetrics.scaledDensity
+ typeface = android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)
+ }
+ private val dateFormat = SimpleDateFormat("dd.MM HH:mm", Locale("ru", "RU"))
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ canvas.drawColor(palette.chartBg)
+ val rows = candles.filter { it.high > 0.0 && it.low > 0.0 }
+ if (rows.size < 2) {
+ drawEmpty(canvas)
+ return
+ }
+
+ val left = dp(8).toFloat()
+ val right = width - dp(62).toFloat()
+ val top = dp(12).toFloat()
+ val bottom = height - dp(24).toFloat()
+ val volumeHeight = max(dp(44).toFloat(), height * 0.17f)
+ val chartBottom = bottom - volumeHeight - dp(8)
+ val chartHeight = max(dp(120).toFloat(), chartBottom - top)
+ val plotWidth = right - left
+
+ val values = mutableListOf()
+ rows.forEach {
+ values += it.high
+ values += it.low
+ it.ema50?.let(values::add)
+ it.ema200?.let(values::add)
+ }
+ var minPrice = values.minOrNull() ?: 0.0
+ var maxPrice = values.maxOrNull() ?: 1.0
+ val rawRange = max(maxPrice - minPrice, maxPrice * 0.0001)
+ minPrice -= rawRange * 0.08
+ maxPrice += rawRange * 0.08
+ val range = max(maxPrice - minPrice, 1e-9)
+
+ fun y(value: Double): Float = (top + ((maxPrice - value) / range).toFloat() * chartHeight)
+ fun x(index: Int): Float {
+ val step = plotWidth / rows.size
+ return left + index * step + step / 2f
+ }
+
+ drawGrid(canvas, left, right, top, chartHeight, minPrice, maxPrice)
+ drawVolumes(canvas, rows, left, plotWidth, chartBottom + dp(8), volumeHeight - dp(4))
+ val bodyWidth = (plotWidth / rows.size * 0.62f).coerceIn(dp(3).toFloat(), dp(10).toFloat())
+ rows.forEachIndexed { index, candle -> drawCandle(canvas, candle, x(index), bodyWidth, ::y) }
+ drawAverageLine(canvas, rows, { it.ema50 }, ::x, ::y, android.graphics.Color.rgb(147, 197, 253))
+ drawAverageLine(canvas, rows, { it.ema200 }, ::x, ::y, android.graphics.Color.rgb(245, 158, 11))
+ drawLastPrice(canvas, rows.last(), left, right, ::y)
+ drawTimeAxis(canvas, rows, left, plotWidth, height - dp(7).toFloat())
+ }
+
+ private fun drawEmpty(canvas: Canvas) {
+ textPaint.color = android.graphics.Color.rgb(148, 163, 184)
+ textPaint.textAlign = Paint.Align.LEFT
+ canvas.drawText("Нет свечей для графика", dp(14).toFloat(), dp(30).toFloat(), textPaint)
+ }
+
+ private fun drawGrid(
+ canvas: Canvas,
+ left: Float,
+ right: Float,
+ top: Float,
+ chartHeight: Float,
+ minPrice: Double,
+ maxPrice: Double,
+ ) {
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = 1f
+ paint.color = palette.chartGrid
+ textPaint.color = android.graphics.Color.rgb(154, 167, 183)
+ textPaint.textAlign = Paint.Align.RIGHT
+ for (i in 0..5) {
+ val ratio = i / 5f
+ val yy = top + chartHeight * ratio
+ canvas.drawLine(left, yy, right + dp(4), yy, paint)
+ val price = maxPrice - (maxPrice - minPrice) * ratio
+ canvas.drawText(compactPrice(price), width - dp(5).toFloat(), yy + dp(4), textPaint)
+ }
+ }
+
+ private fun drawVolumes(
+ canvas: Canvas,
+ rows: List,
+ left: Float,
+ plotWidth: Float,
+ top: Float,
+ height: Float,
+ ) {
+ val maxVolume = max(rows.maxOf { it.volume }, 1.0)
+ val step = plotWidth / rows.size
+ val barWidth = (step * 0.56f).coerceIn(dp(2).toFloat(), dp(8).toFloat())
+ paint.style = Paint.Style.FILL
+ rows.forEachIndexed { index, candle ->
+ val up = candle.close >= candle.open
+ paint.color = if (up) withAlpha(palette.green, 90) else withAlpha(palette.red, 90)
+ val barHeight = max(1f, (candle.volume / maxVolume).toFloat() * height)
+ val x = left + index * step + step / 2f - barWidth / 2f
+ canvas.drawRect(x, top + height - barHeight, x + barWidth, top + height, paint)
+ }
+ }
+
+ private fun drawCandle(
+ canvas: Canvas,
+ candle: Candle,
+ x: Float,
+ bodyWidth: Float,
+ y: (Double) -> Float,
+ ) {
+ val up = candle.close >= candle.open
+ val color = if (up) palette.green else palette.red
+ paint.color = color
+ paint.strokeWidth = 1.1f
+ paint.style = Paint.Style.STROKE
+ canvas.drawLine(x, y(candle.high), x, y(candle.low), paint)
+
+ val top = y(max(candle.open, candle.close))
+ val bottom = y(min(candle.open, candle.close))
+ paint.style = Paint.Style.FILL
+ val bodyHeight = max(1.5f, bottom - top)
+ canvas.drawRect(RectF(x - bodyWidth / 2f, top, x + bodyWidth / 2f, top + bodyHeight), paint)
+ }
+
+ private fun drawAverageLine(
+ canvas: Canvas,
+ rows: List,
+ getter: (Candle) -> Double?,
+ x: (Int) -> Float,
+ y: (Double) -> Float,
+ color: Int,
+ ) {
+ paint.color = color
+ paint.strokeWidth = 1.35f
+ paint.style = Paint.Style.STROKE
+ var started = false
+ rows.forEachIndexed { index, candle ->
+ val value = getter(candle) ?: return@forEachIndexed
+ if (value <= 0.0) return@forEachIndexed
+ if (!started) {
+ canvas.save()
+ paint.pathEffect = null
+ canvas.restore()
+ started = true
+ val xx = x(index)
+ val yy = y(value)
+ canvas.drawPoint(xx, yy, paint)
+ paint.strokeWidth = 1.35f
+ android.graphics.Path().also { path ->
+ path.moveTo(xx, yy)
+ for (next in index + 1 until rows.size) {
+ val nextValue = getter(rows[next]) ?: continue
+ if (nextValue > 0.0) path.lineTo(x(next), y(nextValue))
+ }
+ canvas.drawPath(path, paint)
+ }
+ return
+ }
+ }
+ }
+
+ private fun drawLastPrice(canvas: Canvas, candle: Candle, left: Float, right: Float, y: (Double) -> Float) {
+ val yy = y(candle.close)
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = 1f
+ paint.color = android.graphics.Color.rgb(229, 231, 235)
+ paint.pathEffect = android.graphics.DashPathEffect(floatArrayOf(8f, 6f), 0f)
+ canvas.drawLine(left, yy, right + dp(4), yy, paint)
+ paint.pathEffect = null
+
+ textPaint.color = android.graphics.Color.rgb(229, 231, 235)
+ textPaint.textAlign = Paint.Align.RIGHT
+ canvas.drawText(compactPrice(candle.close), width - dp(5).toFloat(), yy - dp(3), textPaint)
+ }
+
+ private fun drawTimeAxis(canvas: Canvas, rows: List, left: Float, plotWidth: Float, baseline: Float) {
+ textPaint.color = android.graphics.Color.rgb(154, 167, 183)
+ textPaint.textAlign = Paint.Align.CENTER
+ val ticks = min(5, rows.size)
+ for (i in 0 until ticks) {
+ val index = ((rows.size - 1) * (i / max(1f, (ticks - 1).toFloat()))).toInt().coerceIn(0, rows.lastIndex)
+ val step = plotWidth / rows.size
+ val xx = left + index * step + step / 2f
+ val timestamp = rows[index].timestamp
+ val date = if (timestamp > 100_000_000_000L) Date(timestamp) else Date(timestamp * 1000L)
+ canvas.drawText(dateFormat.format(date), xx, baseline, textPaint)
+ }
+ }
+
+ private fun compactPrice(value: Double): String =
+ when {
+ value >= 1000.0 -> "%,.0f".format(Locale.US, value)
+ value >= 1.0 -> "%,.3f".format(Locale.US, value)
+ else -> "%.8f".format(Locale.US, value).trimEnd('0').trimEnd('.')
+ }
+
+ 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))
+
+ private fun dp(value: Int): Int =
+ (value * resources.displayMetrics.density).toInt()
+}
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
new file mode 100644
index 0000000..44b2fab
--- /dev/null
+++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt
@@ -0,0 +1,787 @@
+package xyz.kusoft.tradebotmonitor
+
+import android.app.Activity
+import android.graphics.Typeface
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.text.InputType
+import android.view.Gravity
+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
+import android.widget.Toast
+import org.json.JSONObject
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.concurrent.Executors
+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 val executor = Executors.newSingleThreadExecutor()
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var snapshot: BotSnapshot? = null
+ private var lastError: String = ""
+ private var activeTab: String = "markets"
+ private var palette: AppPalette = AppPalette.dark()
+
+ private val refreshRunnable = object : Runnable {
+ override fun run() {
+ refreshData(silent = true)
+ mainHandler.postDelayed(this, 5000L)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ prefs = AppPrefs(this)
+ palette = if (prefs.themeMode == "light") AppPalette.light() else AppPalette.dark()
+ buildShell()
+ refreshData(silent = false)
+ mainHandler.postDelayed(refreshRunnable, 5000L)
+ }
+
+ override fun onDestroy() {
+ mainHandler.removeCallbacks(refreshRunnable)
+ executor.shutdownNow()
+ super.onDestroy()
+ }
+
+ private fun buildShell() {
+ 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))
+ }
+ 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()
+ }
+
+ private fun refreshData(silent: Boolean) {
+ executor.execute {
+ try {
+ val data = TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).fetchSnapshot()
+ mainHandler.post {
+ snapshot = data
+ lastError = ""
+ ensureSelectedSymbol()
+ updateHeader()
+ render()
+ }
+ } catch (error: Exception) {
+ mainHandler.post {
+ lastError = error.message ?: "ошибка подключения"
+ updateHeader()
+ if (!silent) toast(lastError)
+ render()
+ }
+ }
+ }
+ }
+
+ private fun renderTabs() {
+ tabBar.removeAllViews()
+ listOf(
+ "overview" to "Обзор",
+ "markets" to "Рынки",
+ "training" to "Обучение",
+ "settings" to "Настройки",
+ ).forEach { (id, title) ->
+ tabBar.addView(
+ button(title, selected = activeTab == id) {
+ activeTab = id
+ renderTabs()
+ render()
+ },
+ LinearLayout.LayoutParams(0, dp(38), 1f).apply { setMargins(dp(3), 0, dp(3), 0) },
+ )
+ }
+ }
+
+ 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() {
+ contentHost.removeAllViews()
+ val view = when (activeTab) {
+ "overview" -> overviewScreen()
+ "training" -> trainingScreen()
+ "settings" -> settingsScreen()
+ else -> marketsScreen()
+ }
+ contentHost.addView(view)
+ }
+
+ private fun overviewScreen(): View {
+ val data = snapshot
+ val box = scrollContent()
+ if (data == null) {
+ box.addView(emptyState())
+ 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())))
+ return wrapScroll(box)
+ }
+
+ private fun marketsScreen(): View {
+ val data = snapshot
+ val box = scrollContent()
+ if (data == null) {
+ box.addView(emptyState())
+ 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(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())))
+ return wrapScroll(box)
+ }
+
+ private fun trainingScreen(): View {
+ val data = snapshot
+ val box = scrollContent()
+ box.addView(panel("Состояние переобучения", keyValueBlock(retrainRows(data?.retrain ?: JSONObject()))))
+
+ 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))
+ }))
+
+ box.addView(panel("Расписание retrain", scheduleBlock()))
+ box.addView(panel("Gate модели", keyValueBlock(gateRows(data?.backtest ?: JSONObject()))))
+ return wrapScroll(box)
+ }
+
+ private fun settingsScreen(): View {
+ val data = snapshot
+ val box = scrollContent()
+ val apiInput = input("Адрес API бота", prefs.apiBaseUrl)
+ val tokenInput = input("Токен команд, если включим защиту API", prefs.commandToken).apply {
+ inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ box.addView(panel("Подключение", LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ addView(apiInput)
+ addView(tokenInput.withTopMargin(8))
+ addView(horizontalButtons(listOf(
+ "Сохранить" to {
+ prefs.apiBaseUrl = apiInput.text.toString()
+ prefs.commandToken = tokenInput.text.toString()
+ refreshData(silent = false)
+ toast("Настройки подключения сохранены")
+ },
+ "Обновить" to { refreshData(silent = false) },
+ )).withTopMargin(10))
+ }))
+
+ box.addView(panel("Тема приложения", horizontalButtons(listOf(
+ "Тёмная" to {
+ prefs.themeMode = "dark"
+ palette = AppPalette.dark()
+ buildShell()
+ refreshData(silent = true)
+ },
+ "Светлая" to {
+ prefs.themeMode = "light"
+ palette = AppPalette.light()
+ buildShell()
+ refreshData(silent = true)
+ },
+ ))))
+
+ box.addView(panel("Переход к live-торговле", liveTransitionBlock(data)))
+ 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
+ }
+ row.addView(view, LinearLayout.LayoutParams(dp(104), dp(54)).apply { setMargins(0, 0, dp(7), 0) })
+ }
+ return HorizontalScrollView(this).apply {
+ isHorizontalScrollBarEnabled = false
+ addView(row)
+ }
+ }
+
+ 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> {
+ if (forecast == null) {
+ return listOf("Статус" to "Нет прогноза Torch")
+ }
+ 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() },
+ )
+ }
+
+ private fun kellyRows(signal: SignalData?): List> {
+ 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),
+ )
+ }
+
+ 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 liveTransitionBlock(data: BotSnapshot?): View {
+ val config = data?.config ?: JSONObject()
+ val running = data?.running ?: false
+ 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),
+ "Лимит 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
+ }
+
+ private fun scheduleBlock(): View {
+ 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
+ }
+
+ private fun setRetrainInterval(hours: Int) {
+ prefs.retrainIntervalHours = hours
+ if (prefs.retrainScheduleEnabled) {
+ RetrainScheduler.schedule(this, hours)
+ }
+ toast("Интервал retrain: $hours ч")
+ render()
+ }
+
+ private fun triggerRetrainNow() {
+ executor.execute {
+ try {
+ val message = RetrainCommandClient(prefs.retrainWebhookUrl, prefs.commandToken).triggerNow()
+ mainHandler.post { toast(message.take(180)) }
+ } catch (error: Exception) {
+ mainHandler.post { toast(error.message ?: "retrain не запущен") }
+ }
+ }
+ }
+
+ private fun sendControl(stop: Boolean) {
+ executor.execute {
+ try {
+ val api = TradeBotApi(prefs.apiBaseUrl, prefs.commandToken)
+ if (stop) api.stopBot() else api.startBot()
+ mainHandler.post {
+ toast(if (stop) "Команда остановки отправлена" else "Команда запуска отправлена")
+ refreshData(silent = true)
+ }
+ } catch (error: Exception) {
+ mainHandler.post { toast(error.message ?: "Команда не выполнена") }
+ }
+ }
+ }
+
+ private fun ensureSelectedSymbol() {
+ val data = snapshot ?: return
+ if (data.markets.none { it.symbol == prefs.selectedSymbol }) {
+ prefs.selectedSymbol = data.markets.firstOrNull()?.symbol ?: "BTCUSDT"
+ }
+ }
+
+ 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 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 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 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 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 = true
+ }
+
+ 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 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 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 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 scrollContent(): LinearLayout =
+ LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(dp(12), dp(8), dp(12), dp(18))
+ }
+
+ private fun wrapScroll(content: View): View =
+ ScrollView(this).apply {
+ setBackgroundColor(palette.page)
+ addView(content)
+ }
+
+ private fun emptyState(message: String = lastError.ifBlank { "Нет данных от API" }): View =
+ panel("Состояние", text(message, 13f, Typeface.NORMAL, palette.muted))
+
+ private fun boxDrawable(fill: Int, stroke: Int, radiusDp: Int): GradientDrawable =
+ GradientDrawable().apply {
+ setColor(fill)
+ cornerRadius = dp(radiusDp).toFloat()
+ setStroke(dp(1), stroke)
+ }
+
+ private fun View.withTopMargin(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 =
+ apply {
+ layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
+ setMargins(0, 0, 0, value)
+ }
+ }
+
+ private fun dp(value: Int): Int =
+ (value * resources.displayMetrics.density).toInt()
+
+ private fun toast(message: String) {
+ 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 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/java/xyz/kusoft/tradebotmonitor/Models.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt
new file mode 100644
index 0000000..c0a303d
--- /dev/null
+++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt
@@ -0,0 +1,124 @@
+package xyz.kusoft.tradebotmonitor
+
+import org.json.JSONObject
+
+data class Candle(
+ val timestamp: Long,
+ val open: Double,
+ val high: Double,
+ val low: Double,
+ val close: Double,
+ val volume: Double,
+ val ema50: Double?,
+ val ema200: Double?,
+ val rsi14: Double?,
+ val atr14: Double?,
+ val macd: Double?,
+ val macdSignal: Double?,
+)
+
+data class TickerData(
+ val symbol: String,
+ val lastPrice: Double,
+ val bid: Double,
+ val ask: Double,
+ val turnover24h: Double,
+ val volume24h: Double,
+ val change24h: Double,
+ val spreadPercent: Double,
+)
+
+data class FeatureItem(
+ val group: String,
+ val label: String,
+ val rawDisplay: String,
+ val modelDisplay: String,
+ val interpretation: String,
+)
+
+data class ForecastData(
+ val model: String,
+ val expectedReturnPercent: Double,
+ val probabilityUp: Double,
+ val skill: Double,
+ val volatilityPercent: Double,
+ val horizon: Int,
+ val q10Percent: Double,
+ val q50Percent: Double,
+ val q90Percent: Double,
+ val qualityGatePassed: Boolean?,
+ val reason: String,
+ val features: List,
+)
+
+data class MarketItem(
+ val symbol: String,
+ val ticker: TickerData?,
+ val candles: List,
+ val forecast: ForecastData?,
+ val qualityStatus: String,
+ val qualityScore: Double,
+)
+
+data class SignalData(
+ val symbol: String,
+ val action: String,
+ val confidence: Double,
+ val reason: String,
+ val diagnostics: JSONObject,
+) {
+ val expectedReturnPercent: Double
+ get() = diagnostics.optDoubleOrNull("expected_return_percent")
+ ?: diagnostics.optJSONObject("forecast")?.optDoubleOrNull("expected_return_percent")
+ ?: 0.0
+
+ val probabilityUp: Double
+ get() = diagnostics.optDoubleOrNull("probability_up")
+ ?: diagnostics.optJSONObject("forecast")?.optDoubleOrNull("probability_up")
+ ?: 0.0
+
+ val positionNotionalUsdt: Double
+ get() = diagnostics.optDoubleOrNull("position_notional_usdt")
+ ?: diagnostics.optJSONObject("position_sizing")?.optDoubleOrNull("notional_usdt")
+ ?: 0.0
+}
+
+data class PositionData(
+ val symbol: String,
+ val qty: Double,
+ val entryPrice: Double,
+ val markPrice: Double,
+ val notionalUsdt: Double,
+ val marketValue: Double,
+ val unrealizedPnl: Double,
+ val unrealizedPnlPercent: Double,
+)
+
+data class AccountData(
+ val equity: Double,
+ val cash: Double,
+ val exposure: Double,
+)
+
+data class BotSnapshot(
+ val ok: Boolean,
+ val running: Boolean,
+ val mode: String,
+ val account: AccountData,
+ val positions: List,
+ val markets: List,
+ val signalsBySymbol: Map,
+ val config: JSONObject,
+ val retrain: JSONObject,
+ val backtest: JSONObject,
+ val fetchedAtMillis: Long,
+)
+
+fun JSONObject.optStringClean(name: String): String =
+ if (isNull(name)) "" else optString(name, "")
+
+fun JSONObject.optDoubleOrNull(name: String): Double? =
+ if (has(name) && !isNull(name)) optDouble(name) else null
+
+fun JSONObject.optBooleanOrNull(name: String): Boolean? =
+ if (has(name) && !isNull(name)) optBoolean(name) else null
diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/RetrainScheduler.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/RetrainScheduler.kt
new file mode 100644
index 0000000..3a96c6e
--- /dev/null
+++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/RetrainScheduler.kt
@@ -0,0 +1,63 @@
+package xyz.kusoft.tradebotmonitor
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import java.util.concurrent.Executors
+
+object RetrainScheduler {
+ private const val ACTION_RETRAIN = "xyz.kusoft.tradebotmonitor.RETRAIN"
+ private const val REQUEST_CODE = 6406
+
+ fun schedule(context: Context, hours: Int) {
+ val interval = hours.coerceAtLeast(1) * 60L * 60L * 1000L
+ val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ manager.setInexactRepeating(
+ AlarmManager.RTC_WAKEUP,
+ System.currentTimeMillis() + interval,
+ interval,
+ pendingIntent(context),
+ )
+ }
+
+ fun cancel(context: Context) {
+ val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ manager.cancel(pendingIntent(context))
+ }
+
+ private fun pendingIntent(context: Context): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ REQUEST_CODE,
+ Intent(context, RetrainAlarmReceiver::class.java).setAction(ACTION_RETRAIN),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+}
+
+class RetrainAlarmReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val pending = goAsync()
+ Executors.newSingleThreadExecutor().execute {
+ try {
+ val prefs = AppPrefs(context)
+ if (prefs.retrainScheduleEnabled && prefs.retrainWebhookUrl.isNotBlank()) {
+ RetrainCommandClient(prefs.retrainWebhookUrl, prefs.commandToken).triggerNow()
+ }
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+}
+
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
+ val prefs = AppPrefs(context)
+ if (prefs.retrainScheduleEnabled) {
+ RetrainScheduler.schedule(context, prefs.retrainIntervalHours)
+ }
+ }
+}
diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt
new file mode 100644
index 0000000..3d5bf3b
--- /dev/null
+++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt
@@ -0,0 +1,246 @@
+package xyz.kusoft.tradebotmonitor
+
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.BufferedReader
+import java.io.InputStreamReader
+import java.net.HttpURLConnection
+import java.net.URL
+import java.nio.charset.StandardCharsets
+
+class TradeBotApi(
+ private val baseUrl: String,
+ 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 retrain = getJson("/api/retrain")
+ val backtest = getJson("/api/backtest")
+
+ val accountJson = status.optJSONObject("account") ?: JSONObject()
+ val account = AccountData(
+ equity = accountJson.optDouble("equity", 0.0),
+ cash = accountJson.optDouble("cash", 0.0),
+ exposure = accountJson.optDouble("exposure", 0.0),
+ )
+
+ return BotSnapshot(
+ ok = health.optBoolean("ok", false),
+ running = health.optBoolean("running", false),
+ mode = health.optStringClean("mode"),
+ account = account,
+ positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()),
+ markets = parseMarkets(markets.optJSONArray("markets") ?: JSONArray()),
+ signalsBySymbol = parseLatestSignals(signals.optJSONArray("items") ?: JSONArray()),
+ config = config,
+ retrain = retrain,
+ backtest = backtest,
+ fetchedAtMillis = System.currentTimeMillis(),
+ )
+ }
+
+ fun startBot() {
+ postJson("/api/control/start")
+ }
+
+ fun stopBot() {
+ postJson("/api/control/stop")
+ }
+
+ private fun getJson(path: String): JSONObject = request(path, "GET")
+
+ private fun postJson(path: String): JSONObject = request(path, "POST")
+
+ private fun request(path: String, method: String): JSONObject {
+ val root = baseUrl.trim().trimEnd('/')
+ val url = URL(root + path)
+ val connection = (url.openConnection() as HttpURLConnection).apply {
+ requestMethod = method
+ connectTimeout = 6000
+ readTimeout = 9000
+ setRequestProperty("Accept", "application/json")
+ if (token.isNotBlank()) {
+ setRequestProperty("X-TradeBot-Token", token)
+ }
+ if (method == "POST") {
+ doOutput = true
+ setRequestProperty("Content-Type", "application/json")
+ outputStream.use { stream -> stream.write("{}".toByteArray(StandardCharsets.UTF_8)) }
+ }
+ }
+ val code = connection.responseCode
+ val stream = if (code in 200..299) connection.inputStream else connection.errorStream
+ val text = stream?.bufferedReader(StandardCharsets.UTF_8)?.use(BufferedReader::readText).orEmpty()
+ connection.disconnect()
+ if (code !in 200..299) {
+ throw IllegalStateException("HTTP $code: ${text.take(240)}")
+ }
+ return if (text.isBlank()) JSONObject() else JSONObject(text)
+ }
+
+ private fun parsePositions(items: JSONArray): List {
+ val output = mutableListOf()
+ for (index in 0 until items.length()) {
+ val row = items.optJSONObject(index) ?: continue
+ output += PositionData(
+ symbol = row.optStringClean("symbol"),
+ qty = row.optDouble("qty", 0.0),
+ entryPrice = row.optDouble("entry_price", 0.0),
+ markPrice = row.optDouble("mark_price", 0.0),
+ notionalUsdt = row.optDouble("notional_usdt", 0.0),
+ marketValue = row.optDouble("market_value", 0.0),
+ unrealizedPnl = row.optDouble("unrealized_pnl", 0.0),
+ unrealizedPnlPercent = row.optDouble("unrealized_pnl_percent", 0.0),
+ )
+ }
+ return output
+ }
+
+ private fun parseMarkets(items: JSONArray): List {
+ val output = mutableListOf()
+ for (index in 0 until items.length()) {
+ val row = items.optJSONObject(index) ?: continue
+ val tickerJson = row.optJSONObject("ticker")
+ val instrument = row.optJSONObject("instrument") ?: JSONObject()
+ val symbol = tickerJson?.optStringClean("symbol").orEmpty().ifBlank {
+ instrument.optStringClean("symbol")
+ }
+ val quality = row.optJSONObject("quality") ?: JSONObject()
+ output += MarketItem(
+ symbol = symbol,
+ ticker = tickerJson?.let(::parseTicker),
+ candles = parseCandles(row.optJSONArray("candles") ?: JSONArray()),
+ forecast = row.optJSONObject("forecast")?.let(::parseForecast),
+ qualityStatus = quality.optStringClean("status"),
+ qualityScore = quality.optDouble("score", 0.0),
+ )
+ }
+ return output.sortedBy { it.symbol }
+ }
+
+ private fun parseTicker(row: JSONObject): TickerData =
+ TickerData(
+ symbol = row.optStringClean("symbol"),
+ lastPrice = row.optDouble("last_price", 0.0),
+ bid = row.optDouble("bid", 0.0),
+ ask = row.optDouble("ask", 0.0),
+ turnover24h = row.optDouble("turnover_24h", 0.0),
+ volume24h = row.optDouble("volume_24h", 0.0),
+ change24h = row.optDouble("change_24h", 0.0),
+ spreadPercent = row.optDouble("spread_percent", 0.0),
+ )
+
+ private fun parseCandles(items: JSONArray): List {
+ val output = mutableListOf()
+ for (index in 0 until items.length()) {
+ val row = items.optJSONObject(index) ?: continue
+ output += Candle(
+ timestamp = row.optLong("timestamp", 0L),
+ open = row.optDouble("open", 0.0),
+ high = row.optDouble("high", 0.0),
+ low = row.optDouble("low", 0.0),
+ close = row.optDouble("close", 0.0),
+ volume = row.optDouble("volume", 0.0),
+ ema50 = row.optDoubleOrNull("ema_50"),
+ ema200 = row.optDoubleOrNull("ema_200"),
+ rsi14 = row.optDoubleOrNull("rsi_14"),
+ atr14 = row.optDoubleOrNull("atr_14"),
+ macd = row.optDoubleOrNull("macd"),
+ macdSignal = row.optDoubleOrNull("macd_signal"),
+ )
+ }
+ return output
+ }
+
+ private fun parseForecast(row: JSONObject): ForecastData {
+ val featureItems = row.optJSONArray("feature_snapshot") ?: JSONArray()
+ val features = mutableListOf()
+ for (index in 0 until featureItems.length()) {
+ val feature = featureItems.optJSONObject(index) ?: continue
+ features += FeatureItem(
+ group = feature.optStringClean("group"),
+ label = feature.optStringClean("label").ifBlank { feature.optStringClean("name") },
+ rawDisplay = feature.optStringClean("raw_display").ifBlank {
+ feature.optStringClean("raw_value")
+ },
+ modelDisplay = feature.optStringClean("model_display").ifBlank {
+ feature.optStringClean("model_value")
+ },
+ interpretation = feature.optStringClean("interpretation").ifBlank {
+ feature.optStringClean("description")
+ },
+ )
+ }
+ return ForecastData(
+ model = row.optStringClean("model"),
+ expectedReturnPercent = row.optDouble("expected_return_percent", 0.0),
+ probabilityUp = row.optDouble("probability_up", 0.0),
+ skill = row.optDouble("skill", 0.0),
+ volatilityPercent = row.optDouble("volatility_percent", 0.0),
+ horizon = row.optInt("horizon", 0),
+ q10Percent = row.optDouble("quantile_10_percent", 0.0),
+ q50Percent = row.optDouble("quantile_50_percent", 0.0),
+ q90Percent = row.optDouble("quantile_90_percent", 0.0),
+ qualityGatePassed = row.optBooleanOrNull("quality_gate_passed"),
+ reason = row.optStringClean("reason"),
+ features = features,
+ )
+ }
+
+ private fun parseLatestSignals(items: JSONArray): Map {
+ val output = linkedMapOf()
+ for (index in 0 until items.length()) {
+ val row = items.optJSONObject(index) ?: continue
+ val symbol = row.optStringClean("symbol")
+ if (symbol.isBlank() || output.containsKey(symbol)) continue
+ val diagnostics = try {
+ JSONObject(row.optStringClean("diagnostics_json"))
+ } catch (_: Exception) {
+ JSONObject()
+ }
+ output[symbol] = SignalData(
+ symbol = symbol,
+ action = row.optStringClean("action"),
+ confidence = row.optDouble("confidence", 0.0),
+ reason = row.optStringClean("reason"),
+ diagnostics = diagnostics,
+ )
+ }
+ return output
+ }
+}
+
+class RetrainCommandClient(
+ private val webhookUrl: String,
+ private val token: String,
+) {
+ fun triggerNow(): String {
+ if (webhookUrl.isBlank()) {
+ throw IllegalStateException("URL запуска retrain не задан")
+ }
+ val connection = (URL(webhookUrl).openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ connectTimeout = 6000
+ readTimeout = 15000
+ doOutput = true
+ setRequestProperty("Content-Type", "application/json")
+ setRequestProperty("Accept", "application/json,text/plain,*/*")
+ if (token.isNotBlank()) {
+ setRequestProperty("X-TradeBot-Token", token)
+ }
+ outputStream.use { stream -> stream.write("{\"source\":\"android\"}".toByteArray(StandardCharsets.UTF_8)) }
+ }
+ val code = connection.responseCode
+ val stream = if (code in 200..299) connection.inputStream else connection.errorStream
+ val text = stream?.bufferedReader(StandardCharsets.UTF_8)?.use(BufferedReader::readText).orEmpty()
+ connection.disconnect()
+ if (code !in 200..299) {
+ throw IllegalStateException("Retrain HTTP $code: ${text.take(240)}")
+ }
+ return text.ifBlank { "Команда retrain отправлена" }
+ }
+}
diff --git a/android/TradeBotMonitor/app/src/main/res/values/styles.xml b/android/TradeBotMonitor/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..c42c897
--- /dev/null
+++ b/android/TradeBotMonitor/app/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/android/TradeBotMonitor/build.gradle.kts b/android/TradeBotMonitor/build.gradle.kts
new file mode 100644
index 0000000..fd4b881
--- /dev/null
+++ b/android/TradeBotMonitor/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ id("com.android.application") version "9.2.1" apply false
+}
diff --git a/android/TradeBotMonitor/gradle.properties b/android/TradeBotMonitor/gradle.properties
new file mode 100644
index 0000000..f1ca11b
--- /dev/null
+++ b/android/TradeBotMonitor/gradle.properties
@@ -0,0 +1,2 @@
+android.nonTransitiveRClass=true
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
diff --git a/android/TradeBotMonitor/settings.gradle.kts b/android/TradeBotMonitor/settings.gradle.kts
new file mode 100644
index 0000000..092866d
--- /dev/null
+++ b/android/TradeBotMonitor/settings.gradle.kts
@@ -0,0 +1,25 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+ resolutionStrategy {
+ eachPlugin {
+ if (requested.id.id == "org.jetbrains.kotlin.android") {
+ useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:${requested.version ?: "2.2.10"}")
+ }
+ }
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "TradeBotMonitor"
+include(":app")