Add Android TradeBot monitor app
This commit is contained in:
@@ -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()
|
||||
}
|
||||
+230
@@ -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(
|
||||
"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<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
|
||||
+63
@@ -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>
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user