Compare commits
6 Commits
4a4039c03d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fb79ee2a9 | |||
| 77a063d263 | |||
| e21a96640d | |||
| 7f1ac694e4 | |||
| 33c3831bf2 | |||
| f0c70e2e1f |
@@ -10,7 +10,7 @@ android {
|
||||
applicationId = "xyz.kusoft.tradebotmonitor"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 12
|
||||
versionName = "0.2.9"
|
||||
versionCode = 17
|
||||
versionName = "0.2.14"
|
||||
}
|
||||
}
|
||||
|
||||
+546
-61
@@ -11,10 +11,14 @@ import android.text.InputType
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import org.json.JSONObject
|
||||
@@ -36,10 +40,62 @@ class MainActivity : Activity() {
|
||||
|
||||
private var snapshot: BotSnapshot? = null
|
||||
private var lastError: String = ""
|
||||
private var activeTab: String = "ai"
|
||||
private var activeTab: String = "assets"
|
||||
private var palette: AppPalette = AppPalette.dark()
|
||||
private var trainingStatusSignature: String = ""
|
||||
private var retrainRequestInFlight: Boolean = false
|
||||
private var assetsBindings: AssetsBindings? = null
|
||||
private var tradingBindings: TradingBindings? = null
|
||||
private var settingsBindings: SettingsBindings? = null
|
||||
|
||||
private data class AssetsBindings(
|
||||
var positionSignature: String,
|
||||
var closedTradesSignature: String,
|
||||
var equity: TextView? = null,
|
||||
var openPnl: TextView? = null,
|
||||
var realizedPnl: TextView? = null,
|
||||
var mode: TextView? = null,
|
||||
var cash: TextView? = null,
|
||||
var exposure: TextView? = null,
|
||||
var positionCount: TextView? = null,
|
||||
var positionsContainer: LinearLayout? = null,
|
||||
var positionsScroller: HorizontalScrollView? = null,
|
||||
var positionsList: LinearLayout? = null,
|
||||
var closedTradesContainer: LinearLayout? = null,
|
||||
val positionOrder: MutableList<String> = mutableListOf(),
|
||||
val positions: MutableMap<String, PositionBinding> = linkedMapOf(),
|
||||
)
|
||||
|
||||
private data class PositionBinding(
|
||||
var index: TextView? = null,
|
||||
var value: TextView? = null,
|
||||
var quantity: TextView? = null,
|
||||
var entryPrice: TextView? = null,
|
||||
var markPrice: TextView? = null,
|
||||
var pnl: TextView? = null,
|
||||
var takeProfit: TextView? = null,
|
||||
var highestPrice: TextView? = null,
|
||||
)
|
||||
|
||||
private data class TradingBindings(
|
||||
val symbol: String,
|
||||
var price: TextView? = null,
|
||||
var edgeLine: TextView? = null,
|
||||
var equity: TextView? = null,
|
||||
var status: TextView? = null,
|
||||
var chart: CandleChartView? = null,
|
||||
var chartTime: TextView? = null,
|
||||
var decision: TextView? = null,
|
||||
var kelly: TextView? = null,
|
||||
var reason: TextView? = null,
|
||||
)
|
||||
|
||||
private data class SettingsBindings(
|
||||
var retrainSignature: String,
|
||||
var liveSignature: String,
|
||||
var retrainContainer: LinearLayout? = null,
|
||||
var liveContainer: LinearLayout? = null,
|
||||
)
|
||||
|
||||
private val refreshRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
@@ -111,6 +167,9 @@ class MainActivity : Activity() {
|
||||
trainingStatusSignature = newTrainingStatusSignature
|
||||
lastError = ""
|
||||
ensureSelectedSymbol()
|
||||
if (silent && hadSnapshot && updateVisibleScreen(data)) {
|
||||
return@post
|
||||
}
|
||||
if (shouldRenderAfterRefresh(silent, hadSnapshot, hadError, trainingChanged)) {
|
||||
render()
|
||||
}
|
||||
@@ -121,6 +180,9 @@ class MainActivity : Activity() {
|
||||
val hadError = lastError.isNotBlank()
|
||||
lastError = error.message ?: "ошибка подключения"
|
||||
if (!silent) toast(lastError)
|
||||
if (silent && hadSnapshot) {
|
||||
return@post
|
||||
}
|
||||
if (shouldRenderAfterRefresh(silent, hadSnapshot, hadError, trainingChanged = false)) {
|
||||
render()
|
||||
}
|
||||
@@ -140,16 +202,17 @@ class MainActivity : Activity() {
|
||||
if (activeTab == "settings" && trainingChanged) {
|
||||
return true
|
||||
}
|
||||
if (activeTab == "assets" || activeTab == "trading") {
|
||||
return true
|
||||
}
|
||||
return !hadSnapshot || hadError
|
||||
}
|
||||
|
||||
private fun renderBottomNav() {
|
||||
bottomNav.removeAllViews()
|
||||
listOf(
|
||||
"markets" to "Рынки",
|
||||
"ai" to "AI",
|
||||
"risk" to "Риск",
|
||||
"assets" to "Активы",
|
||||
"trading" to "Торговля",
|
||||
"settings" to "Настройки",
|
||||
).forEach { (id, title) ->
|
||||
bottomNav.addView(
|
||||
@@ -171,16 +234,120 @@ class MainActivity : Activity() {
|
||||
if (saveScroll) {
|
||||
saveCurrentScroll(tabForRender)
|
||||
}
|
||||
contentHost.removeAllViews()
|
||||
assetsBindings = null
|
||||
tradingBindings = null
|
||||
settingsBindings = null
|
||||
val view = when (activeTab) {
|
||||
"markets" -> marketsScreen()
|
||||
"risk" -> riskScreen()
|
||||
"assets" -> assetsScreen()
|
||||
"trading" -> tradingScreen()
|
||||
"settings" -> settingsScreen()
|
||||
else -> aiScreen()
|
||||
else -> assetsScreen()
|
||||
}
|
||||
val previous = contentHost.getChildAt(0)
|
||||
contentHost.addView(view)
|
||||
restoreScroll(view, tabForRender)
|
||||
if (previous != null) {
|
||||
contentHost.removeView(previous)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateVisibleScreen(data: BotSnapshot): Boolean =
|
||||
when (activeTab) {
|
||||
"assets" -> updateAssetsScreen(data)
|
||||
"trading" -> updateTradingScreen(data)
|
||||
"settings" -> updateSettingsScreen(data)
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun updateAssetsScreen(data: BotSnapshot): Boolean {
|
||||
val binding = assetsBindings ?: return false
|
||||
val positionSignature = positionsSignature(data.positions)
|
||||
if (binding.positionSignature != positionSignature) {
|
||||
if (!syncPositionsList(binding, data.positions)) return false
|
||||
binding.positionSignature = positionSignature
|
||||
}
|
||||
val closedSignature = closedTradesSignature(data.closedTrades, data.closedTradesSummary)
|
||||
if (binding.closedTradesSignature != closedSignature) {
|
||||
binding.closedTradesContainer?.let { container ->
|
||||
container.removeAllViews()
|
||||
container.addView(closedTradesPanel(data.closedTrades, data.closedTradesSummary))
|
||||
binding.closedTradesSignature = closedSignature
|
||||
} ?: return false
|
||||
}
|
||||
|
||||
val openPnl = data.positions.sumOf { it.unrealizedPnl }
|
||||
val realizedPnl = data.closedTradesSummary.netPnl
|
||||
setText(binding.equity, money(data.account.equity))
|
||||
setText(binding.openPnl, "P&L открытых позиций ${signedMoney(openPnl)}", colorForSigned(openPnl))
|
||||
setText(binding.realizedPnl, "Прибыль закрытых сделок ${signedMoney(realizedPnl)}", colorForSigned(realizedPnl))
|
||||
setText(binding.mode, "Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"}")
|
||||
setText(binding.cash, money(data.account.cash))
|
||||
setText(binding.exposure, money(data.account.exposure))
|
||||
setText(binding.positionCount, "Открытые позиции: ${data.positions.size}")
|
||||
|
||||
data.positions.forEach { position ->
|
||||
val row = binding.positions[positionKey(position)] ?: return@forEach
|
||||
setText(row.value, "${money(position.marketValue)} · ${signedPercent(position.unrealizedPnlPercent, 2)}", colorForSigned(position.unrealizedPnlPercent))
|
||||
setText(row.quantity, number(position.qty, 8))
|
||||
setText(row.entryPrice, price(position.entryPrice))
|
||||
setText(row.markPrice, price(position.markPrice))
|
||||
setText(row.pnl, signedMoney(position.unrealizedPnl), colorForSigned(position.unrealizedPnl))
|
||||
setText(row.takeProfit, priceOrNone(position.takeProfit))
|
||||
setText(row.highestPrice, priceOrNone(position.highestPrice))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateTradingScreen(data: BotSnapshot): Boolean {
|
||||
val binding = tradingBindings ?: return false
|
||||
if (binding.symbol != prefs.selectedSymbol) return false
|
||||
val market = selectedMarket(data) ?: return false
|
||||
if (market.symbol != binding.symbol) return false
|
||||
val signal = data.signalsBySymbol[market.symbol]
|
||||
val edge = signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0
|
||||
val probability = signal?.probabilityUp ?: market.forecast?.probabilityUp ?: 0.0
|
||||
val action = normalizedAction(signal?.action)
|
||||
val decision = when (action) {
|
||||
"BUY" -> "Покупка разрешена"
|
||||
"SELL" -> "Выход из позиции"
|
||||
else -> "Ожидание"
|
||||
}
|
||||
val reason = signal?.reason?.ifBlank { market.forecast?.reason.orEmpty() } ?: market.forecast?.reason.orEmpty()
|
||||
|
||||
setText(binding.price, price(latestPrice(market)), colorForSigned(edge))
|
||||
setText(binding.edgeLine, "Edge ${signedPercent(edge, 2)} · P(up) ${probability(probability)} · 1h")
|
||||
setText(binding.equity, money(data.account.equity))
|
||||
setText(binding.status, if (data.running) "РАБОТАЕТ" else "СТОП", if (data.running) palette.green else palette.amber)
|
||||
setText(binding.decision, decision, actionColor(action))
|
||||
setText(binding.kelly, money(signal?.positionNotionalUsdt ?: 0.0))
|
||||
setText(binding.reason, reason.ifBlank { "Нет объяснения от модели" })
|
||||
binding.chart?.candles = market.candles
|
||||
setText(binding.chartTime, lastCandleTime(market))
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateSettingsScreen(data: BotSnapshot): Boolean {
|
||||
if (contentHost.findFocus() is EditText) {
|
||||
return true
|
||||
}
|
||||
val binding = settingsBindings ?: return false
|
||||
val retrainSignature = retrainBlockSignature(data)
|
||||
if (binding.retrainSignature != retrainSignature) {
|
||||
binding.retrainContainer?.let { container ->
|
||||
container.removeAllViews()
|
||||
container.addView(section("Переобучение Torch", retrainSettingsBlock(data.retrain, data.backtest)))
|
||||
binding.retrainSignature = retrainSignature
|
||||
} ?: return false
|
||||
}
|
||||
val liveSignature = liveBlockSignature(data)
|
||||
if (binding.liveSignature != liveSignature) {
|
||||
binding.liveContainer?.let { container ->
|
||||
container.removeAllViews()
|
||||
container.addView(section("Переход к live-торговле", liveSettingsBlock(data)))
|
||||
binding.liveSignature = liveSignature
|
||||
} ?: return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveCurrentScroll(tabId: String = activeTab) {
|
||||
@@ -199,9 +366,9 @@ class MainActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun aiScreen(): View {
|
||||
private fun tradingScreen(): View {
|
||||
val box = screenContent()
|
||||
box.addView(statusLine("AI Торговля", modeBadgeText()))
|
||||
box.addView(statusLine("Торговля", modeBadgeText()))
|
||||
val data = snapshot
|
||||
if (data == null) {
|
||||
box.addView(connectionStatePanel().top(dp(18)))
|
||||
@@ -215,11 +382,12 @@ class MainActivity : Activity() {
|
||||
}
|
||||
val signal = data.signalsBySymbol[market.symbol]
|
||||
|
||||
tradingBindings = TradingBindings(market.symbol)
|
||||
box.addView(pairSelector(data).top(dp(14)))
|
||||
box.addView(aiHero(data, market, signal).top(dp(14)))
|
||||
box.addView(chartTabs().top(dp(18)))
|
||||
box.addView(chartPanel(market).top(dp(10)))
|
||||
box.addView(decisionPanel(market, signal).top(dp(14)))
|
||||
box.addView(torchFeaturePanel(market.forecast).top(dp(14)))
|
||||
return wrapScroll(box)
|
||||
}
|
||||
|
||||
@@ -278,36 +446,43 @@ class MainActivity : Activity() {
|
||||
|
||||
val openPnl = data.positions.sumOf { it.unrealizedPnl }
|
||||
val realizedPnl = data.closedTradesSummary.netPnl
|
||||
assetsBindings = AssetsBindings(
|
||||
positionSignature = positionsSignature(data.positions),
|
||||
closedTradesSignature = closedTradesSignature(data.closedTrades, data.closedTradesSummary),
|
||||
)
|
||||
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(accountMetrics(data).top(dp(14)))
|
||||
box.addView(positionsPanel(data.positions).top(dp(16)))
|
||||
box.addView(closedTradesPanel(data.closedTrades, data.closedTradesSummary).top(dp(16)))
|
||||
val closedTradesHost = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||
assetsBindings?.closedTradesContainer = closedTradesHost
|
||||
closedTradesHost.addView(closedTradesPanel(data.closedTrades, data.closedTradesSummary))
|
||||
box.addView(closedTradesHost.top(dp(16)))
|
||||
return wrapScroll(box)
|
||||
}
|
||||
|
||||
private fun settingsScreen(): View {
|
||||
val box = screenContent()
|
||||
box.addView(statusLine("Настройки", prefs.apiBaseUrl))
|
||||
settingsBindings = SettingsBindings(
|
||||
retrainSignature = retrainBlockSignature(snapshot),
|
||||
liveSignature = liveBlockSignature(snapshot),
|
||||
)
|
||||
|
||||
val (savedLogin, savedPassword) = authParts(prefs.commandToken)
|
||||
val apiInput = input("Адрес API бота", prefs.apiBaseUrl)
|
||||
val tokenInput = input("API auth: token, Bearer, Basic или login:password", prefs.commandToken).apply {
|
||||
val loginInput = input("Логин API", savedLogin)
|
||||
val passwordInput = input("Пароль API", savedPassword).apply {
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
box.addView(section("Подключение", LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(apiInput)
|
||||
addView(tokenInput.top(dp(8)))
|
||||
addView(loginInput.top(dp(8)))
|
||||
addView(passwordInput.top(dp(8)))
|
||||
addView(actionRow(
|
||||
"Сохранить" to {
|
||||
prefs.apiBaseUrl = apiInput.text.toString()
|
||||
prefs.commandToken = tokenInput.text.toString()
|
||||
prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString())
|
||||
toast("Подключение сохранено")
|
||||
refreshData(silent = false)
|
||||
},
|
||||
@@ -330,8 +505,15 @@ class MainActivity : Activity() {
|
||||
},
|
||||
)).top(dp(14)))
|
||||
|
||||
box.addView(section("Переобучение Torch", retrainSettingsBlock(snapshot?.retrain ?: JSONObject(), snapshot?.backtest ?: JSONObject())).top(dp(14)))
|
||||
box.addView(section("Переход к live-торговле", liveSettingsBlock(snapshot)).top(dp(14)))
|
||||
val retrainHost = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||
settingsBindings?.retrainContainer = retrainHost
|
||||
retrainHost.addView(section("Переобучение Torch", retrainSettingsBlock(snapshot?.retrain ?: JSONObject(), snapshot?.backtest ?: JSONObject())))
|
||||
box.addView(retrainHost.top(dp(14)))
|
||||
|
||||
val liveHost = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||
settingsBindings?.liveContainer = liveHost
|
||||
liveHost.addView(section("Переход к live-торговле", liveSettingsBlock(snapshot)))
|
||||
box.addView(liveHost.top(dp(14)))
|
||||
return wrapScroll(box)
|
||||
}
|
||||
|
||||
@@ -347,18 +529,26 @@ class MainActivity : Activity() {
|
||||
top.addView(LinearLayout(this@MainActivity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(text(market.symbol, 20f, Typeface.BOLD))
|
||||
addView(text(price(latestPrice(market)), 32f, Typeface.BOLD, colorForSigned(signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0)).top(dp(6)))
|
||||
val priceText = text(price(latestPrice(market)), 32f, Typeface.BOLD, colorForSigned(signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0))
|
||||
tradingBindings?.price = priceText
|
||||
addView(priceText.top(dp(6)))
|
||||
val edge = signal?.expectedReturnPercent ?: market.forecast?.expectedReturnPercent ?: 0.0
|
||||
val probability = signal?.probabilityUp ?: market.forecast?.probabilityUp ?: 0.0
|
||||
addView(text("Edge ${signedPercent(edge, 2)} · P(up) ${probability(probability)} · 1h", 13f, Typeface.NORMAL, palette.muted).top(dp(2)))
|
||||
val edgeLine = text("Edge ${signedPercent(edge, 2)} · P(up) ${probability(probability)} · 1h", 13f, Typeface.NORMAL, palette.muted)
|
||||
tradingBindings?.edgeLine = edgeLine
|
||||
addView(edgeLine.top(dp(2)))
|
||||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
|
||||
top.addView(LinearLayout(this@MainActivity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.END
|
||||
addView(text("Equity", 12f, Typeface.NORMAL, palette.muted))
|
||||
addView(text(money(data.account.equity), 18f, Typeface.BOLD).top(dp(5)))
|
||||
addView(statusBadge(if (data.running) "Работает" else "Стоп", if (data.running) "ok" else "warn").top(dp(8)))
|
||||
val equityText = text(money(data.account.equity), 18f, Typeface.BOLD)
|
||||
tradingBindings?.equity = equityText
|
||||
addView(equityText.top(dp(5)))
|
||||
val statusText = statusBadge(if (data.running) "Работает" else "Стоп", if (data.running) "ok" else "warn")
|
||||
tradingBindings?.status = statusText
|
||||
addView(statusText.top(dp(8)))
|
||||
}, LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
|
||||
addView(top)
|
||||
@@ -374,7 +564,6 @@ class MainActivity : Activity() {
|
||||
setPadding(0, dp(12), 0, dp(6))
|
||||
addView(tabText("График", true))
|
||||
addView(tabText("Torch", false))
|
||||
addView(tabText("Риск", false))
|
||||
addView(tabText("История", false))
|
||||
})
|
||||
}
|
||||
@@ -387,18 +576,67 @@ class MainActivity : Activity() {
|
||||
palette = this@MainActivity.palette
|
||||
candles = market.candles
|
||||
}
|
||||
tradingBindings?.chart = chart
|
||||
addView(chart, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(278)))
|
||||
addView(LinearLayout(this@MainActivity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(0, dp(4), 0, 0)
|
||||
addView(text("EMA50", 11f, Typeface.NORMAL, palette.muted))
|
||||
addView(text("EMA200", 11f, Typeface.NORMAL, palette.amber).left(dp(16)))
|
||||
addView(text(lastCandleTime(market), 11f, Typeface.NORMAL, palette.muted).apply {
|
||||
val timeText = text(lastCandleTime(market), 11f, Typeface.NORMAL, palette.muted).apply {
|
||||
gravity = Gravity.END
|
||||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
}
|
||||
tradingBindings?.chartTime = timeText
|
||||
addView(timeText, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
})
|
||||
}
|
||||
|
||||
private fun pairSelector(data: BotSnapshot): View {
|
||||
val symbols = orderedMarkets(data.markets).map { it.symbol }
|
||||
val selectedIndex = symbols.indexOf(prefs.selectedSymbol).coerceAtLeast(0)
|
||||
val adapter = object : ArrayAdapter<String>(this, android.R.layout.simple_spinner_item, symbols) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getView(position, convertView, parent) as TextView
|
||||
view.setTextColor(palette.text)
|
||||
view.textSize = 15f
|
||||
view.typeface = Typeface.DEFAULT_BOLD
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = super.getDropDownView(position, convertView, parent) as TextView
|
||||
view.setTextColor(palette.text)
|
||||
view.setBackgroundColor(palette.panel)
|
||||
view.textSize = 15f
|
||||
view.setPadding(dp(14), dp(12), dp(14), dp(12))
|
||||
return view
|
||||
}
|
||||
}.apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
return LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(text("Торговая пара", 12f, Typeface.NORMAL, palette.muted))
|
||||
addView(Spinner(this@MainActivity).apply {
|
||||
this.adapter = adapter
|
||||
setSelection(selectedIndex, false)
|
||||
background = boxDrawable(palette.panel, palette.line, 8)
|
||||
setPadding(dp(10), 0, dp(10), 0)
|
||||
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
val symbol = symbols.getOrNull(position) ?: return
|
||||
if (symbol != prefs.selectedSymbol) {
|
||||
prefs.selectedSymbol = symbol
|
||||
render(saveScroll = false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
}
|
||||
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun decisionPanel(market: MarketItem, signal: SignalData?): View {
|
||||
val action = normalizedAction(signal?.action)
|
||||
val color = actionColor(action)
|
||||
@@ -416,17 +654,21 @@ class MainActivity : Activity() {
|
||||
addView(LinearLayout(this@MainActivity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(text("Решение Torch", 12f, Typeface.NORMAL, palette.muted))
|
||||
addView(text(decision, 20f, Typeface.BOLD, color).top(dp(8)))
|
||||
val decisionText = text(decision, 20f, Typeface.BOLD, color)
|
||||
tradingBindings?.decision = decisionText
|
||||
addView(decisionText.top(dp(8)))
|
||||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
addView(LinearLayout(this@MainActivity).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.END
|
||||
addView(text("Kelly", 12f, Typeface.NORMAL, palette.muted))
|
||||
addView(text(money(signal?.positionNotionalUsdt ?: 0.0), 20f, Typeface.BOLD).top(dp(8)))
|
||||
val kellyText = text(money(signal?.positionNotionalUsdt ?: 0.0), 20f, Typeface.BOLD)
|
||||
tradingBindings?.kelly = kellyText
|
||||
addView(kellyText.top(dp(8)))
|
||||
}, LinearLayout.LayoutParams(dp(120), ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
})
|
||||
addView(thinDivider().top(dp(12)))
|
||||
addView(keyValueLine("Причина", reason.ifBlank { "Нет объяснения от модели" }).top(dp(8)))
|
||||
addView(boundKeyValueLine("Причина", reason.ifBlank { "Нет объяснения от модели" }) { tradingBindings?.reason = it }.top(dp(8)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -457,7 +699,7 @@ class MainActivity : Activity() {
|
||||
background = if (selected) boxDrawable(palette.panelAlt, Color.TRANSPARENT, 2) else null
|
||||
setOnClickListener {
|
||||
prefs.selectedSymbol = market.symbol
|
||||
activeTab = "ai"
|
||||
activeTab = "trading"
|
||||
renderBottomNav()
|
||||
render(saveScroll = false)
|
||||
}
|
||||
@@ -556,31 +798,170 @@ class MainActivity : Activity() {
|
||||
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)))
|
||||
val equityText = text(money(data.account.equity), 34f, Typeface.BOLD)
|
||||
assetsBindings?.equity = equityText
|
||||
addView(equityText.top(dp(8)))
|
||||
val openPnlText = text("P&L открытых позиций ${signedMoney(pnl)}", 15f, Typeface.NORMAL, colorForSigned(pnl))
|
||||
assetsBindings?.openPnl = openPnlText
|
||||
addView(openPnlText.top(dp(8)))
|
||||
val realizedPnlText = text("Прибыль закрытых сделок ${signedMoney(realizedPnl)}", 15f, Typeface.NORMAL, colorForSigned(realizedPnl))
|
||||
assetsBindings?.realizedPnl = realizedPnlText
|
||||
addView(realizedPnlText.top(dp(5)))
|
||||
val modeText = text("Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"}", 12f, Typeface.NORMAL, palette.muted)
|
||||
assetsBindings?.mode = modeText
|
||||
addView(modeText.top(dp(8)))
|
||||
}
|
||||
|
||||
private fun accountMetrics(data: BotSnapshot): View =
|
||||
LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
addView(metricCell("Доступно", money(data.account.cash)).also { cell ->
|
||||
assetsBindings?.cash = cell.findViewWithTag<TextView>("metric_value")
|
||||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { setMargins(0, 0, dp(6), 0) })
|
||||
addView(metricCell("В позициях", money(data.account.exposure)).also { cell ->
|
||||
assetsBindings?.exposure = cell.findViewWithTag<TextView>("metric_value")
|
||||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { setMargins(dp(6), 0, 0, 0) })
|
||||
}
|
||||
|
||||
private fun positionsPanel(positions: List<PositionData>): View {
|
||||
if (positions.isEmpty()) {
|
||||
return section("Открытые позиции", text("Открытых позиций нет", 13f, Typeface.NORMAL, palette.muted))
|
||||
}
|
||||
val box = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||
positions.forEachIndexed { index, position ->
|
||||
if (index > 0) box.addView(thinDivider().top(dp(8)))
|
||||
box.addView(LinearLayout(this).apply {
|
||||
val body = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(keyValueLine(position.symbol, "${money(position.marketValue)} · ${signedPercent(position.unrealizedPnlPercent, 2)}", colorForSigned(position.unrealizedPnlPercent)))
|
||||
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)))
|
||||
val title = text("Открытые позиции: ${positions.size}", 18f, Typeface.BOLD)
|
||||
assetsBindings?.positionCount = title
|
||||
addView(title)
|
||||
val container = LinearLayout(this@MainActivity).apply { orientation = LinearLayout.VERTICAL }
|
||||
assetsBindings?.positionsContainer = container
|
||||
rebuildPositionsContent(container, positions)
|
||||
addView(container.top(dp(12)))
|
||||
}
|
||||
return section("Открытые позиции", box)
|
||||
return section(null, body)
|
||||
}
|
||||
|
||||
private fun rebuildPositionsContent(container: LinearLayout, positions: List<PositionData>, preserveScrollX: Int = 0) {
|
||||
container.removeAllViews()
|
||||
assetsBindings?.positions?.clear()
|
||||
assetsBindings?.positionOrder?.clear()
|
||||
assetsBindings?.positionsScroller = null
|
||||
assetsBindings?.positionsList = null
|
||||
if (positions.isEmpty()) {
|
||||
container.addView(text("Открытых позиций нет", 13f, Typeface.NORMAL, palette.muted))
|
||||
return
|
||||
}
|
||||
val cardWidth = (resources.displayMetrics.widthPixels - dp(64)).coerceAtLeast(dp(260))
|
||||
val list = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(0, 0, dp(10), 0)
|
||||
}
|
||||
assetsBindings?.positionsList = list
|
||||
rebuildPositionsList(list, positions, cardWidth)
|
||||
val scroller = HorizontalScrollView(this).apply {
|
||||
isHorizontalScrollBarEnabled = false
|
||||
overScrollMode = View.OVER_SCROLL_NEVER
|
||||
addView(list, ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
if (preserveScrollX > 0) {
|
||||
post { scrollTo(preserveScrollX, 0) }
|
||||
}
|
||||
}
|
||||
assetsBindings?.positionsScroller = scroller
|
||||
container.addView(scroller)
|
||||
}
|
||||
|
||||
private fun rebuildPositionsList(
|
||||
list: LinearLayout,
|
||||
positions: List<PositionData>,
|
||||
cardWidth: Int = (resources.displayMetrics.widthPixels - dp(64)).coerceAtLeast(dp(260)),
|
||||
) {
|
||||
list.removeAllViews()
|
||||
assetsBindings?.positionOrder?.clear()
|
||||
positions.forEachIndexed { index, position ->
|
||||
assetsBindings?.positionOrder?.add(positionKey(position))
|
||||
list.addView(positionCard(position, index + 1, positions.size), LinearLayout.LayoutParams(cardWidth, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
if (index < positions.lastIndex) setMargins(0, 0, dp(12), 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncPositionsList(binding: AssetsBindings, positions: List<PositionData>): Boolean {
|
||||
val container = binding.positionsContainer ?: return false
|
||||
val list = binding.positionsList
|
||||
if (list == null || positions.isEmpty() || binding.positionOrder.isEmpty()) {
|
||||
rebuildPositionsContent(container, positions, binding.positionsScroller?.scrollX ?: 0)
|
||||
return true
|
||||
}
|
||||
|
||||
val byKey = positions.associateBy(::positionKey)
|
||||
var index = 0
|
||||
while (index < binding.positionOrder.size) {
|
||||
val key = binding.positionOrder[index]
|
||||
if (!byKey.containsKey(key)) {
|
||||
binding.positionOrder.removeAt(index)
|
||||
binding.positions.remove(key)
|
||||
list.removeViewAt(index)
|
||||
} else {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
val cardWidth = (resources.displayMetrics.widthPixels - dp(64)).coerceAtLeast(dp(260))
|
||||
positions.forEach { position ->
|
||||
val key = positionKey(position)
|
||||
if (!binding.positionOrder.contains(key)) {
|
||||
binding.positionOrder.add(key)
|
||||
list.addView(positionCard(position, binding.positionOrder.size, positions.size), LinearLayout.LayoutParams(cardWidth, ViewGroup.LayoutParams.WRAP_CONTENT))
|
||||
}
|
||||
}
|
||||
updatePositionCardChrome(binding)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updatePositionCardChrome(binding: AssetsBindings) {
|
||||
val total = binding.positionOrder.size
|
||||
binding.positionOrder.forEachIndexed { index, key ->
|
||||
setText(binding.positions[key]?.index, "${index + 1}/$total")
|
||||
}
|
||||
val list = binding.positionsList ?: return
|
||||
for (i in 0 until list.childCount) {
|
||||
val params = (list.getChildAt(i).layoutParams as? LinearLayout.LayoutParams) ?: continue
|
||||
params.setMargins(0, 0, if (i < list.childCount - 1) dp(12) else 0, 0)
|
||||
list.getChildAt(i).layoutParams = params
|
||||
}
|
||||
}
|
||||
|
||||
private fun positionCard(position: PositionData, index: Int, total: Int): View =
|
||||
LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(dp(14), dp(13), dp(14), dp(13))
|
||||
background = boxDrawable(palette.panelAlt, palette.line, 8)
|
||||
addView(LinearLayout(this@MainActivity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
addView(text(position.symbol, 20f, Typeface.BOLD), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
val indexText = text("$index/$total", 12f, Typeface.BOLD, palette.muted)
|
||||
assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.index = indexText
|
||||
addView(indexText)
|
||||
})
|
||||
val valueText = text("${money(position.marketValue)} · ${signedPercent(position.unrealizedPnlPercent, 2)}", 15f, Typeface.BOLD, colorForSigned(position.unrealizedPnlPercent))
|
||||
addView(valueText.top(dp(8)))
|
||||
addView(thinDivider().top(dp(12)))
|
||||
addView(boundKeyValueLine("Количество", number(position.qty, 8)) { textView ->
|
||||
assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.quantity = textView
|
||||
}.top(dp(10)))
|
||||
addView(boundKeyValueLine("Вход", price(position.entryPrice)) { textView ->
|
||||
assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.entryPrice = textView
|
||||
}.top(dp(5)))
|
||||
addView(boundKeyValueLine("Текущая", price(position.markPrice)) { textView ->
|
||||
assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.markPrice = textView
|
||||
}.top(dp(5)))
|
||||
addView(boundKeyValueLine("PnL", signedMoney(position.unrealizedPnl), colorForSigned(position.unrealizedPnl)) { textView ->
|
||||
assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.pnl = textView
|
||||
}.top(dp(5)))
|
||||
addView(boundKeyValueLine("Take-profit", priceOrNone(position.takeProfit)) { textView ->
|
||||
assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.takeProfit = textView
|
||||
}.top(dp(5)))
|
||||
addView(boundKeyValueLine("Максимум", priceOrNone(position.highestPrice)) { textView ->
|
||||
assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.highestPrice = textView
|
||||
}.top(dp(5)))
|
||||
assetsBindings?.positions?.getOrPut(positionKey(position)) { PositionBinding() }?.value = valueText
|
||||
}
|
||||
|
||||
private fun closedTradesPanel(trades: List<ClosedTradeData>, summary: ClosedTradesSummary): View {
|
||||
@@ -717,13 +1098,17 @@ class MainActivity : Activity() {
|
||||
prefs.retrainScheduleEnabled = true
|
||||
RetrainScheduler.schedule(this@MainActivity, prefs.retrainIntervalHours)
|
||||
toast("Расписание retrain включено")
|
||||
if (activeTab != "settings" || snapshot == null || !updateSettingsScreen(snapshot!!)) {
|
||||
render()
|
||||
}
|
||||
},
|
||||
"Отключить" to {
|
||||
prefs.retrainScheduleEnabled = false
|
||||
RetrainScheduler.cancel(this@MainActivity)
|
||||
toast("Расписание retrain отключено")
|
||||
if (activeTab != "settings" || snapshot == null || !updateSettingsScreen(snapshot!!)) {
|
||||
render()
|
||||
}
|
||||
},
|
||||
).top(dp(10)))
|
||||
}
|
||||
@@ -785,7 +1170,9 @@ class MainActivity : Activity() {
|
||||
setPadding(dp(14), dp(12), dp(14), dp(12))
|
||||
background = boxDrawable(palette.panel, palette.line, 8)
|
||||
addView(text(title, 12f, Typeface.NORMAL, palette.muted))
|
||||
addView(text(value, 17f, Typeface.BOLD, colorForSigned(value)).top(dp(6)))
|
||||
addView(text(value, 17f, Typeface.BOLD, colorForSigned(value)).apply {
|
||||
tag = "metric_value"
|
||||
}.top(dp(6)))
|
||||
}
|
||||
|
||||
private fun tableHeader(columns: List<Pair<String, Float>>): View =
|
||||
@@ -833,19 +1220,22 @@ class MainActivity : Activity() {
|
||||
if (!isAuthError()) {
|
||||
return emptyState("Нет данных от API. ${lastError.ifBlank { "Проверь подключение." }}")
|
||||
}
|
||||
val (savedLogin, savedPassword) = authParts(prefs.commandToken)
|
||||
val apiInput = input("Адрес API бота", prefs.apiBaseUrl)
|
||||
val authInput = input("Логин и пароль: login:password", prefs.commandToken).apply {
|
||||
val loginInput = input("Логин API", savedLogin)
|
||||
val passwordInput = input("Пароль API", savedPassword).apply {
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
return section("Нужен вход в API", LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(text("Сервер tb.kusoft.xyz отвечает 401, значит он доступен, но требует авторизацию. Введите доступ в формате login:password, приложение само отправит Basic Auth.", 13f, Typeface.NORMAL, palette.muted))
|
||||
addView(text("Сервер tb.kusoft.xyz отвечает 401, значит он доступен, но требует авторизацию. Введите логин и пароль, приложение само отправит Basic Auth.", 13f, Typeface.NORMAL, palette.muted))
|
||||
addView(apiInput.top(dp(12)))
|
||||
addView(authInput.top(dp(8)))
|
||||
addView(loginInput.top(dp(8)))
|
||||
addView(passwordInput.top(dp(8)))
|
||||
addView(actionRow(
|
||||
"Подключиться" to {
|
||||
prefs.apiBaseUrl = apiInput.text.toString()
|
||||
prefs.commandToken = authInput.text.toString()
|
||||
prefs.commandToken = authToken(loginInput.text.toString(), passwordInput.text.toString())
|
||||
toast("Доступ сохранен, проверяю API")
|
||||
refreshData(silent = false)
|
||||
},
|
||||
@@ -932,6 +1322,28 @@ class MainActivity : Activity() {
|
||||
}, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.05f))
|
||||
}
|
||||
|
||||
private fun boundKeyValueLine(key: String, value: String, valueColor: Int = colorForSigned(value), bindValue: (TextView) -> Unit): View =
|
||||
LinearLayout(this).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
addView(text(key, 13f, Typeface.NORMAL, palette.muted), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
val valueText = text(value.ifBlank { "нет данных" }, 13f, Typeface.BOLD, valueColor).apply {
|
||||
gravity = Gravity.END
|
||||
}
|
||||
bindValue(valueText)
|
||||
addView(valueText, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.05f))
|
||||
}
|
||||
|
||||
private fun setText(view: TextView?, value: String, color: Int? = null) {
|
||||
if (view == null) return
|
||||
if (view.text.toString() != value) {
|
||||
view.text = value
|
||||
}
|
||||
if (color != null) {
|
||||
view.setTextColor(color)
|
||||
}
|
||||
}
|
||||
|
||||
private fun allocationBar(fractionRaw: Double): View {
|
||||
val fraction = fractionRaw.coerceIn(0.0, 1.0).toFloat()
|
||||
return LinearLayout(this).apply {
|
||||
@@ -1107,13 +1519,18 @@ class MainActivity : Activity() {
|
||||
RetrainScheduler.schedule(this, hours)
|
||||
}
|
||||
toast("Интервал retrain: $hours ч")
|
||||
if (activeTab == "settings" && snapshot != null && updateSettingsScreen(snapshot!!)) {
|
||||
return
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
private fun triggerRetrainNow() {
|
||||
if (retrainRequestInFlight) return
|
||||
retrainRequestInFlight = true
|
||||
if (activeTab != "settings" || snapshot == null || !updateSettingsScreen(snapshot!!)) {
|
||||
render()
|
||||
}
|
||||
executor.execute {
|
||||
try {
|
||||
val message = TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
|
||||
@@ -1155,6 +1572,53 @@ class MainActivity : Activity() {
|
||||
private fun trainingStatusSignature(retrain: JSONObject): String =
|
||||
(retrain.optJSONObject("coordination") ?: JSONObject()).toString()
|
||||
|
||||
private fun retrainBlockSignature(data: BotSnapshot?): String =
|
||||
listOf(
|
||||
retrainRequestInFlight.toString(),
|
||||
prefs.retrainScheduleEnabled.toString(),
|
||||
prefs.retrainIntervalHours.toString(),
|
||||
data?.retrain?.toString().orEmpty(),
|
||||
data?.backtest?.optJSONObject("full_replay")?.toString().orEmpty(),
|
||||
).joinToString("|")
|
||||
|
||||
private fun liveBlockSignature(data: BotSnapshot?): String {
|
||||
val config = data?.config ?: JSONObject()
|
||||
return listOf(
|
||||
data?.mode.orEmpty(),
|
||||
data?.running?.toString().orEmpty(),
|
||||
config.optBoolean("live_ready", false).toString(),
|
||||
config.optDouble("live_order_max_usdt", 0.0).toString(),
|
||||
config.optDouble("risk_per_trade_percent", 0.0).toString(),
|
||||
).joinToString("|")
|
||||
}
|
||||
|
||||
private fun positionsSignature(positions: List<PositionData>): String =
|
||||
positions.map { positionKey(it) }.sorted().joinToString("|")
|
||||
|
||||
private fun positionKey(position: PositionData): String =
|
||||
when {
|
||||
position.id != null && position.id > 0L -> "id:${position.id}"
|
||||
position.openedAt.isNotBlank() -> "${position.symbol}:${position.openedAt}"
|
||||
else -> position.symbol
|
||||
}
|
||||
|
||||
private fun closedTradesSignature(trades: List<ClosedTradeData>, summary: ClosedTradesSummary): String =
|
||||
listOf(
|
||||
summary.trades.toString(),
|
||||
number(summary.netPnl, 8),
|
||||
number(summary.feeUsdt, 8),
|
||||
number(summary.winRate, 8),
|
||||
trades.take(10).joinToString(";") { trade ->
|
||||
listOf(
|
||||
trade.closedAt,
|
||||
trade.symbol,
|
||||
number(trade.qty, 8),
|
||||
priceOrNone(trade.exitPrice),
|
||||
number(trade.netPnl, 8),
|
||||
).joinToString(",")
|
||||
},
|
||||
).joinToString("|")
|
||||
|
||||
private fun trainingJobLabel(status: String, phase: String): String =
|
||||
when (status) {
|
||||
"pending" -> "ждет Windows-agent"
|
||||
@@ -1213,6 +1677,27 @@ class MainActivity : Activity() {
|
||||
else -> value.ifBlank { "нет данных" }
|
||||
}
|
||||
|
||||
private fun authParts(value: String): Pair<String, String> {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank() || trimmed.startsWith("Bearer ", ignoreCase = true)) {
|
||||
return "" to ""
|
||||
}
|
||||
val basicPrefix = "Basic "
|
||||
val raw = if (trimmed.startsWith(basicPrefix, ignoreCase = true)) "" else trimmed
|
||||
val separator = raw.indexOf(':')
|
||||
return if (separator >= 0) {
|
||||
raw.substring(0, separator) to raw.substring(separator + 1)
|
||||
} else {
|
||||
raw to ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun authToken(login: String, password: String): String {
|
||||
val cleanLogin = login.trim()
|
||||
val cleanPassword = password.trim()
|
||||
return if (cleanLogin.isBlank() && cleanPassword.isBlank()) "" else "$cleanLogin:$cleanPassword"
|
||||
}
|
||||
|
||||
private fun modelLabel(value: String): String =
|
||||
when {
|
||||
value.contains("gru", ignoreCase = true) -> "PyTorch GRU"
|
||||
|
||||
@@ -84,6 +84,7 @@ data class SignalData(
|
||||
}
|
||||
|
||||
data class PositionData(
|
||||
val id: Long?,
|
||||
val symbol: String,
|
||||
val qty: Double,
|
||||
val entryPrice: Double,
|
||||
@@ -97,6 +98,7 @@ data class PositionData(
|
||||
val highestPrice: Double?,
|
||||
val trailingStop: Double?,
|
||||
val atrTrailingStop: Double?,
|
||||
val openedAt: String,
|
||||
val exitAction: String,
|
||||
val exitReason: String,
|
||||
val stopLossExitEnabled: Boolean,
|
||||
@@ -153,5 +155,8 @@ fun JSONObject.optStringClean(name: String): String =
|
||||
fun JSONObject.optDoubleOrNull(name: String): Double? =
|
||||
if (has(name) && !isNull(name)) optDouble(name) else null
|
||||
|
||||
fun JSONObject.optLongOrNull(name: String): Long? =
|
||||
if (has(name) && !isNull(name)) optLong(name) else null
|
||||
|
||||
fun JSONObject.optBooleanOrNull(name: String): Boolean? =
|
||||
if (has(name) && !isNull(name)) optBoolean(name) else null
|
||||
|
||||
@@ -104,6 +104,7 @@ class TradeBotApi(
|
||||
val row = items.optJSONObject(index) ?: continue
|
||||
val exitPlan = row.optJSONObject("exit_plan") ?: JSONObject()
|
||||
output += PositionData(
|
||||
id = row.optLongOrNull("id"),
|
||||
symbol = row.optStringClean("symbol"),
|
||||
qty = row.optDouble("qty", 0.0),
|
||||
entryPrice = row.optDouble("entry_price", 0.0),
|
||||
@@ -117,6 +118,7 @@ class TradeBotApi(
|
||||
highestPrice = exitPlan.optDoubleOrNull("highest_price") ?: row.optDoubleOrNull("highest_price"),
|
||||
trailingStop = exitPlan.optDoubleOrNull("trailing_stop"),
|
||||
atrTrailingStop = exitPlan.optDoubleOrNull("atr_trailing_stop"),
|
||||
openedAt = row.optStringClean("opened_at"),
|
||||
exitAction = exitPlan.optStringClean("action"),
|
||||
exitReason = exitPlan.optStringClean("reason"),
|
||||
stopLossExitEnabled = exitPlan.optBooleanOrNull("stop_loss_exit_enabled") ?: true,
|
||||
|
||||
@@ -142,6 +142,7 @@ class Settings:
|
||||
take_profit_percent: float
|
||||
trailing_stop_percent: float
|
||||
min_hold_seconds: int
|
||||
min_exit_net_percent: float
|
||||
entry_cooldown_seconds: int
|
||||
max_daily_drawdown_usdt: float
|
||||
min_cash_reserve_usdt: float
|
||||
@@ -294,6 +295,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings:
|
||||
take_profit_percent=_float_env("TAKE_PROFIT_PERCENT", 0.035),
|
||||
trailing_stop_percent=_float_env("TRAILING_STOP_PERCENT", 0.015),
|
||||
min_hold_seconds=_int_env("MIN_HOLD_SECONDS", 180),
|
||||
min_exit_net_percent=_float_env("MIN_EXIT_NET_PERCENT", 0.20),
|
||||
entry_cooldown_seconds=_int_env("ENTRY_COOLDOWN_SECONDS", 180),
|
||||
max_daily_drawdown_usdt=_float_env("MAX_DAILY_DRAWDOWN_USDT", 6.0),
|
||||
min_cash_reserve_usdt=_float_env("MIN_CASH_RESERVE_USDT", 5.0),
|
||||
|
||||
@@ -323,6 +323,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]:
|
||||
"take_profit_percent": settings.take_profit_percent,
|
||||
"trailing_stop_percent": settings.trailing_stop_percent,
|
||||
"min_hold_seconds": settings.min_hold_seconds,
|
||||
"min_exit_net_percent": settings.min_exit_net_percent,
|
||||
"entry_cooldown_seconds": settings.entry_cooldown_seconds,
|
||||
"max_daily_drawdown_usdt": settings.max_daily_drawdown_usdt,
|
||||
"min_cash_reserve_usdt": settings.min_cash_reserve_usdt,
|
||||
|
||||
@@ -394,6 +394,7 @@ class SpotStrategy:
|
||||
effective_take_profit = position.entry_price * (1 + take_profit_percent)
|
||||
trailing = position.trailing_stop(trailing_percent)
|
||||
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, self.settings)
|
||||
min_exit_net_percent = _min_exit_net_percent(self.settings)
|
||||
diagnostics = {
|
||||
"price": price,
|
||||
"entry_price": position.entry_price,
|
||||
@@ -408,6 +409,7 @@ class SpotStrategy:
|
||||
"adaptive_rules": adaptive,
|
||||
"forecast": forecast,
|
||||
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4),
|
||||
"min_exit_net_percent": min_exit_net_percent,
|
||||
"min_exit_profit_percent": float(adaptive.get("min_exit_profit_percent", 0.0) or 0.0),
|
||||
}
|
||||
if effective_stop_loss is not None and price <= effective_stop_loss:
|
||||
@@ -438,6 +440,7 @@ class SpotStrategy:
|
||||
estimated_exit_net_percent=estimated_exit_net_percent,
|
||||
stop_loss_percent=stop_loss_percent,
|
||||
min_edge_percent=self.settings.time_series_min_edge_percent,
|
||||
min_exit_net_percent=min_exit_net_percent,
|
||||
)
|
||||
if forecast_exit is not None:
|
||||
action, confidence, reason = forecast_exit
|
||||
@@ -876,6 +879,7 @@ def _torch_forecast_exit_signal(
|
||||
min_edge = max(0.0, settings.time_series_min_edge_percent)
|
||||
min_probability = _torch_min_probability(settings)
|
||||
estimated_exit_net_percent = _estimated_exit_net_percent(position, price, settings)
|
||||
min_exit_net_percent = _min_exit_net_percent(settings)
|
||||
entry_path = str(position.entry_diagnostics.get("entry_path", ""))
|
||||
entry_edge_mode = str(position.entry_diagnostics.get("edge_mode", ""))
|
||||
rebound_fallback_position = entry_path == "rebound_fallback" or entry_edge_mode == "rebound_fallback"
|
||||
@@ -899,6 +903,7 @@ def _torch_forecast_exit_signal(
|
||||
"min_probability_up": min_probability,
|
||||
"skill": skill,
|
||||
"estimated_exit_net_percent": round(estimated_exit_net_percent, 4),
|
||||
"min_exit_net_percent": min_exit_net_percent,
|
||||
"atr_14": latest.atr_14 if latest else None,
|
||||
}
|
||||
hold_seconds = (utc_now() - position.opened_at).total_seconds()
|
||||
@@ -909,13 +914,15 @@ def _torch_forecast_exit_signal(
|
||||
if price >= position.take_profit:
|
||||
return Signal(position.symbol, "SELL", 0.96, "torch_forecast: take-profit hit", diagnostics)
|
||||
if atr_trailing_stop is not None and price <= atr_trailing_stop:
|
||||
if estimated_exit_net_percent < min_exit_net_percent:
|
||||
diagnostics["atr_exit_blocked_by_min_profit"] = True
|
||||
if estimated_exit_net_percent < 0:
|
||||
diagnostics["atr_exit_blocked_by_cost"] = True
|
||||
return Signal(
|
||||
position.symbol,
|
||||
"HOLD",
|
||||
0.45,
|
||||
"torch_forecast: ATR trailing touched, but exit is not worth fees",
|
||||
"torch_forecast: ATR trailing touched, but exit profit is below minimum",
|
||||
diagnostics,
|
||||
)
|
||||
return Signal(position.symbol, "SELL", 0.94, "torch_forecast: ATR trailing stop hit", diagnostics)
|
||||
@@ -956,23 +963,26 @@ def _torch_forecast_exit_signal(
|
||||
estimated_exit_net_percent=estimated_exit_net_percent,
|
||||
stop_loss_percent=stop_loss_percent,
|
||||
min_edge_percent=min_edge,
|
||||
min_exit_net_percent=min_exit_net_percent,
|
||||
)
|
||||
if forecast_exit is not None:
|
||||
action, confidence, reason = forecast_exit
|
||||
return Signal(position.symbol, action, confidence, reason, diagnostics)
|
||||
diagnostics["forecast_exit_blocked_by_min_profit"] = True
|
||||
if estimated_exit_net_percent < 0:
|
||||
diagnostics["forecast_exit_blocked_by_cost"] = True
|
||||
return Signal(
|
||||
position.symbol,
|
||||
"HOLD",
|
||||
0.44,
|
||||
(
|
||||
"torch_forecast: forecast weakened, but exit is not worth fees; "
|
||||
"torch_forecast: forecast weakened, but exit profit is below minimum; "
|
||||
f"p_up={probability_up:.3f}, expected={expected_return:.4f}%"
|
||||
),
|
||||
diagnostics,
|
||||
)
|
||||
weak_hold = expected_return < min_edge or probability_up < min_probability or skill <= 0.0
|
||||
if weak_hold and estimated_exit_net_percent >= 0:
|
||||
if weak_hold and estimated_exit_net_percent >= min_exit_net_percent:
|
||||
return Signal(
|
||||
position.symbol,
|
||||
"SELL",
|
||||
@@ -983,6 +993,8 @@ def _torch_forecast_exit_signal(
|
||||
),
|
||||
diagnostics,
|
||||
)
|
||||
if weak_hold and estimated_exit_net_percent >= 0:
|
||||
diagnostics["weak_exit_blocked_by_min_profit"] = True
|
||||
return Signal(position.symbol, "HOLD", 0.35, "torch_forecast: PyTorch hold confirmed", diagnostics)
|
||||
|
||||
|
||||
@@ -1633,6 +1645,10 @@ def _estimated_exit_net_percent(position: Position, price: float, settings: Sett
|
||||
return gross_percent - round_trip_cost_percent
|
||||
|
||||
|
||||
def _min_exit_net_percent(settings: Settings) -> float:
|
||||
return round(_clamp(settings.min_exit_net_percent, 0.0, 5.0), 4)
|
||||
|
||||
|
||||
def _adaptive_indicator_exit_allowed(adaptive: dict, mode_key: str, estimated_exit_net_percent: float) -> bool:
|
||||
mode = str(adaptive.get(mode_key, "normal")).lower()
|
||||
if mode != "profit_only":
|
||||
@@ -1649,6 +1665,7 @@ def _forecast_exit_signal(
|
||||
estimated_exit_net_percent: float,
|
||||
stop_loss_percent: float,
|
||||
min_edge_percent: float,
|
||||
min_exit_net_percent: float,
|
||||
) -> tuple[str, float, str] | None:
|
||||
if not forecast.get("usable"):
|
||||
return None
|
||||
@@ -1660,7 +1677,7 @@ def _forecast_exit_signal(
|
||||
if not strong_negative:
|
||||
return None
|
||||
reason = forecast.get("reason") or "ожидается снижение"
|
||||
if estimated_exit_net_percent >= 0:
|
||||
if estimated_exit_net_percent >= min_exit_net_percent:
|
||||
return "SELL", 0.82, f"прогноз временного ряда ухудшился: {reason}; фиксируем результат"
|
||||
loss_from_entry = ((price - position.entry_price) / position.entry_price) if position.entry_price else 0.0
|
||||
soft_loss_limit = -max(0.003, stop_loss_percent * 0.35)
|
||||
|
||||
@@ -94,6 +94,7 @@ def make_settings():
|
||||
take_profit_percent=0.035,
|
||||
trailing_stop_percent=0.015,
|
||||
min_hold_seconds=180,
|
||||
min_exit_net_percent=0.20,
|
||||
entry_cooldown_seconds=180,
|
||||
max_daily_drawdown_usdt=6.0,
|
||||
min_cash_reserve_usdt=5.0,
|
||||
|
||||
@@ -954,6 +954,81 @@ def test_torch_forecast_holds_atr_trailing_exit_that_does_not_cover_fees(make_se
|
||||
assert signal.diagnostics["atr_exit_blocked_by_cost"] is True
|
||||
|
||||
|
||||
def test_torch_forecast_holds_atr_trailing_exit_below_min_profit(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20)
|
||||
strategy = SpotStrategy(settings)
|
||||
candles = _trend_entry_candles(close=100.35)
|
||||
candles[-1].atr_14 = 0.6
|
||||
position = Position(
|
||||
1,
|
||||
"MNTUSDT",
|
||||
1,
|
||||
100,
|
||||
100,
|
||||
0.1,
|
||||
96,
|
||||
120,
|
||||
102,
|
||||
opened_at=utc_now() - timedelta(seconds=600),
|
||||
)
|
||||
ticker = Ticker("MNTUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0)
|
||||
|
||||
signal = strategy.exit_signal(
|
||||
position,
|
||||
candles,
|
||||
ticker,
|
||||
forecast={
|
||||
"usable": True,
|
||||
"model": "torch_lstm",
|
||||
"expected_return_percent": 0.4,
|
||||
"probability_up": 0.58,
|
||||
"skill": 0.18,
|
||||
"block_entry": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert signal.action == "HOLD"
|
||||
assert signal.diagnostics["atr_exit_blocked_by_min_profit"] is True
|
||||
assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent
|
||||
|
||||
|
||||
def test_torch_forecast_holds_negative_forecast_exit_below_min_profit(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=60, min_exit_net_percent=0.20)
|
||||
strategy = SpotStrategy(settings)
|
||||
position = Position(
|
||||
1,
|
||||
"BTCUSDT",
|
||||
1,
|
||||
100,
|
||||
100,
|
||||
0.1,
|
||||
96,
|
||||
120,
|
||||
100.5,
|
||||
opened_at=utc_now() - timedelta(seconds=600),
|
||||
)
|
||||
ticker = Ticker("BTCUSDT", 100.35, 100.34, 100.36, 10_000_000, 1000, 1.0)
|
||||
|
||||
signal = strategy.exit_signal(
|
||||
position,
|
||||
_trend_entry_candles(close=100.35),
|
||||
ticker,
|
||||
forecast={
|
||||
"usable": True,
|
||||
"model": "torch_lstm",
|
||||
"expected_return_percent": -0.2,
|
||||
"probability_up": 0.40,
|
||||
"skill": 0.18,
|
||||
"block_entry": False,
|
||||
"reason": "model turned down",
|
||||
},
|
||||
)
|
||||
|
||||
assert signal.action == "HOLD"
|
||||
assert signal.diagnostics["forecast_exit_blocked_by_min_profit"] is True
|
||||
assert signal.diagnostics["estimated_exit_net_percent"] < settings.min_exit_net_percent
|
||||
|
||||
|
||||
def test_torch_forecast_rebound_fallback_holds_without_model(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
|
||||
strategy = SpotStrategy(settings)
|
||||
|
||||
@@ -40,6 +40,15 @@ function Resolve-Python {
|
||||
throw "Python was not found. Create .venv or install Python 3.12."
|
||||
}
|
||||
|
||||
function Resolve-WindowlessPython {
|
||||
$python = Resolve-Python
|
||||
$pythonw = Join-Path (Split-Path -Parent $python) "pythonw.exe"
|
||||
if (Test-Path $pythonw) {
|
||||
return $pythonw
|
||||
}
|
||||
return $python
|
||||
}
|
||||
|
||||
if ($ApiAuth) {
|
||||
[Environment]::SetEnvironmentVariable("TRADEBOT_API_AUTH", $ApiAuth, "User")
|
||||
$env:TRADEBOT_API_AUTH = $ApiAuth
|
||||
@@ -59,7 +68,7 @@ if (-not $KeepLegacyRetrainer) {
|
||||
}
|
||||
}
|
||||
|
||||
$python = Resolve-Python
|
||||
$python = Resolve-WindowlessPython
|
||||
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
$arguments = @(
|
||||
"-u",
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -124,6 +125,7 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
**hidden_subprocess_kwargs(),
|
||||
) as process:
|
||||
reader = threading.Thread(target=read_output, name="training-output-reader", daemon=True)
|
||||
reader.start()
|
||||
@@ -140,7 +142,7 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
||||
log(log_path, message)
|
||||
line_count += 1
|
||||
if message:
|
||||
last_message = message[-220:]
|
||||
last_message = friendly_training_message(message)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
@@ -163,6 +165,78 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
||||
report_progress(args, job_id, "running", "guard", 70, "Guard завершён, подготавливаю артефакты")
|
||||
|
||||
|
||||
def friendly_training_message(message: str) -> str:
|
||||
cleaned = message.strip()
|
||||
if not cleaned:
|
||||
return "PyTorch обучает модель"
|
||||
|
||||
if "Starting PyTorch recurrent retrain:" in cleaned:
|
||||
return "PyTorch LSTM/GRU запущен: готовлю данные и варианты модели"
|
||||
|
||||
started = re.search(
|
||||
r"training started: symbols=(?P<symbols>\d+) interval=(?P<interval>\d+) "
|
||||
r"limit=(?P<limit>\d+) epochs=(?P<epochs>\d+)",
|
||||
cleaned,
|
||||
)
|
||||
if started:
|
||||
interval = started.group("interval")
|
||||
timeframe = "1h" if interval == "60" else f"{interval}m"
|
||||
return (
|
||||
f"Старт обучения: {started.group('symbols')} пар, таймфрейм {timeframe}, "
|
||||
f"история {started.group('limit')} свечей, до {started.group('epochs')} эпох"
|
||||
)
|
||||
|
||||
pair_started = re.search(r"^(?P<symbol>[A-Z0-9]+): training started \((?P<index>\d+)/(?P<total>\d+)\)", cleaned)
|
||||
if pair_started:
|
||||
return (
|
||||
f"{pair_started.group('symbol')}: обучение пары "
|
||||
f"{pair_started.group('index')}/{pair_started.group('total')}"
|
||||
)
|
||||
|
||||
preparing = re.search(r"^(?P<symbol>[A-Z0-9]+): preparing lookback=(?P<lookback>\d+)", cleaned)
|
||||
if preparing:
|
||||
return f"{preparing.group('symbol')}: готовлю окно {preparing.group('lookback')} свечей"
|
||||
|
||||
fitting = re.search(
|
||||
r"^(?P<symbol>[A-Z0-9]+): fitting (?P<arch>lstm|gru) "
|
||||
r"lookback=(?P<lookback>\d+) hidden=(?P<hidden>\d+) "
|
||||
r"layers=(?P<layers>\d+) dropout=(?P<dropout>[0-9.]+)",
|
||||
cleaned,
|
||||
)
|
||||
if fitting:
|
||||
return (
|
||||
f"{fitting.group('symbol')}: обучаю {fitting.group('arch').upper()}, "
|
||||
f"окно {fitting.group('lookback')}, нейронов {fitting.group('hidden')}, "
|
||||
f"слоёв {fitting.group('layers')}, dropout {fitting.group('dropout')}"
|
||||
)
|
||||
|
||||
model = re.search(
|
||||
r"^(?P<symbol>[A-Z0-9]+): model=torch_(?P<arch>lstm|gru).*?"
|
||||
r"mae=(?P<mae>[0-9.]+)%.*?skill=(?P<skill>-?[0-9.]+).*?dir=(?P<direction>[0-9.]+)",
|
||||
cleaned,
|
||||
)
|
||||
if model:
|
||||
direction = float(model.group("direction")) * 100
|
||||
skill = float(model.group("skill")) * 100
|
||||
return (
|
||||
f"{model.group('symbol')}: выбран {model.group('arch').upper()}, "
|
||||
f"ошибка {model.group('mae')}%, skill {skill:.1f}%, направление {direction:.1f}%"
|
||||
)
|
||||
|
||||
if "Calibrating current artifact" in cleaned:
|
||||
return "Проверяю текущую модель на replay"
|
||||
if "Calibrating candidate artifact" in cleaned:
|
||||
return "Проверяю новую модель на replay"
|
||||
if "Running retrain guard" in cleaned:
|
||||
return "Gate сравнивает новую модель с текущей"
|
||||
if "Candidate rejected by guard" in cleaned:
|
||||
return "Новая модель обучилась, но gate не дал ей ходу"
|
||||
if "Candidate accepted by guard" in cleaned:
|
||||
return "Новая модель прошла gate и стала активной"
|
||||
|
||||
return cleaned[-220:]
|
||||
|
||||
|
||||
def training_heartbeat_message(now: float, started_at: float, last_output_at: float, last_message: str) -> str:
|
||||
elapsed = format_duration(now - started_at)
|
||||
idle_seconds = max(0.0, now - last_output_at)
|
||||
@@ -296,7 +370,11 @@ def log(path: Path, message: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
line = f"[{stamp}] {message}"
|
||||
if sys.stdout is not None:
|
||||
try:
|
||||
print(line, flush=True)
|
||||
except OSError:
|
||||
pass
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
|
||||
@@ -309,6 +387,18 @@ def read_json(path: Path) -> dict[str, Any]:
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def hidden_subprocess_kwargs() -> dict[str, Any]:
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
startupinfo.wShowWindow = 0
|
||||
return {
|
||||
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
"startupinfo": startupinfo,
|
||||
}
|
||||
|
||||
|
||||
def quote_for_log(value: str) -> str:
|
||||
return f'"{value}"' if " " in value else value
|
||||
|
||||
|
||||
Reference in New Issue
Block a user