Show realized trade PnL in Android app
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+83
-3
@@ -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()) {
|
||||
|
||||
+29
-4
@@ -358,10 +358,35 @@ class CryptoSpotBot:
|
||||
|
||||
def positions_snapshot(self) -> list[dict]:
|
||||
prices = self.market.prices()
|
||||
return [
|
||||
position.as_dict(mark_price=prices.get(position.symbol, position.entry_price))
|
||||
for position in self.broker.open_positions()
|
||||
]
|
||||
items: list[dict] = []
|
||||
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:
|
||||
snapshot = self.learner.state.as_dict()
|
||||
|
||||
@@ -84,7 +84,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
@app.get("/api/trades")
|
||||
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")
|
||||
async def signals(limit: int = 120) -> dict[str, Any]:
|
||||
|
||||
@@ -242,6 +242,34 @@ class Storage:
|
||||
).fetchall()
|
||||
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:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
|
||||
@@ -27,6 +27,9 @@ def test_paper_broker_buy_and_sell_records_trade(make_settings, tmp_path) -> Non
|
||||
assert trade.side == "SELL"
|
||||
assert len(broker.open_positions()) == 0
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user