Add Android TradeBot monitor app

This commit is contained in:
Codex
2026-06-26 21:09:44 +03:00
parent 93c51070c5
commit 2706242fc7
16 changed files with 1687 additions and 0 deletions
+4
View File
@@ -7,3 +7,7 @@ venv/
.env .env
runtime/ runtime/
*.log *.log
android/**/.gradle/
android/**/build/
android/**/*.apk
android/**/*.aab
+1
View File
@@ -18,6 +18,7 @@ Spot-бот для демо-торговли криптовалютой на р
- Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`. - Grid, rebound, adaptive learning, Kelly sizing и time-series forecast выключены по умолчанию и не участвуют в принятии решений `trend_macd`.
- Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются. - Быстрый режим торговли: отдельный короткий интервал цикла, короткий cooldown после выхода и лимит новых входов в минуту; выходы по риску этим лимитом не блокируются.
- Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения. - Веб-dashboard на русском: equity, cash, PnL, позиции, сделки, сигналы, события, свечные графики, переключатель быстрой торговли и индикаторы работы обучения.
- Android-монитор в `android/TradeBotMonitor`: русский мобильный интерфейс для просмотра 12 пар, свечей, Torch/Kelly параметров, расписания удалённого retrain и live-чеклиста.
- SQLite runtime-хранилище в `runtime/tradebot.sqlite3`. - SQLite runtime-хранилище в `runtime/tradebot.sqlite3`.
- Health endpoint `/api/health` и Prometheus-compatible `/metrics`. - Health endpoint `/api/health` и Prometheus-compatible `/metrics`.
- Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост. - Docker Compose для установки на Raspberry Pi 5 или другой Linux-хост.
+57
View File
@@ -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` и подтверждения риска.
@@ -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"
}
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="true"
android:label="TradeBot"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".RetrainAlarmReceiver"
android:exported="false" />
<receiver
android:name=".BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -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),
)
}
}
@@ -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()
}
@@ -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<Candle> = 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<Double>()
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<Candle>,
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<Candle>,
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<Candle>, 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()
}
@@ -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<MarketItem>, 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<Pair<String, String>> {
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<Pair<String, String>> {
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<Pair<String, String>> {
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<Pair<String, String>> =
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<Pair<String, String>> {
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(
"" to { setRetrainInterval(1) },
"" to { setRetrainInterval(3) },
"" 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<Metric>): 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<PositionData>): 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<SignalData>): 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<FeatureItem>): 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<Pair<String, String>>): 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<Pair<String, () -> 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)
}
@@ -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<FeatureItem>,
)
data class MarketItem(
val symbol: String,
val ticker: TickerData?,
val candles: List<Candle>,
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<PositionData>,
val markets: List<MarketItem>,
val signalsBySymbol: Map<String, SignalData>,
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
@@ -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)
}
}
}
@@ -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<PositionData> {
val output = mutableListOf<PositionData>()
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<MarketItem> {
val output = mutableListOf<MarketItem>()
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<Candle> {
val output = mutableListOf<Candle>()
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<FeatureItem>()
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<String, SignalData> {
val output = linkedMapOf<String, SignalData>()
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 отправлена" }
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.2.1" apply false
}
@@ -0,0 +1,2 @@
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
@@ -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")