Show realized trade PnL in Android app
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-3
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user