diff --git a/android/TradeBotMonitor/app/build.gradle.kts b/android/TradeBotMonitor/app/build.gradle.kts index 0c3964c..ceaa312 100644 --- a/android/TradeBotMonitor/app/build.gradle.kts +++ b/android/TradeBotMonitor/app/build.gradle.kts @@ -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" } } diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt index 9980f7c..305cd82 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/MainActivity.kt @@ -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, 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) diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt index c0a303d..0e00457 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/Models.kt @@ -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, + val closedTrades: List, + val closedTradesSummary: ClosedTradesSummary, val markets: List, val signalsBySymbol: Map, val config: JSONObject, diff --git a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt index 1f53b88..db735a7 100644 --- a/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt +++ b/android/TradeBotMonitor/app/src/main/java/xyz/kusoft/tradebotmonitor/TradeBotApi.kt @@ -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() 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 { + val output = mutableListOf() + 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 { val output = mutableListOf() for (index in 0 until items.length()) { diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py index e24ea21..c4ee1f7 100644 --- a/crypto_spot_bot/bot.py +++ b/crypto_spot_bot/bot.py @@ -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() diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index 28b344e..eb70f62 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -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]: diff --git a/crypto_spot_bot/storage.py b/crypto_spot_bot/storage.py index a433525..8d2c113 100644 --- a/crypto_spot_bot/storage.py +++ b/crypto_spot_bot/storage.py @@ -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( diff --git a/tests/test_execution.py b/tests/test_execution.py index fd402aa..6a81aeb 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -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: