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" applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 9 versionCode = 10
versionName = "0.2.6" versionName = "0.2.7"
} }
} }
@@ -275,13 +275,19 @@ class MainActivity : Activity() {
return wrapScroll(box) return wrapScroll(box)
} }
val pnl = data.positions.sumOf { it.unrealizedPnl } val openPnl = data.positions.sumOf { it.unrealizedPnl }
box.addView(assetHero(data, pnl).top(dp(14))) val realizedPnl = data.closedTradesSummary.netPnl
box.addView(assetHero(data, openPnl, realizedPnl).top(dp(14)))
box.addView(twoColumnMetrics( box.addView(twoColumnMetrics(
"Доступно" to money(data.account.cash), "Доступно" to money(data.account.cash),
"В позициях" to money(data.account.exposure), "В позициях" to money(data.account.exposure),
).top(dp(14))) ).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)))
return wrapScroll(box) return wrapScroll(box)
} }
@@ -545,12 +551,13 @@ class MainActivity : Activity() {
"Запустить цикл" to { sendControl(stop = false) }, "Запустить цикл" 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 { 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))) 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("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))) 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.entryPrice)).top(dp(4)))
addView(keyValueLine("Текущая", price(position.markPrice)).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("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))) }.top(dp(8)))
} }
return section("Открытые позиции", box) 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 = private fun trainingComputerPanel(retrain: JSONObject): View =
LinearLayout(this).apply { LinearLayout(this).apply {
val coordination = retrain.optJSONObject("coordination") ?: JSONObject() 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)) 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) { private fun setRetrainInterval(hours: Int) {
prefs.retrainIntervalHours = hours prefs.retrainIntervalHours = hours
if (prefs.retrainScheduleEnabled) { if (prefs.retrainScheduleEnabled) {
@@ -1154,6 +1206,31 @@ class MainActivity : Activity() {
private fun yesNo(value: Boolean): String = if (value) "да" else "нет" 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 = private fun colorForSigned(value: String): Int =
when { when {
value.trim().startsWith("-") -> palette.red value.trim().startsWith("-") -> palette.red
@@ -1182,6 +1259,9 @@ class MainActivity : Activity() {
else -> "0" 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 = private fun number(value: Double, digits: Int = 2): String =
"%,.${digits}f".format(Locale.US, value) "%,.${digits}f".format(Locale.US, value)
@@ -92,6 +92,14 @@ data class PositionData(
val marketValue: Double, val marketValue: Double,
val unrealizedPnl: Double, val unrealizedPnl: Double,
val unrealizedPnlPercent: 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( data class AccountData(
@@ -100,12 +108,37 @@ data class AccountData(
val exposure: Double, 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( data class BotSnapshot(
val ok: Boolean, val ok: Boolean,
val running: Boolean, val running: Boolean,
val mode: String, val mode: String,
val account: AccountData, val account: AccountData,
val positions: List<PositionData>, val positions: List<PositionData>,
val closedTrades: List<ClosedTradeData>,
val closedTradesSummary: ClosedTradesSummary,
val markets: List<MarketItem>, val markets: List<MarketItem>,
val signalsBySymbol: Map<String, SignalData>, val signalsBySymbol: Map<String, SignalData>,
val config: JSONObject, val config: JSONObject,
@@ -19,6 +19,7 @@ class TradeBotApi(
val markets = getJson("/api/markets") val markets = getJson("/api/markets")
val signals = getJson("/api/signals?limit=220") val signals = getJson("/api/signals?limit=220")
val config = getJson("/api/config") val config = getJson("/api/config")
val trades = getJson("/api/trades?limit=10")
val retrain = getJson("/api/retrain") val retrain = getJson("/api/retrain")
val backtest = getJson("/api/backtest") val backtest = getJson("/api/backtest")
@@ -35,6 +36,8 @@ class TradeBotApi(
mode = health.optStringClean("mode"), mode = health.optStringClean("mode"),
account = account, account = account,
positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()), 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()), markets = parseMarkets(markets.optJSONArray("markets") ?: JSONArray()),
signalsBySymbol = parseLatestSignals(signals.optJSONArray("items") ?: JSONArray()), signalsBySymbol = parseLatestSignals(signals.optJSONArray("items") ?: JSONArray()),
config = config, config = config,
@@ -99,6 +102,7 @@ class TradeBotApi(
val output = mutableListOf<PositionData>() val output = mutableListOf<PositionData>()
for (index in 0 until items.length()) { for (index in 0 until items.length()) {
val row = items.optJSONObject(index) ?: continue val row = items.optJSONObject(index) ?: continue
val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject()
output += PositionData( output += PositionData(
symbol = row.optStringClean("symbol"), symbol = row.optStringClean("symbol"),
qty = row.optDouble("qty", 0.0), qty = row.optDouble("qty", 0.0),
@@ -108,11 +112,50 @@ class TradeBotApi(
marketValue = row.optDouble("market_value", 0.0), marketValue = row.optDouble("market_value", 0.0),
unrealizedPnl = row.optDouble("unrealized_pnl", 0.0), unrealizedPnl = row.optDouble("unrealized_pnl", 0.0),
unrealizedPnlPercent = row.optDouble("unrealized_pnl_percent", 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 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> { private fun parseMarkets(items: JSONArray): List<MarketItem> {
val output = mutableListOf<MarketItem>() val output = mutableListOf<MarketItem>()
for (index in 0 until items.length()) { for (index in 0 until items.length()) {
+29 -4
View File
@@ -358,10 +358,35 @@ class CryptoSpotBot:
def positions_snapshot(self) -> list[dict]: def positions_snapshot(self) -> list[dict]:
prices = self.market.prices() prices = self.market.prices()
return [ items: list[dict] = []
position.as_dict(mark_price=prices.get(position.symbol, position.entry_price)) for position in self.broker.open_positions():
for position in self.broker.open_positions() mark_price = prices.get(position.symbol, position.entry_price)
] item = position.as_dict(mark_price=mark_price)
exit_signal = self.strategy.exit_signal(
position=position,
candles=self.market.candles.get(position.symbol, []),
ticker=self.market.tickers.get(position.symbol),
learning=self.learner.state.as_dict(),
forecast=self.market.forecasts.get(position.symbol, {}),
)
diagnostics = exit_signal.diagnostics or {}
fallback_stop_loss = position.stop_loss if self.settings.stop_loss_exit_enabled else None
item["exit_plan"] = {
"action": exit_signal.action,
"reason": exit_signal.reason,
"confidence": exit_signal.confidence,
"stop_loss": diagnostics.get("stop_loss", fallback_stop_loss),
"take_profit": diagnostics.get("take_profit", position.take_profit),
"trailing_stop": diagnostics.get("trailing_stop"),
"atr_trailing_stop": diagnostics.get("atr_trailing_stop"),
"highest_price": diagnostics.get("highest_price", position.highest_price),
"stop_loss_exit_enabled": diagnostics.get(
"stop_loss_exit_enabled",
self.settings.stop_loss_exit_enabled,
),
}
items.append(item)
return items
def learning_snapshot(self) -> dict: def learning_snapshot(self) -> dict:
snapshot = self.learner.state.as_dict() snapshot = self.learner.state.as_dict()
+6 -1
View File
@@ -84,7 +84,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
@app.get("/api/trades") @app.get("/api/trades")
async def trades(limit: int = 80) -> dict[str, Any]: async def trades(limit: int = 80) -> dict[str, Any]:
return {"items": storage.recent_trades(_limit(limit))} row_limit = _limit(limit)
return {
"items": storage.recent_trades(row_limit),
"closed_items": storage.closed_trades(row_limit),
"closed_summary": storage.closed_trade_summary(),
}
@app.get("/api/signals") @app.get("/api/signals")
async def signals(limit: int = 120) -> dict[str, Any]: async def signals(limit: int = 120) -> dict[str, Any]:
+28
View File
@@ -242,6 +242,34 @@ class Storage:
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def closed_trade_summary(self) -> dict[str, Any]:
with self.connect() as conn:
row = conn.execute(
"""
SELECT
COUNT(*) AS trades,
COALESCE(SUM(net_pnl), 0) AS net_pnl,
COALESCE(SUM(gross_pnl), 0) AS gross_pnl,
COALESCE(SUM(fee_usdt), 0) AS fee_usdt,
COALESCE(SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END), 0) AS wins,
COALESCE(SUM(CASE WHEN net_pnl < 0 THEN 1 ELSE 0 END), 0) AS losses
FROM trades
WHERE side='SELL' AND closed_at IS NOT NULL
"""
).fetchone()
trades = int(row["trades"] if row else 0)
wins = int(row["wins"] if row else 0)
losses = int(row["losses"] if row else 0)
return {
"trades": trades,
"net_pnl": round(float(row["net_pnl"] if row else 0.0), 6),
"gross_pnl": round(float(row["gross_pnl"] if row else 0.0), 6),
"fee_usdt": round(float(row["fee_usdt"] if row else 0.0), 6),
"wins": wins,
"losses": losses,
"win_rate": round(wins / trades, 4) if trades else 0.0,
}
def insert_signal(self, signal: Signal) -> None: def insert_signal(self, signal: Signal) -> None:
with self.connect() as conn: with self.connect() as conn:
conn.execute( conn.execute(
+3
View File
@@ -27,6 +27,9 @@ def test_paper_broker_buy_and_sell_records_trade(make_settings, tmp_path) -> Non
assert trade.side == "SELL" assert trade.side == "SELL"
assert len(broker.open_positions()) == 0 assert len(broker.open_positions()) == 0
assert storage.recent_trades(limit=10) assert storage.recent_trades(limit=10)
summary = storage.closed_trade_summary()
assert summary["trades"] == 1
assert summary["net_pnl"] == round(trade.net_pnl, 6)
def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) -> None: def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) -> None: