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")