Improve Android monitor incremental updates

This commit is contained in:
Codex
2026-07-09 22:39:26 +03:00
parent 7f1ac694e4
commit e21a96640d
4 changed files with 499 additions and 67 deletions
+2 -2
View File
@@ -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"
}
}
@@ -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<String, PositionBinding> = 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<String>(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<TextView>("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<TextView>("metric_value")
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { setMargins(dp(6), 0, 0, 0) })
}
private fun positionsPanel(positions: List<PositionData>): 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<PositionData>) {
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<PositionData>,
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<ClosedTradeData>, 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<Pair<String, Float>>): 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<PositionData>): 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<ClosedTradeData>, 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<String, String> {
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"
@@ -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
@@ -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,