Show realized trade PnL in Android app

This commit is contained in:
Codex
2026-06-29 21:06:51 +03:00
parent fe276d95ff
commit 9f47c04235
8 changed files with 227 additions and 10 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ android {
applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26
targetSdk = 36
versionCode = 9
versionName = "0.2.6"
versionCode = 10
versionName = "0.2.7"
}
}
@@ -275,13 +275,19 @@ class MainActivity : Activity() {
return wrapScroll(box)
}
val pnl = data.positions.sumOf { it.unrealizedPnl }
box.addView(assetHero(data, pnl).top(dp(14)))
val openPnl = data.positions.sumOf { it.unrealizedPnl }
val realizedPnl = data.closedTradesSummary.netPnl
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(positionsPanel(data.positions).top(dp(16)))
box.addView(closedTradesPanel(data.closedTrades, data.closedTradesSummary).top(dp(16)))
return wrapScroll(box)
}
@@ -545,12 +551,13 @@ class MainActivity : Activity() {
"Запустить цикл" to { sendControl(stop = false) },
)
private fun assetHero(data: BotSnapshot, pnl: Double): View =
private fun assetHero(data: BotSnapshot, pnl: Double, realizedPnl: Double): View =
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)))
}
@@ -567,11 +574,45 @@ class MainActivity : Activity() {
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)
}
private fun closedTradesPanel(trades: List<ClosedTradeData>, summary: ClosedTradesSummary): View {
val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
box.addView(keyValueLine("Всего закрыто", summary.trades.toString()))
box.addView(keyValueLine("Реализованный итог", signedMoney(summary.netPnl), colorForSigned(summary.netPnl)).top(dp(4)))
box.addView(keyValueLine("Комиссии", money(summary.feeUsdt), palette.muted).top(dp(4)))
box.addView(keyValueLine("Win-rate", percent(summary.winRate * 100.0, 1)).top(dp(4)))
if (trades.isEmpty()) {
box.addView(text("Закрытых сделок пока нет. Рост equity выше 100 USDT сейчас может быть только плавающей оценкой открытых позиций.", 13f, Typeface.NORMAL, palette.muted).top(dp(12)))
return section("Последние 10 закрытых сделок", box)
}
box.addView(tableHeader(listOf("Время" to 1.05f, "Пара" to 0.9f, "Выход" to 0.9f, "Итог" to 0.9f)).top(dp(12)))
trades.take(10).forEach { trade ->
box.addView(closedTradeRow(trade).top(dp(7)))
}
return section("Последние 10 закрытых сделок", box)
}
private fun closedTradeRow(trade: ClosedTradeData): View =
LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
addView(text(shortIsoTime(trade.closedAt), 12f, Typeface.NORMAL, palette.muted), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.05f))
addView(text(trade.symbol, 12f, Typeface.BOLD), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 0.9f))
addView(text(priceOrNone(trade.exitPrice), 12f, Typeface.NORMAL, palette.text).apply {
gravity = Gravity.END
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 0.9f))
addView(text(signedMoney(trade.netPnl), 12f, Typeface.BOLD, colorForSigned(trade.netPnl)).apply {
gravity = Gravity.END
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 0.9f))
}
private fun trainingComputerPanel(retrain: JSONObject): View =
LinearLayout(this).apply {
val coordination = retrain.optJSONObject("coordination") ?: JSONObject()
@@ -1039,6 +1080,17 @@ class MainActivity : Activity() {
return SimpleDateFormat("dd.MM HH:mm", Locale("ru", "RU")).format(Date(millis))
}
private fun shortIsoTime(value: String): String {
if (value.isBlank()) return "-"
return try {
val normalized = value.replace("Z", "+00:00")
val millis = java.time.OffsetDateTime.parse(normalized).toInstant().toEpochMilli()
SimpleDateFormat("dd.MM HH:mm", Locale("ru", "RU")).format(Date(millis))
} catch (_: Exception) {
value.take(16).replace("T", " ")
}
}
private fun setRetrainInterval(hours: Int) {
prefs.retrainIntervalHours = hours
if (prefs.retrainScheduleEnabled) {
@@ -1154,6 +1206,31 @@ class MainActivity : Activity() {
private fun yesNo(value: Boolean): String = if (value) "да" else "нет"
private fun exitSignalText(position: PositionData): String {
val action = normalizedAction(position.exitAction)
val reason = position.exitReason.ifBlank { "нет причины" }
return if (action == "SELL") "SELL сейчас: ${reason.take(26)}" else "держим: ${reason.take(30)}"
}
private fun protectiveExitText(position: PositionData): String {
val trailing = position.atrTrailingStop ?: position.trailingStop
if (trailing != null && trailing > 0.0) {
return "trailing ${price(trailing)}"
}
val stop = position.stopLoss
if (position.stopLossExitEnabled && stop != null && stop > 0.0) {
return "stop-loss ${price(stop)}"
}
return "stop-loss выключен"
}
private fun protectiveExitColor(position: PositionData): Int =
when {
position.atrTrailingStop != null || position.trailingStop != null -> palette.amber
position.stopLossExitEnabled && position.stopLoss != null -> palette.red
else -> palette.muted
}
private fun colorForSigned(value: String): Int =
when {
value.trim().startsWith("-") -> palette.red
@@ -1182,6 +1259,9 @@ class MainActivity : Activity() {
else -> "0"
}
private fun priceOrNone(value: Double?): String =
if (value != null && value > 0.0) price(value) else "нет"
private fun number(value: Double, digits: Int = 2): String =
"%,.${digits}f".format(Locale.US, value)
@@ -92,6 +92,14 @@ data class PositionData(
val marketValue: Double,
val unrealizedPnl: Double,
val unrealizedPnlPercent: Double,
val stopLoss: Double?,
val takeProfit: Double?,
val highestPrice: Double?,
val trailingStop: Double?,
val atrTrailingStop: Double?,
val exitAction: String,
val exitReason: String,
val stopLossExitEnabled: Boolean,
)
data class AccountData(
@@ -100,12 +108,37 @@ data class AccountData(
val exposure: Double,
)
data class ClosedTradeData(
val symbol: String,
val qty: Double,
val entryPrice: Double?,
val exitPrice: Double?,
val grossPnl: Double,
val feeUsdt: Double,
val netPnl: Double,
val reason: String,
val openedAt: String,
val closedAt: String,
)
data class ClosedTradesSummary(
val trades: Int,
val netPnl: Double,
val grossPnl: Double,
val feeUsdt: Double,
val wins: Int,
val losses: Int,
val winRate: Double,
)
data class BotSnapshot(
val ok: Boolean,
val running: Boolean,
val mode: String,
val account: AccountData,
val positions: List<PositionData>,
val closedTrades: List<ClosedTradeData>,
val closedTradesSummary: ClosedTradesSummary,
val markets: List<MarketItem>,
val signalsBySymbol: Map<String, SignalData>,
val config: JSONObject,
@@ -19,6 +19,7 @@ class TradeBotApi(
val markets = getJson("/api/markets")
val signals = getJson("/api/signals?limit=220")
val config = getJson("/api/config")
val trades = getJson("/api/trades?limit=10")
val retrain = getJson("/api/retrain")
val backtest = getJson("/api/backtest")
@@ -35,6 +36,8 @@ class TradeBotApi(
mode = health.optStringClean("mode"),
account = account,
positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()),
closedTrades = parseClosedTrades(trades.optJSONArray("closed_items") ?: JSONArray()),
closedTradesSummary = parseClosedTradesSummary(trades.optJSONObject("closed_summary") ?: JSONObject()),
markets = parseMarkets(markets.optJSONArray("markets") ?: JSONArray()),
signalsBySymbol = parseLatestSignals(signals.optJSONArray("items") ?: JSONArray()),
config = config,
@@ -99,6 +102,7 @@ class TradeBotApi(
val output = mutableListOf<PositionData>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject()
output += PositionData(
symbol = row.optStringClean("symbol"),
qty = row.optDouble("qty", 0.0),
@@ -108,11 +112,50 @@ class TradeBotApi(
marketValue = row.optDouble("market_value", 0.0),
unrealizedPnl = row.optDouble("unrealized_pnl", 0.0),
unrealizedPnlPercent = row.optDouble("unrealized_pnl_percent", 0.0),
stopLoss = exitPlan.optDoubleOrNull("stop_loss") ?: row.optDoubleOrNull("stop_loss"),
takeProfit = exitPlan.optDoubleOrNull("take_profit") ?: row.optDoubleOrNull("take_profit"),
highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"),
trailingStop = exitPlan.optDoubleOrNull("trailing_stop"),
atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"),
exitAction = exitPlan.optStringClean("action"),
exitReason = exitPlan.optStringClean("reason"),
stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true,
)
}
return output
}
private fun parseClosedTrades(items: JSONArray): List<ClosedTradeData> {
val output = mutableListOf<ClosedTradeData>()
for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue
output += ClosedTradeData(
symbol = row.optStringClean("symbol"),
qty = row.optDouble("qty", 0.0),
entryPrice = row.optDoubleOrNull("entry_price"),
exitPrice = row.optDoubleOrNull("exit_price"),
grossPnl = row.optDouble("gross_pnl", 0.0),
feeUsdt = row.optDouble("fee_usdt", 0.0),
netPnl = row.optDouble("net_pnl", 0.0),
reason = row.optStringClean("reason"),
openedAt = row.optStringClean("opened_at"),
closedAt = row.optStringClean("closed_at"),
)
}
return output
}
private fun parseClosedTradesSummary(row: JSONObject): ClosedTradesSummary =
ClosedTradesSummary(
trades = row.optInt("trades", 0),
netPnl = row.optDouble("net_pnl", 0.0),
grossPnl = row.optDouble("gross_pnl", 0.0),
feeUsdt = row.optDouble("fee_usdt", 0.0),
wins = row.optInt("wins", 0),
losses = row.optInt("losses", 0),
winRate = row.optDouble("win_rate", 0.0),
)
private fun parseMarkets(items: JSONArray): List<MarketItem> {
val output = mutableListOf<MarketItem>()
for (index in 0 until items.length()) {