From e21a96640dda419094a55240f5c893a929b862f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 22:39:26 +0300 Subject: [PATCH] Improve Android monitor incremental updates --- android/TradeBotMonitor/app/build.gradle.kts | 4 +- .../kusoft/tradebotmonitor/MainActivity.kt | 555 ++++++++++++++++-- .../java/xyz/kusoft/tradebotmonitor/Models.kt | 5 + .../xyz/kusoft/tradebotmonitor/TradeBotApi.kt | 2 + 4 files changed, 499 insertions(+), 67 deletions(-) diff --git a/android/TradeBotMonitor/app/build.gradle.kts b/android/TradeBotMonitor/app/build.gradle.kts index f142a52..bb56972 100644 --- a/android/TradeBotMonitor/app/build.gradle.kts +++ b/android/TradeBotMonitor/app/build.gradle.kts @@ -10,7 +10,7 @@ android { applicationId = "xyz.kusoft.tradebotmonitor" minSdk = 26 targetSdk = 36 - versionCode = 12 - versionName = "0.2.9" + versionCode = 15 + versionName = "0.2.12" } } diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt index 1aa325d..9e4c7b4 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt @@ -11,10 +11,14 @@ import android.text.InputType import android.view.Gravity import android.view.View import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter import android.widget.EditText import android.widget.FrameLayout +import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.ScrollView +import android.widget.Spinner import android.widget.TextView import android.widget.Toast import org.json.JSONObject @@ -36,10 +40,59 @@ class MainActivity : Activity() { private var snapshot: BotSnapshot? = null private var lastError: String = "" - private var activeTab: String = "ai" + private var activeTab: String = "assets" private var palette: AppPalette = AppPalette.dark() private var trainingStatusSignature: String = "" private var retrainRequestInFlight: Boolean = false + private var assetsBindings: AssetsBindings? = null + private var tradingBindings: TradingBindings? = null + private var settingsBindings: SettingsBindings? = null + + private data class AssetsBindings( + var positionSignature: String, + var closedTradesSignature: String, + var equity: TextView? = null, + var openPnl: TextView? = null, + var realizedPnl: TextView? = null, + var mode: TextView? = null, + var cash: TextView? = null, + var exposure: TextView? = null, + var positionCount: TextView? = null, + var positionsContainer: LinearLayout? = null, + var positionsList: LinearLayout? = null, + var closedTradesContainer: LinearLayout? = null, + val positions: MutableMap = linkedMapOf(), + ) + + private data class PositionBinding( + var value: TextView? = null, + var quantity: TextView? = null, + var entryPrice: TextView? = null, + var markPrice: TextView? = null, + var pnl: TextView? = null, + var takeProfit: TextView? = null, + var highestPrice: TextView? = null, + ) + + private data class TradingBindings( + val symbol: String, + var price: TextView? = null, + var edgeLine: TextView? = null, + var equity: TextView? = null, + var status: TextView? = null, + var chart: CandleChartView? = null, + var chartTime: TextView? = null, + var decision: TextView? = null, + var kelly: TextView? = null, + var reason: TextView? = null, + ) + + private data class SettingsBindings( + var retrainSignature: String, + var liveSignature: String, + var retrainContainer: LinearLayout? = null, + var liveContainer: LinearLayout? = null, + ) private val refreshRunnable = object : Runnable { override fun run() { @@ -111,6 +164,9 @@ class MainActivity : Activity() { trainingStatusSignature = newTrainingStatusSignature lastError = "" ensureSelectedSymbol() + if (silent && hadSnapshot && !hadError && updateVisibleScreen(data)) { + return@post + } if (shouldRenderAfterRefresh(silent, hadSnapshot, hadError, trainingChanged)) { render() } @@ -140,16 +196,17 @@ class MainActivity : Activity() { if (activeTab == "settings" && trainingChanged) { return true } + if (activeTab == "assets" || activeTab == "trading") { + return true + } return !hadSnapshot || hadError } private fun renderBottomNav() { bottomNav.removeAllViews() listOf( - "markets" to "Рынки", - "ai" to "AI", - "risk" to "Риск", "assets" to "Активы", + "trading" to "Торговля", "settings" to "Настройки", ).forEach { (id, title) -> bottomNav.addView( @@ -171,16 +228,122 @@ class MainActivity : Activity() { if (saveScroll) { saveCurrentScroll(tabForRender) } - contentHost.removeAllViews() + assetsBindings = null + tradingBindings = null + settingsBindings = null val view = when (activeTab) { - "markets" -> marketsScreen() - "risk" -> riskScreen() "assets" -> assetsScreen() + "trading" -> tradingScreen() "settings" -> settingsScreen() - else -> aiScreen() + else -> assetsScreen() } + val previous = contentHost.getChildAt(0) contentHost.addView(view) restoreScroll(view, tabForRender) + if (previous != null) { + contentHost.removeView(previous) + } + } + + private fun updateVisibleScreen(data: BotSnapshot): Boolean = + when (activeTab) { + "assets" -> updateAssetsScreen(data) + "trading" -> updateTradingScreen(data) + "settings" -> updateSettingsScreen(data) + else -> false + } + + private fun updateAssetsScreen(data: BotSnapshot): Boolean { + val binding = assetsBindings ?: return false + val positionSignature = positionsSignature(data.positions) + if (binding.positionSignature != positionSignature) { + binding.positionsContainer?.let { container -> + rebuildPositionsContent(container, data.positions) + binding.positionSignature = positionSignature + } ?: return false + } + val closedSignature = closedTradesSignature(data.closedTrades, data.closedTradesSummary) + if (binding.closedTradesSignature != closedSignature) { + binding.closedTradesContainer?.let { container -> + container.removeAllViews() + container.addView(closedTradesPanel(data.closedTrades, data.closedTradesSummary)) + binding.closedTradesSignature = closedSignature + } ?: return false + } + + val openPnl = data.positions.sumOf { it.unrealizedPnl } + val realizedPnl = data.closedTradesSummary.netPnl + setText(binding.equity, money(data.account.equity)) + setText(binding.openPnl, "P&L открытых позиций ${signedMoney(openPnl)}", colorForSigned(openPnl)) + setText(binding.realizedPnl, "Прибыль закрытых сделок ${signedMoney(realizedPnl)}", colorForSigned(realizedPnl)) + setText(binding.mode, "Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"}") + setText(binding.cash, money(data.account.cash)) + setText(binding.exposure, money(data.account.exposure)) + setText(binding.positionCount, "Открытые позиции: ${data.positions.size}") + + data.positions.forEach { position -> + val row = binding.positions[positionKey(position)] ?: return@forEach + setText(row.value, "${money(position.marketValue)} · ${signedPercent(position.unrealizedPnlPercent, 2)}", colorForSigned(position.unrealizedPnlPercent)) + setText(row.quantity, number(position.qty, 8)) + setText(row.entryPrice, price(position.entryPrice)) + setText(row.markPrice, price(position.markPrice)) + setText(row.pnl, signedMoney(position.unrealizedPnl), colorForSigned(position.unrealizedPnl)) + setText(row.takeProfit, priceOrNone(position.takeProfit)) + setText(row.highestPrice, priceOrNone(position.highestPrice)) + } + return true + } + + private fun updateTradingScreen(data: BotSnapshot): Boolean { + val binding = tradingBindings ?: return false + if (binding.symbol != prefs.selectedSymbol) return false + val market = selectedMarket(data) ?: return false + if (market.symbol != binding.symbol) return false + val signal = data.signalsBySymbol[market.symbol] + val edge = signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0 + val probability = signal?.probabilityUp ?: market.forecast?.probabilityUp ?: 0.0 + val action = normalizedAction(signal?.action) + val decision = when (action) { + "BUY" -> "Покупка разрешена" + "SELL" -> "Выход из позиции" + else -> "Ожидание" + } + val reason = signal?.reason?.ifBlank { market.forecast?.reason.orEmpty() } ?: market.forecast?.reason.orEmpty() + + setText(binding.price, price(latestPrice(market)), colorForSigned(edge)) + setText(binding.edgeLine, "Edge ${signedPercent(edge, 2)} · P(up) ${probability(probability)} · 1h") + setText(binding.equity, money(data.account.equity)) + setText(binding.status, if (data.running) "РАБОТАЕТ" else "СТОП", if (data.running) palette.green else palette.amber) + setText(binding.decision, decision, actionColor(action)) + setText(binding.kelly, money(signal?.positionNotionalUsdt ?: 0.0)) + setText(binding.reason, reason.ifBlank { "Нет объяснения от модели" }) + binding.chart?.candles = market.candles + setText(binding.chartTime, lastCandleTime(market)) + return true + } + + private fun updateSettingsScreen(data: BotSnapshot): Boolean { + if (contentHost.findFocus() is EditText) { + return true + } + val binding = settingsBindings ?: return false + val retrainSignature = retrainBlockSignature(data) + if (binding.retrainSignature != retrainSignature) { + binding.retrainContainer?.let { container -> + container.removeAllViews() + container.addView(section("Переобучение Torch", retrainSettingsBlock(data.retrain, data.backtest))) + binding.retrainSignature = retrainSignature + } ?: return false + } + val liveSignature = liveBlockSignature(data) + if (binding.liveSignature != liveSignature) { + binding.liveContainer?.let { container -> + container.removeAllViews() + container.addView(section("Переход к live-торговле", liveSettingsBlock(data))) + binding.liveSignature = liveSignature + } ?: return false + } + return true } private fun saveCurrentScroll(tabId: String = activeTab) { @@ -199,9 +362,9 @@ class MainActivity : Activity() { } } - private fun aiScreen(): View { + private fun tradingScreen(): View { val box = screenContent() - box.addView(statusLine("AI Торговля", modeBadgeText())) + box.addView(statusLine("Торговля", modeBadgeText())) val data = snapshot if (data == null) { box.addView(connectionStatePanel().top(dp(18))) @@ -215,11 +378,12 @@ class MainActivity : Activity() { } val signal = data.signalsBySymbol[market.symbol] + tradingBindings = TradingBindings(market.symbol) + box.addView(pairSelector(data).top(dp(14))) box.addView(aiHero(data, market, signal).top(dp(14))) box.addView(chartTabs().top(dp(18))) box.addView(chartPanel(market).top(dp(10))) box.addView(decisionPanel(market, signal).top(dp(14))) - box.addView(torchFeaturePanel(market.forecast).top(dp(14))) return wrapScroll(box) } @@ -278,36 +442,43 @@ class MainActivity : Activity() { val openPnl = data.positions.sumOf { it.unrealizedPnl } val realizedPnl = data.closedTradesSummary.netPnl + assetsBindings = AssetsBindings( + positionSignature = positionsSignature(data.positions), + closedTradesSignature = closedTradesSignature(data.closedTrades, data.closedTradesSummary), + ) box.addView(assetHero(data, openPnl, realizedPnl).top(dp(14))) - box.addView(twoColumnMetrics( - "Доступно" to money(data.account.cash), - "В позициях" to money(data.account.exposure), - ).top(dp(14))) - box.addView(twoColumnMetrics( - "Закрытые сделки" to signedMoney(realizedPnl), - "Открытый P&L" to signedMoney(openPnl), - ).top(dp(14))) + box.addView(accountMetrics(data).top(dp(14))) box.addView(positionsPanel(data.positions).top(dp(16))) - box.addView(closedTradesPanel(data.closedTrades, data.closedTradesSummary).top(dp(16))) + val closedTradesHost = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + assetsBindings?.closedTradesContainer = closedTradesHost + closedTradesHost.addView(closedTradesPanel(data.closedTrades, data.closedTradesSummary)) + box.addView(closedTradesHost.top(dp(16))) return wrapScroll(box) } private fun settingsScreen(): View { val box = screenContent() box.addView(statusLine("Настройки", prefs.apiBaseUrl)) + settingsBindings = SettingsBindings( + retrainSignature = retrainBlockSignature(snapshot), + liveSignature = liveBlockSignature(snapshot), + ) + val (savedLogin, savedPassword) = authParts(prefs.commandToken) val apiInput = input("Адрес API бота", prefs.apiBaseUrl) - val tokenInput = input("API auth: token, Bearer, Basic или login:password", prefs.commandToken).apply { + val loginInput = input("Логин API", savedLogin) + val passwordInput = input("Пароль API", savedPassword).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD } box.addView(section("Подключение", LinearLayout(this).apply { orientation = LinearLayout.VERTICAL addView(apiInput) - addView(tokenInput.top(dp(8))) + addView(loginInput.top(dp(8))) + addView(passwordInput.top(dp(8))) addView(actionRow( "Сохранить" to { prefs.apiBaseUrl = apiInput.text.toString() - prefs.commandToken = tokenInput.text.toString() + prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString()) toast("Подключение сохранено") refreshData(silent = false) }, @@ -330,8 +501,15 @@ class MainActivity : Activity() { }, )).top(dp(14))) - box.addView(section("Переобучение Torch", retrainSettingsBlock(snapshot?.retrain ?: JSONObject(), snapshot?.backtest ?: JSONObject())).top(dp(14))) - box.addView(section("Переход к live-торговле", liveSettingsBlock(snapshot)).top(dp(14))) + val retrainHost = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + settingsBindings?.retrainContainer = retrainHost + retrainHost.addView(section("Переобучение Torch", retrainSettingsBlock(snapshot?.retrain ?: JSONObject(), snapshot?.backtest ?: JSONObject()))) + box.addView(retrainHost.top(dp(14))) + + val liveHost = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + settingsBindings?.liveContainer = liveHost + liveHost.addView(section("Переход к live-торговле", liveSettingsBlock(snapshot))) + box.addView(liveHost.top(dp(14))) return wrapScroll(box) } @@ -347,18 +525,26 @@ class MainActivity : Activity() { top.addView(LinearLayout(this@MainActivity).apply { orientation = LinearLayout.VERTICAL addView(text(market.symbol, 20f, Typeface.BOLD)) - addView(text(price(latestPrice(market)), 32f, Typeface.BOLD, colorForSigned(signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0)).top(dp(6))) + val priceText = text(price(latestPrice(market)), 32f, Typeface.BOLD, colorForSigned(signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0)) + tradingBindings?.price = priceText + addView(priceText.top(dp(6))) val edge = signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0 val probability = signal?.probabilityUp ?: market.forecast?.probabilityUp ?: 0.0 - addView(text("Edge ${signedPercent(edge, 2)} · P(up) ${probability(probability)} · 1h", 13f, Typeface.NORMAL, palette.muted).top(dp(2))) + val edgeLine = text("Edge ${signedPercent(edge, 2)} · P(up) ${probability(probability)} · 1h", 13f, Typeface.NORMAL, palette.muted) + tradingBindings?.edgeLine = edgeLine + addView(edgeLine.top(dp(2))) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) top.addView(LinearLayout(this@MainActivity).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.END addView(text("Equity", 12f, Typeface.NORMAL, palette.muted)) - addView(text(money(data.account.equity), 18f, Typeface.BOLD).top(dp(5))) - addView(statusBadge(if (data.running) "Работает" else "Стоп", if (data.running) "ok" else "warn").top(dp(8))) + val equityText = text(money(data.account.equity), 18f, Typeface.BOLD) + tradingBindings?.equity = equityText + addView(equityText.top(dp(5))) + val statusText = statusBadge(if (data.running) "Работает" else "Стоп", if (data.running) "ok" else "warn") + tradingBindings?.status = statusText + addView(statusText.top(dp(8))) }, LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT)) addView(top) @@ -374,7 +560,6 @@ class MainActivity : Activity() { setPadding(0, dp(12), 0, dp(6)) addView(tabText("График", true)) addView(tabText("Torch", false)) - addView(tabText("Риск", false)) addView(tabText("История", false)) }) } @@ -387,18 +572,67 @@ class MainActivity : Activity() { palette = this@MainActivity.palette candles = market.candles } + tradingBindings?.chart = chart addView(chart, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(278))) addView(LinearLayout(this@MainActivity).apply { orientation = LinearLayout.HORIZONTAL setPadding(0, dp(4), 0, 0) addView(text("EMA50", 11f, Typeface.NORMAL, palette.muted)) addView(text("EMA200", 11f, Typeface.NORMAL, palette.amber).left(dp(16))) - addView(text(lastCandleTime(market), 11f, Typeface.NORMAL, palette.muted).apply { + val timeText = text(lastCandleTime(market), 11f, Typeface.NORMAL, palette.muted).apply { gravity = Gravity.END - }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + } + tradingBindings?.chartTime = timeText + addView(timeText, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) }) } + private fun pairSelector(data: BotSnapshot): View { + val symbols = orderedMarkets(data.markets).map { it.symbol } + val selectedIndex = symbols.indexOf(prefs.selectedSymbol).coerceAtLeast(0) + val adapter = object : ArrayAdapter(this, android.R.layout.simple_spinner_item, symbols) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) as TextView + view.setTextColor(palette.text) + view.textSize = 15f + view.typeface = Typeface.DEFAULT_BOLD + return view + } + + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getDropDownView(position, convertView, parent) as TextView + view.setTextColor(palette.text) + view.setBackgroundColor(palette.panel) + view.textSize = 15f + view.setPadding(dp(14), dp(12), dp(14), dp(12)) + return view + } + }.apply { + setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + } + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(text("Торговая пара", 12f, Typeface.NORMAL, palette.muted)) + addView(Spinner(this@MainActivity).apply { + this.adapter = adapter + setSelection(selectedIndex, false) + background = boxDrawable(palette.panel, palette.line, 8) + setPadding(dp(10), 0, dp(10), 0) + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + val symbol = symbols.getOrNull(position) ?: return + if (symbol != prefs.selectedSymbol) { + prefs.selectedSymbol = symbol + render(saveScroll = false) + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48))) + } + } + private fun decisionPanel(market: MarketItem, signal: SignalData?): View { val action = normalizedAction(signal?.action) val color = actionColor(action) @@ -416,17 +650,21 @@ class MainActivity : Activity() { addView(LinearLayout(this@MainActivity).apply { orientation = LinearLayout.VERTICAL addView(text("Решение Torch", 12f, Typeface.NORMAL, palette.muted)) - addView(text(decision, 20f, Typeface.BOLD, color).top(dp(8))) + val decisionText = text(decision, 20f, Typeface.BOLD, color) + tradingBindings?.decision = decisionText + addView(decisionText.top(dp(8))) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) addView(LinearLayout(this@MainActivity).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.END addView(text("Kelly", 12f, Typeface.NORMAL, palette.muted)) - addView(text(money(signal?.positionNotionalUsdt ?: 0.0), 20f, Typeface.BOLD).top(dp(8))) + val kellyText = text(money(signal?.positionNotionalUsdt ?: 0.0), 20f, Typeface.BOLD) + tradingBindings?.kelly = kellyText + addView(kellyText.top(dp(8))) }, LinearLayout.LayoutParams(dp(120), ViewGroup.LayoutParams.WRAP_CONTENT)) }) addView(thinDivider().top(dp(12))) - addView(keyValueLine("Причина", reason.ifBlank { "Нет объяснения от модели" }).top(dp(8))) + addView(boundKeyValueLine("Причина", reason.ifBlank { "Нет объяснения от модели" }) { tradingBindings?.reason = it }.top(dp(8))) }) } @@ -457,7 +695,7 @@ class MainActivity : Activity() { background = if (selected) boxDrawable(palette.panelAlt, Color.TRANSPARENT, 2) else null setOnClickListener { prefs.selectedSymbol = market.symbol - activeTab = "ai" + activeTab = "trading" renderBottomNav() render(saveScroll = false) } @@ -556,33 +794,116 @@ class MainActivity : Activity() { LinearLayout(this).apply { orientation = LinearLayout.VERTICAL addView(text("Общие активы", 13f, Typeface.NORMAL, palette.muted)) - addView(text(money(data.account.equity), 34f, Typeface.BOLD).top(dp(8))) - addView(text("P&L открытых позиций ${signedMoney(pnl)}", 15f, Typeface.NORMAL, colorForSigned(pnl)).top(dp(8))) - addView(text("Прибыль закрытых сделок ${signedMoney(realizedPnl)}", 15f, Typeface.NORMAL, colorForSigned(realizedPnl)).top(dp(5))) - addView(text("Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"}", 12f, Typeface.NORMAL, palette.muted).top(dp(8))) + val equityText = text(money(data.account.equity), 34f, Typeface.BOLD) + assetsBindings?.equity = equityText + addView(equityText.top(dp(8))) + val openPnlText = text("P&L открытых позиций ${signedMoney(pnl)}", 15f, Typeface.NORMAL, colorForSigned(pnl)) + assetsBindings?.openPnl = openPnlText + addView(openPnlText.top(dp(8))) + val realizedPnlText = text("Прибыль закрытых сделок ${signedMoney(realizedPnl)}", 15f, Typeface.NORMAL, colorForSigned(realizedPnl)) + assetsBindings?.realizedPnl = realizedPnlText + addView(realizedPnlText.top(dp(5))) + val modeText = text("Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"}", 12f, Typeface.NORMAL, palette.muted) + assetsBindings?.mode = modeText + addView(modeText.top(dp(8))) + } + + private fun accountMetrics(data: BotSnapshot): View = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + addView(metricCell("Доступно", money(data.account.cash)).also { cell -> + assetsBindings?.cash = cell.findViewWithTag("metric_value") + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { setMargins(0, 0, dp(6), 0) }) + addView(metricCell("В позициях", money(data.account.exposure)).also { cell -> + assetsBindings?.exposure = cell.findViewWithTag("metric_value") + }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { setMargins(dp(6), 0, 0, 0) }) } private fun positionsPanel(positions: List): View { - if (positions.isEmpty()) { - return section("Открытые позиции", text("Открытых позиций нет", 13f, Typeface.NORMAL, palette.muted)) + val body = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + val title = text("Открытые позиции: ${positions.size}", 18f, Typeface.BOLD) + assetsBindings?.positionCount = title + addView(title) + val container = LinearLayout(this@MainActivity).apply { orientation = LinearLayout.VERTICAL } + assetsBindings?.positionsContainer = container + rebuildPositionsContent(container, positions) + addView(container.top(dp(12))) } - val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - positions.forEachIndexed { index, position -> - if (index > 0) box.addView(thinDivider().top(dp(8))) - box.addView(LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - addView(keyValueLine(position.symbol, "${money(position.marketValue)} · ${signedPercent(position.unrealizedPnlPercent, 2)}", colorForSigned(position.unrealizedPnlPercent))) - addView(keyValueLine("Вход", price(position.entryPrice)).top(dp(4))) - addView(keyValueLine("Текущая", price(position.markPrice)).top(dp(4))) - addView(keyValueLine("PnL", signedMoney(position.unrealizedPnl), colorForSigned(position.unrealizedPnl)).top(dp(4))) - addView(keyValueLine("Take-profit", priceOrNone(position.takeProfit)).top(dp(4))) - addView(keyValueLine("Защитный выход", protectiveExitText(position), protectiveExitColor(position)).top(dp(4))) - addView(keyValueLine("Сигнал выхода", exitSignalText(position), actionColor(normalizedAction(position.exitAction))).top(dp(4))) - }.top(dp(8))) - } - return section("Открытые позиции", box) + return section(null, body) } + private fun rebuildPositionsContent(container: LinearLayout, positions: List) { + container.removeAllViews() + assetsBindings?.positions?.clear() + assetsBindings?.positionsList = null + if (positions.isEmpty()) { + container.addView(text("Открытых позиций нет", 13f, Typeface.NORMAL, palette.muted)) + return + } + val cardWidth = (resources.displayMetrics.widthPixels - dp(64)).coerceAtLeast(dp(260)) + val list = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + setPadding(0, 0, dp(10), 0) + } + assetsBindings?.positionsList = list + rebuildPositionsList(list, positions, cardWidth) + val scroller = HorizontalScrollView(this).apply { + isHorizontalScrollBarEnabled = false + overScrollMode = View.OVER_SCROLL_NEVER + addView(list, ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + container.addView(scroller) + } + + private fun rebuildPositionsList( + list: LinearLayout, + positions: List, + cardWidth: Int = (resources.displayMetrics.widthPixels - dp(64)).coerceAtLeast(dp(260)), + ) { + list.removeAllViews() + positions.forEachIndexed { index, position -> + list.addView(positionCard(position, index + 1, positions.size), LinearLayout.LayoutParams(cardWidth, ViewGroup.LayoutParams.WRAP_CONTENT).apply { + if (index < positions.lastIndex) setMargins(0, 0, dp(12), 0) + }) + } + } + + private fun positionCard(position: PositionData, index: Int, total: Int): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(14), dp(13), dp(14), dp(13)) + background = boxDrawable(palette.panelAlt, palette.line, 8) + addView(LinearLayout(this@MainActivity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(text(position.symbol, 20f, Typeface.BOLD), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + addView(text("$index/$total", 12f, Typeface.BOLD, palette.muted)) + }) + val valueText = text("${money(position.marketValue)} · ${signedPercent(position.unrealizedPnlPercent, 2)}", 15f, Typeface.BOLD, colorForSigned(position.unrealizedPnlPercent)) + addView(valueText.top(dp(8))) + addView(thinDivider().top(dp(12))) + addView(boundKeyValueLine("Количество", number(position.qty, 8)) { textView -> + assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.quantity = textView + }.top(dp(10))) + addView(boundKeyValueLine("Вход", price(position.entryPrice)) { textView -> + assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.entryPrice = textView + }.top(dp(5))) + addView(boundKeyValueLine("Текущая", price(position.markPrice)) { textView -> + assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.markPrice = textView + }.top(dp(5))) + addView(boundKeyValueLine("PnL", signedMoney(position.unrealizedPnl), colorForSigned(position.unrealizedPnl)) { textView -> + assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.pnl = textView + }.top(dp(5))) + addView(boundKeyValueLine("Take-profit", priceOrNone(position.takeProfit)) { textView -> + assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.takeProfit = textView + }.top(dp(5))) + addView(boundKeyValueLine("Максимум", priceOrNone(position.highestPrice)) { textView -> + assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.highestPrice = textView + }.top(dp(5))) + assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.value = valueText + } + private fun closedTradesPanel(trades: List, summary: ClosedTradesSummary): View { val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } box.addView(keyValueLine("Всего закрыто", summary.trades.toString())) @@ -717,13 +1038,17 @@ class MainActivity : Activity() { prefs.retrainScheduleEnabled = true RetrainScheduler.schedule(this@MainActivity, prefs.retrainIntervalHours) toast("Расписание retrain включено") - render() + if (activeTab != "settings" || snapshot == null || !updateSettingsScreen(snapshot!!)) { + render() + } }, "Отключить" to { prefs.retrainScheduleEnabled = false RetrainScheduler.cancel(this@MainActivity) toast("Расписание retrain отключено") - render() + if (activeTab != "settings" || snapshot == null || !updateSettingsScreen(snapshot!!)) { + render() + } }, ).top(dp(10))) } @@ -785,7 +1110,9 @@ class MainActivity : Activity() { setPadding(dp(14), dp(12), dp(14), dp(12)) background = boxDrawable(palette.panel, palette.line, 8) addView(text(title, 12f, Typeface.NORMAL, palette.muted)) - addView(text(value, 17f, Typeface.BOLD, colorForSigned(value)).top(dp(6))) + addView(text(value, 17f, Typeface.BOLD, colorForSigned(value)).apply { + tag = "metric_value" + }.top(dp(6))) } private fun tableHeader(columns: List>): View = @@ -833,19 +1160,22 @@ class MainActivity : Activity() { if (!isAuthError()) { return emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}") } + val (savedLogin, savedPassword) = authParts(prefs.commandToken) val apiInput = input("Адрес API бота", prefs.apiBaseUrl) - val authInput = input("Логин и пароль: login:password", prefs.commandToken).apply { + val loginInput = input("Логин API", savedLogin) + val passwordInput = input("Пароль API", savedPassword).apply { inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD } return section("Нужен вход в API", LinearLayout(this).apply { orientation = LinearLayout.VERTICAL - addView(text("Сервер tb.kusoft.xyz отвечает 401, значит он доступен, но требует авторизацию. Введите доступ в формате login:password, приложение само отправит Basic Auth.", 13f, Typeface.NORMAL, palette.muted)) + addView(text("Сервер tb.kusoft.xyz отвечает 401, значит он доступен, но требует авторизацию. Введите логин и пароль, приложение само отправит Basic Auth.", 13f, Typeface.NORMAL, palette.muted)) addView(apiInput.top(dp(12))) - addView(authInput.top(dp(8))) + addView(loginInput.top(dp(8))) + addView(passwordInput.top(dp(8))) addView(actionRow( "Подключиться" to { prefs.apiBaseUrl = apiInput.text.toString() - prefs.commandToken = authInput.text.toString() + prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString()) toast("Доступ сохранен, проверяю API") refreshData(silent = false) }, @@ -932,6 +1262,28 @@ class MainActivity : Activity() { }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.05f)) } + private fun boundKeyValueLine(key: String, value: String, valueColor: Int = colorForSigned(value), bindValue: (TextView) -> Unit): View = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView(text(key, 13f, Typeface.NORMAL, palette.muted), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + val valueText = text(value.ifBlank { "нет данных" }, 13f, Typeface.BOLD, valueColor).apply { + gravity = Gravity.END + } + bindValue(valueText) + addView(valueText, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.05f)) + } + + private fun setText(view: TextView?, value: String, color: Int? = null) { + if (view == null) return + if (view.text.toString() != value) { + view.text = value + } + if (color != null) { + view.setTextColor(color) + } + } + private fun allocationBar(fractionRaw: Double): View { val fraction = fractionRaw.coerceIn(0.0, 1.0).toFloat() return LinearLayout(this).apply { @@ -1107,13 +1459,18 @@ class MainActivity : Activity() { RetrainScheduler.schedule(this, hours) } toast("Интервал retrain: $hours ч") + if (activeTab == "settings" && snapshot != null && updateSettingsScreen(snapshot!!)) { + return + } render() } private fun triggerRetrainNow() { if (retrainRequestInFlight) return retrainRequestInFlight = true - render() + if (activeTab != "settings" || snapshot == null || !updateSettingsScreen(snapshot!!)) { + render() + } executor.execute { try { val message = TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain() @@ -1155,6 +1512,53 @@ class MainActivity : Activity() { private fun trainingStatusSignature(retrain: JSONObject): String = (retrain.optJSONObject("coordination") ?: JSONObject()).toString() + private fun retrainBlockSignature(data: BotSnapshot?): String = + listOf( + retrainRequestInFlight.toString(), + prefs.retrainScheduleEnabled.toString(), + prefs.retrainIntervalHours.toString(), + data?.retrain?.toString().orEmpty(), + data?.backtest?.optJSONObject("full_replay")?.toString().orEmpty(), + ).joinToString("|") + + private fun liveBlockSignature(data: BotSnapshot?): String { + val config = data?.config ?: JSONObject() + return listOf( + data?.mode.orEmpty(), + data?.running?.toString().orEmpty(), + config.optBoolean("live_ready", false).toString(), + config.optDouble("live_order_max_usdt", 0.0).toString(), + config.optDouble("risk_per_trade_percent", 0.0).toString(), + ).joinToString("|") + } + + private fun positionsSignature(positions: List): String = + positions.joinToString("|") { positionKey(it) } + + private fun positionKey(position: PositionData): String = + when { + position.id != null && position.id > 0L -> "id:${position.id}" + position.openedAt.isNotBlank() -> "${position.symbol}:${position.openedAt}" + else -> position.symbol + } + + private fun closedTradesSignature(trades: List, summary: ClosedTradesSummary): String = + listOf( + summary.trades.toString(), + number(summary.netPnl, 8), + number(summary.feeUsdt, 8), + number(summary.winRate, 8), + trades.take(10).joinToString(";") { trade -> + listOf( + trade.closedAt, + trade.symbol, + number(trade.qty, 8), + priceOrNone(trade.exitPrice), + number(trade.netPnl, 8), + ).joinToString(",") + }, + ).joinToString("|") + private fun trainingJobLabel(status: String, phase: String): String = when (status) { "pending" -> "ждет Windows-agent" @@ -1213,6 +1617,27 @@ class MainActivity : Activity() { else -> value.ifBlank { "нет данных" } } + private fun authParts(value: String): Pair { + val trimmed = value.trim() + if (trimmed.isBlank() || trimmed.startsWith("Bearer ", ignoreCase = true)) { + return "" to "" + } + val basicPrefix = "Basic " + val raw = if (trimmed.startsWith(basicPrefix, ignoreCase = true)) "" else trimmed + val separator = raw.indexOf(':') + return if (separator >= 0) { + raw.substring(0, separator) to raw.substring(separator + 1) + } else { + raw to "" + } + } + + private fun authToken(login: String, password: String): String { + val cleanLogin = login.trim() + val cleanPassword = password.trim() + return if (cleanLogin.isBlank() && cleanPassword.isBlank()) "" else "$cleanLogin:$cleanPassword" + } + private fun modelLabel(value: String): String = when { value.contains("gru", ignoreCase = true) -> "PyTorch GRU" diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt index 0e00457..81a2669 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt @@ -84,6 +84,7 @@ data class SignalData( } data class PositionData( + val id: Long?, val symbol: String, val qty: Double, val entryPrice: Double, @@ -97,6 +98,7 @@ data class PositionData( val highestPrice: Double?, val trailingStop: Double?, val atrTrailingStop: Double?, + val openedAt: String, val exitAction: String, val exitReason: String, val stopLossExitEnabled: Boolean, @@ -153,5 +155,8 @@ fun JSONObject.optStringClean(name: String): String = fun JSONObject.optDoubleOrNull(name: String): Double? = if (has(name) && !isNull(name)) optDouble(name) else null +fun JSONObject.optLongOrNull(name: String): Long? = + if (has(name) && !isNull(name)) optLong(name) else null + fun JSONObject.optBooleanOrNull(name: String): Boolean? = if (has(name) && !isNull(name)) optBoolean(name) else null diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt index db735a7..3e02a72 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt @@ -104,6 +104,7 @@ class TradeBotApi( val row = items.optJSONObject(index) ?: continue val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject() output += PositionData( + id = row.optLongOrNull("id"), symbol = row.optStringClean("symbol"), qty = row.optDouble("qty", 0.0), entryPrice = row.optDouble("entry_price", 0.0), @@ -117,6 +118,7 @@ class TradeBotApi( highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"), trailingStop = exitPlan.optDoubleOrNull("trailing_stop"), atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"), + openedAt = row.optStringClean("opened_at"), exitAction = exitPlan.optStringClean("action"), exitReason = exitPlan.optStringClean("reason"), stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true,