Compare commits

..

6 Commits

Author SHA1 Message Date
Codex 6fb79ee2a9 Keep open positions carousel stable 2026-07-09 22:50:33 +03:00
Codex 77a063d263 Preserve open positions carousel scroll 2026-07-09 22:46:04 +03:00
Codex e21a96640d Improve Android monitor incremental updates 2026-07-09 22:39:26 +03:00
Codex 7f1ac694e4 Improve Windows training agent progress 2026-07-03 20:44:09 +03:00
Codex 33c3831bf2 Require minimum net profit for forecast exits 2026-07-02 15:35:29 +03:00
Codex f0c70e2e1f Run Windows training agent without console 2026-07-01 13:43:41 +03:00
11 changed files with 764 additions and 77 deletions
+2 -2
View File
@@ -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"
}
}
@@ -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,
+2
View File
@@ -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),
+1
View File
@@ -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,
+21 -4
View File
@@ -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)
+1
View File
@@ -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,
+75
View File
@@ -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)
+10 -1
View File
@@ -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",
+91 -1
View File
@@ -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