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" applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 12 versionCode = 15
versionName = "0.2.9" versionName = "0.2.12"
} }
} }
@@ -11,10 +11,14 @@ import android.text.InputType
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText import android.widget.EditText
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.HorizontalScrollView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ScrollView import android.widget.ScrollView
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import org.json.JSONObject import org.json.JSONObject
@@ -36,10 +40,59 @@ class MainActivity : Activity() {
private var snapshot: BotSnapshot? = null private var snapshot: BotSnapshot? = null
private var lastError: String = "" private var lastError: String = ""
private var activeTab: String = "ai" private var activeTab: String = "assets"
private var palette: AppPalette = AppPalette.dark() private var palette: AppPalette = AppPalette.dark()
private var trainingStatusSignature: String = "" private var trainingStatusSignature: String = ""
private var retrainRequestInFlight: Boolean = false 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 { private val refreshRunnable = object : Runnable {
override fun run() { override fun run() {
@@ -111,6 +164,9 @@ class MainActivity : Activity() {
trainingStatusSignature = newTrainingStatusSignature trainingStatusSignature = newTrainingStatusSignature
lastError = "" lastError = ""
ensureSelectedSymbol() ensureSelectedSymbol()
if (silent && hadSnapshot && !hadError && updateVisibleScreen(data)) {
return@post
}
if (shouldRenderAfterRefresh(silent, hadSnapshot, hadError, trainingChanged)) { if (shouldRenderAfterRefresh(silent, hadSnapshot, hadError, trainingChanged)) {
render() render()
} }
@@ -140,16 +196,17 @@ class MainActivity : Activity() {
if (activeTab == "settings" && trainingChanged) { if (activeTab == "settings" && trainingChanged) {
return true return true
} }
if (activeTab == "assets" || activeTab == "trading") {
return true
}
return !hadSnapshot || hadError return !hadSnapshot || hadError
} }
private fun renderBottomNav() { private fun renderBottomNav() {
bottomNav.removeAllViews() bottomNav.removeAllViews()
listOf( listOf(
"markets" to "Рынки",
"ai" to "AI",
"risk" to "Риск",
"assets" to "Активы", "assets" to "Активы",
"trading" to "Торговля",
"settings" to "Настройки", "settings" to "Настройки",
).forEach { (id, title) -> ).forEach { (id, title) ->
bottomNav.addView( bottomNav.addView(
@@ -171,16 +228,122 @@ class MainActivity : Activity() {
if (saveScroll) { if (saveScroll) {
saveCurrentScroll(tabForRender) saveCurrentScroll(tabForRender)
} }
contentHost.removeAllViews() assetsBindings = null
tradingBindings = null
settingsBindings = null
val view = when (activeTab) { val view = when (activeTab) {
"markets" -> marketsScreen()
"risk" -> riskScreen()
"assets" -> assetsScreen() "assets" -> assetsScreen()
"trading" -> tradingScreen()
"settings" -> settingsScreen() "settings" -> settingsScreen()
else -> aiScreen() else -> assetsScreen()
} }
val previous = contentHost.getChildAt(0)
contentHost.addView(view) contentHost.addView(view)
restoreScroll(view, tabForRender) 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) { private fun saveCurrentScroll(tabId: String = activeTab) {
@@ -199,9 +362,9 @@ class MainActivity : Activity() {
} }
} }
private fun aiScreen(): View { private fun tradingScreen(): View {
val box = screenContent() val box = screenContent()
box.addView(statusLine("AI Торговля", modeBadgeText())) box.addView(statusLine("Торговля", modeBadgeText()))
val data = snapshot val data = snapshot
if (data == null) { if (data == null) {
box.addView(connectionStatePanel().top(dp(18))) box.addView(connectionStatePanel().top(dp(18)))
@@ -215,11 +378,12 @@ class MainActivity : Activity() {
} }
val signal = data.signalsBySymbol[market.symbol] 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(aiHero(data, market, signal).top(dp(14)))
box.addView(chartTabs().top(dp(18))) box.addView(chartTabs().top(dp(18)))
box.addView(chartPanel(market).top(dp(10))) box.addView(chartPanel(market).top(dp(10)))
box.addView(decisionPanel(market, signal).top(dp(14))) box.addView(decisionPanel(market, signal).top(dp(14)))
box.addView(torchFeaturePanel(market.forecast).top(dp(14)))
return wrapScroll(box) return wrapScroll(box)
} }
@@ -278,36 +442,43 @@ class MainActivity : Activity() {
val openPnl = data.positions.sumOf { it.unrealizedPnl } val openPnl = data.positions.sumOf { it.unrealizedPnl }
val realizedPnl = data.closedTradesSummary.netPnl 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(assetHero(data, openPnl, realizedPnl).top(dp(14)))
box.addView(twoColumnMetrics( box.addView(accountMetrics(data).top(dp(14)))
"Доступно" 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(positionsPanel(data.positions).top(dp(16))) 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) return wrapScroll(box)
} }
private fun settingsScreen(): View { private fun settingsScreen(): View {
val box = screenContent() val box = screenContent()
box.addView(statusLine("Настройки", prefs.apiBaseUrl)) 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 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 inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
} }
box.addView(section("Подключение", LinearLayout(this).apply { box.addView(section("Подключение", LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
addView(apiInput) addView(apiInput)
addView(tokenInput.top(dp(8))) addView(loginInput.top(dp(8)))
addView(passwordInput.top(dp(8)))
addView(actionRow( addView(actionRow(
"Сохранить" to { "Сохранить" to {
prefs.apiBaseUrl = apiInput.text.toString() prefs.apiBaseUrl = apiInput.text.toString()
prefs.commandToken = tokenInput.text.toString() prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString())
toast("Подключение сохранено") toast("Подключение сохранено")
refreshData(silent = false) refreshData(silent = false)
}, },
@@ -330,8 +501,15 @@ class MainActivity : Activity() {
}, },
)).top(dp(14))) )).top(dp(14)))
box.addView(section("Переобучение Torch", retrainSettingsBlock(snapshot?.retrain ?: JSONObject(), snapshot?.backtest ?: JSONObject())).top(dp(14))) val retrainHost = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
box.addView(section("Переход к live-торговле", liveSettingsBlock(snapshot)).top(dp(14))) 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) return wrapScroll(box)
} }
@@ -347,18 +525,26 @@ class MainActivity : Activity() {
top.addView(LinearLayout(this@MainActivity).apply { top.addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
addView(text(market.symbol, 20f, Typeface.BOLD)) 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 edge = signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0
val probability = signal?.probabilityUp ?: market.forecast?.probabilityUp ?: 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)) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
top.addView(LinearLayout(this@MainActivity).apply { top.addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
gravity = Gravity.END gravity = Gravity.END
addView(text("Equity", 12f, Typeface.NORMAL, palette.muted)) addView(text("Equity", 12f, Typeface.NORMAL, palette.muted))
addView(text(money(data.account.equity), 18f, Typeface.BOLD).top(dp(5))) val equityText = text(money(data.account.equity), 18f, Typeface.BOLD)
addView(statusBadge(if (data.running) "Работает" else "Стоп", if (data.running) "ok" else "warn").top(dp(8))) 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)) }, LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT))
addView(top) addView(top)
@@ -374,7 +560,6 @@ class MainActivity : Activity() {
setPadding(0, dp(12), 0, dp(6)) setPadding(0, dp(12), 0, dp(6))
addView(tabText("График", true)) addView(tabText("График", true))
addView(tabText("Torch", false)) addView(tabText("Torch", false))
addView(tabText("Риск", false))
addView(tabText("История", false)) addView(tabText("История", false))
}) })
} }
@@ -387,18 +572,67 @@ class MainActivity : Activity() {
palette = this@MainActivity.palette palette = this@MainActivity.palette
candles = market.candles candles = market.candles
} }
tradingBindings?.chart = chart
addView(chart, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(278))) addView(chart, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(278)))
addView(LinearLayout(this@MainActivity).apply { addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
setPadding(0, dp(4), 0, 0) setPadding(0, dp(4), 0, 0)
addView(text("EMA50", 11f, Typeface.NORMAL, palette.muted)) addView(text("EMA50", 11f, Typeface.NORMAL, palette.muted))
addView(text("EMA200", 11f, Typeface.NORMAL, palette.amber).left(dp(16))) 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 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 { private fun decisionPanel(market: MarketItem, signal: SignalData?): View {
val action = normalizedAction(signal?.action) val action = normalizedAction(signal?.action)
val color = actionColor(action) val color = actionColor(action)
@@ -416,17 +650,21 @@ class MainActivity : Activity() {
addView(LinearLayout(this@MainActivity).apply { addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
addView(text("Решение Torch", 12f, Typeface.NORMAL, palette.muted)) 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)) }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
addView(LinearLayout(this@MainActivity).apply { addView(LinearLayout(this@MainActivity).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
gravity = Gravity.END gravity = Gravity.END
addView(text("Kelly", 12f, Typeface.NORMAL, palette.muted)) 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)) }, LinearLayout.LayoutParams(dp(120), ViewGroup.LayoutParams.WRAP_CONTENT))
}) })
addView(thinDivider().top(dp(12))) 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 background = if (selected) boxDrawable(palette.panelAlt, Color.TRANSPARENT, 2) else null
setOnClickListener { setOnClickListener {
prefs.selectedSymbol = market.symbol prefs.selectedSymbol = market.symbol
activeTab = "ai" activeTab = "trading"
renderBottomNav() renderBottomNav()
render(saveScroll = false) render(saveScroll = false)
} }
@@ -556,31 +794,114 @@ class MainActivity : Activity() {
LinearLayout(this).apply { LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
addView(text("Общие активы", 13f, Typeface.NORMAL, palette.muted)) addView(text("Общие активы", 13f, Typeface.NORMAL, palette.muted))
addView(text(money(data.account.equity), 34f, Typeface.BOLD).top(dp(8))) val equityText = text(money(data.account.equity), 34f, Typeface.BOLD)
addView(text("P&L открытых позиций ${signedMoney(pnl)}", 15f, Typeface.NORMAL, colorForSigned(pnl)).top(dp(8))) assetsBindings?.equity = equityText
addView(text("Прибыль закрытых сделок ${signedMoney(realizedPnl)}", 15f, Typeface.NORMAL, colorForSigned(realizedPnl)).top(dp(5))) addView(equityText.top(dp(8)))
addView(text("Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"}", 12f, Typeface.NORMAL, palette.muted).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 { private fun positionsPanel(positions: List<PositionData>): View {
if (positions.isEmpty()) { val body = LinearLayout(this).apply {
return section("Открытые позиции", text("Открытых позиций нет", 13f, Typeface.NORMAL, palette.muted))
}
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 orientation = LinearLayout.VERTICAL
addView(keyValueLine(position.symbol, "${money(position.marketValue)} · ${signedPercent(position.unrealizedPnlPercent, 2)}", colorForSigned(position.unrealizedPnlPercent))) val title = text("Открытые позиции: ${positions.size}", 18f, Typeface.BOLD)
addView(keyValueLine("Вход", price(position.entryPrice)).top(dp(4))) assetsBindings?.positionCount = title
addView(keyValueLine("Текущая", price(position.markPrice)).top(dp(4))) addView(title)
addView(keyValueLine("PnL", signedMoney(position.unrealizedPnl), colorForSigned(position.unrealizedPnl)).top(dp(4))) val container = LinearLayout(this@MainActivity).apply { orientation = LinearLayout.VERTICAL }
addView(keyValueLine("Take-profit", priceOrNone(position.takeProfit)).top(dp(4))) assetsBindings?.positionsContainer = container
addView(keyValueLine("Защитный выход", protectiveExitText(position), protectiveExitColor(position)).top(dp(4))) rebuildPositionsContent(container, positions)
addView(keyValueLine("Сигнал выхода", exitSignalText(position), actionColor(normalizedAction(position.exitAction))).top(dp(4))) addView(container.top(dp(12)))
}.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 { private fun closedTradesPanel(trades: List<ClosedTradeData>, summary: ClosedTradesSummary): View {
@@ -717,13 +1038,17 @@ class MainActivity : Activity() {
prefs.retrainScheduleEnabled = true prefs.retrainScheduleEnabled = true
RetrainScheduler.schedule(this@MainActivity, prefs.retrainIntervalHours) RetrainScheduler.schedule(this@MainActivity, prefs.retrainIntervalHours)
toast("Расписание retrain включено") toast("Расписание retrain включено")
if (activeTab != "settings" || snapshot == null || !updateSettingsScreen(snapshot!!)) {
render() render()
}
}, },
"Отключить" to { "Отключить" to {
prefs.retrainScheduleEnabled = false prefs.retrainScheduleEnabled = false
RetrainScheduler.cancel(this@MainActivity) RetrainScheduler.cancel(this@MainActivity)
toast("Расписание retrain отключено") toast("Расписание retrain отключено")
if (activeTab != "settings" || snapshot == null || !updateSettingsScreen(snapshot!!)) {
render() render()
}
}, },
).top(dp(10))) ).top(dp(10)))
} }
@@ -785,7 +1110,9 @@ class MainActivity : Activity() {
setPadding(dp(14), dp(12), dp(14), dp(12)) setPadding(dp(14), dp(12), dp(14), dp(12))
background = boxDrawable(palette.panel, palette.line, 8) background = boxDrawable(palette.panel, palette.line, 8)
addView(text(title, 12f, Typeface.NORMAL, palette.muted)) 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 = private fun tableHeader(columns: List<Pair<String, Float>>): View =
@@ -833,19 +1160,22 @@ class MainActivity : Activity() {
if (!isAuthError()) { if (!isAuthError()) {
return emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}") return emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}")
} }
val (savedLogin, savedPassword) = authParts(prefs.commandToken)
val apiInput = input("Адрес API бота", prefs.apiBaseUrl) 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 inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
} }
return section("Нужен вход в API", LinearLayout(this).apply { return section("Нужен вход в API", LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL 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(apiInput.top(dp(12)))
addView(authInput.top(dp(8))) addView(loginInput.top(dp(8)))
addView(passwordInput.top(dp(8)))
addView(actionRow( addView(actionRow(
"Подключиться" to { "Подключиться" to {
prefs.apiBaseUrl = apiInput.text.toString() prefs.apiBaseUrl = apiInput.text.toString()
prefs.commandToken = authInput.text.toString() prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString())
toast("Доступ сохранен, проверяю API") toast("Доступ сохранен, проверяю API")
refreshData(silent = false) refreshData(silent = false)
}, },
@@ -932,6 +1262,28 @@ class MainActivity : Activity() {
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.05f)) }, 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 { private fun allocationBar(fractionRaw: Double): View {
val fraction = fractionRaw.coerceIn(0.0, 1.0).toFloat() val fraction = fractionRaw.coerceIn(0.0, 1.0).toFloat()
return LinearLayout(this).apply { return LinearLayout(this).apply {
@@ -1107,13 +1459,18 @@ class MainActivity : Activity() {
RetrainScheduler.schedule(this, hours) RetrainScheduler.schedule(this, hours)
} }
toast("Интервал retrain: $hours ч") toast("Интервал retrain: $hours ч")
if (activeTab == "settings" && snapshot != null && updateSettingsScreen(snapshot!!)) {
return
}
render() render()
} }
private fun triggerRetrainNow() { private fun triggerRetrainNow() {
if (retrainRequestInFlight) return if (retrainRequestInFlight) return
retrainRequestInFlight = true retrainRequestInFlight = true
if (activeTab != "settings" || snapshot == null || !updateSettingsScreen(snapshot!!)) {
render() render()
}
executor.execute { executor.execute {
try { try {
val message = TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain() val message = TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
@@ -1155,6 +1512,53 @@ class MainActivity : Activity() {
private fun trainingStatusSignature(retrain: JSONObject): String = private fun trainingStatusSignature(retrain: JSONObject): String =
(retrain.optJSONObject("coordination") ?: JSONObject()).toString() (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 = private fun trainingJobLabel(status: String, phase: String): String =
when (status) { when (status) {
"pending" -> "ждет Windows-agent" "pending" -> "ждет Windows-agent"
@@ -1213,6 +1617,27 @@ class MainActivity : Activity() {
else -> value.ifBlank { "нет данных" } 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 = private fun modelLabel(value: String): String =
when { when {
value.contains("gru", ignoreCase = true) -> "PyTorch GRU" value.contains("gru", ignoreCase = true) -> "PyTorch GRU"
@@ -84,6 +84,7 @@ data class SignalData(
} }
data class PositionData( data class PositionData(
val id: Long?,
val symbol: String, val symbol: String,
val qty: Double, val qty: Double,
val entryPrice: Double, val entryPrice: Double,
@@ -97,6 +98,7 @@ data class PositionData(
val highestPrice: Double?, val highestPrice: Double?,
val trailingStop: Double?, val trailingStop: Double?,
val atrTrailingStop: Double?, val atrTrailingStop: Double?,
val openedAt: String,
val exitAction: String, val exitAction: String,
val exitReason: String, val exitReason: String,
val stopLossExitEnabled: Boolean, val stopLossExitEnabled: Boolean,
@@ -153,5 +155,8 @@ fun JSONObject.optStringClean(name: String): String =
fun JSONObject.optDoubleOrNull(name: String): Double? = fun JSONObject.optDoubleOrNull(name: String): Double? =
if (has(name) && !isNull(name)) optDouble(name) else null 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? = fun JSONObject.optBooleanOrNull(name: String): Boolean? =
if (has(name) && !isNull(name)) optBoolean(name) else null if (has(name) && !isNull(name)) optBoolean(name) else null
@@ -104,6 +104,7 @@ class TradeBotApi(
val row = items.optJSONObject(index) ?: continue val row = items.optJSONObject(index) ?: continue
val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject() val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject()
output += PositionData( output += PositionData(
id = row.optLongOrNull("id"),
symbol = row.optStringClean("symbol"), symbol = row.optStringClean("symbol"),
qty = row.optDouble("qty", 0.0), qty = row.optDouble("qty", 0.0),
entryPrice = row.optDouble("entry_price", 0.0), entryPrice = row.optDouble("entry_price", 0.0),
@@ -117,6 +118,7 @@ class TradeBotApi(
highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"), highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"),
trailingStop = exitPlan.optDoubleOrNull("trailing_stop"), trailingStop = exitPlan.optDoubleOrNull("trailing_stop"),
atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"), atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"),
openedAt = row.optStringClean("opened_at"),
exitAction = exitPlan.optStringClean("action"), exitAction = exitPlan.optStringClean("action"),
exitReason = exitPlan.optStringClean("reason"), exitReason = exitPlan.optStringClean("reason"),
stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true, stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true,