Harden trading, training, and monitoring

This commit is contained in:
Codex
2026-07-10 15:51:53 +03:00
parent 6fb79ee2a9
commit 069d75d2f2
55 changed files with 2658 additions and 2332049 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ android {
applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26
targetSdk = 36
versionCode = 17
versionName = "0.2.14"
versionCode = 18
versionName = "0.3.0"
}
}
@@ -11,7 +11,7 @@
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -1,6 +1,12 @@
package xyz.kusoft.tradebotmonitor
import android.content.Context
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class AppPrefs(context: Context) {
private val prefs = context.getSharedPreferences("tradebot_monitor", Context.MODE_PRIVATE)
@@ -20,8 +26,23 @@ class AppPrefs(context: Context) {
set(value) = prefs.edit().putString("api_base_url", normalizeBaseUrl(value)).apply()
var commandToken: String
get() = prefs.getString("command_token", "") ?: ""
set(value) = prefs.edit().putString("command_token", value.trim()).apply()
get() {
val encrypted = prefs.getString("command_token_v2", "").orEmpty()
if (encrypted.isNotBlank()) return decryptToken(encrypted)
val legacy = prefs.getString("command_token", "").orEmpty()
if (legacy.isNotBlank()) {
commandToken = legacy
prefs.edit().remove("command_token").apply()
}
return legacy
}
set(value) {
val clean = value.trim()
prefs.edit()
.putString("command_token_v2", if (clean.isBlank()) "" else encryptToken(clean))
.remove("command_token")
.apply()
}
var selectedSymbol: String
get() = prefs.getString("selected_symbol", "BTCUSDT") ?: "BTCUSDT"
@@ -59,17 +80,62 @@ class AppPrefs(context: Context) {
private fun normalizeBaseUrl(value: String): String {
val trimmed = value.trim().trimEnd('/')
if (trimmed.isBlank()) return DEFAULT_API_BASE_URL
return if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"https://$trimmed"
return when {
trimmed.startsWith("https://") -> trimmed
trimmed.startsWith("http://") -> "https://${trimmed.removePrefix("http://")}"
else -> "https://$trimmed"
}
}
private fun encryptToken(value: String): String {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, tokenKey())
val iv = Base64.encodeToString(cipher.iv, Base64.NO_WRAP)
val data = Base64.encodeToString(cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
return "$iv:$data"
}
private fun decryptToken(value: String): String {
return try {
val parts = value.split(':', limit = 2)
if (parts.size != 2) {
""
} else {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
tokenKey(),
GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP)),
)
String(cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)), Charsets.UTF_8)
}
} catch (_: Exception) {
""
}
}
private fun tokenKey(): SecretKey {
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(store.getKey(TOKEN_KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance("AES", "AndroidKeyStore")
generator.init(
android.security.keystore.KeyGenParameterSpec.Builder(
TOKEN_KEY_ALIAS,
android.security.keystore.KeyProperties.PURPOSE_ENCRYPT or
android.security.keystore.KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(android.security.keystore.KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
return generator.generateKey()
}
private companion object {
const val DEFAULT_API_BASE_URL = "https://tb.kusoft.xyz"
const val LEGACY_PI_API_BASE_URL = "http://192.168.0.185:8787"
const val DEFAULT_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
const val DEFAULT_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1"
}
}
@@ -110,9 +110,19 @@ class MainActivity : Activity() {
palette = if (prefs.themeMode == "light") AppPalette.light() else AppPalette.dark()
buildShell()
refreshData(silent = false)
}
override fun onStart() {
super.onStart()
mainHandler.removeCallbacks(refreshRunnable)
mainHandler.postDelayed(refreshRunnable, 5000L)
}
override fun onStop() {
mainHandler.removeCallbacks(refreshRunnable)
super.onStop()
}
override fun onDestroy() {
mainHandler.removeCallbacks(refreshRunnable)
executor.shutdownNow()
@@ -280,7 +290,10 @@ class MainActivity : Activity() {
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.mode,
"Режим: ${modeLabel(data.mode)} · ${if (data.running) "цикл работает" else "цикл остановлен"} · ${if (data.ready) "готов" else "есть блокировки"}",
)
setText(binding.cash, money(data.account.cash))
setText(binding.exposure, money(data.account.exposure))
setText(binding.positionCount, "Открытые позиции: ${data.positions.size}")
@@ -317,7 +330,15 @@ class MainActivity : Activity() {
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.status,
when {
!data.running -> "СТОП"
data.ready -> "ГОТОВ"
else -> "БЛОКИРОВКА"
},
if (data.ready) palette.green else palette.amber,
)
setText(binding.decision, decision, actionColor(action))
setText(binding.kelly, money(signal?.positionNotionalUsdt ?: 0.0))
setText(binding.reason, reason.ifBlank { "Нет объяснения от модели" })
@@ -1586,6 +1607,8 @@ class MainActivity : Activity() {
return listOf(
data?.mode.orEmpty(),
data?.running?.toString().orEmpty(),
data?.ready?.toString().orEmpty(),
data?.readinessReasons?.joinToString(",").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(),
@@ -136,6 +136,8 @@ data class ClosedTradesSummary(
data class BotSnapshot(
val ok: Boolean,
val running: Boolean,
val ready: Boolean,
val readinessReasons: List<String>,
val mode: String,
val account: AccountData,
val positions: List<PositionData>,
@@ -39,7 +39,8 @@ object RetrainScheduler {
class RetrainAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
Executors.newSingleThreadExecutor().execute {
val executor = Executors.newSingleThreadExecutor()
executor.execute {
try {
val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled) {
@@ -47,6 +48,7 @@ class RetrainAlarmReceiver : BroadcastReceiver() {
}
} finally {
pending.finish()
executor.shutdown()
}
}
}
@@ -14,16 +14,19 @@ class TradeBotApi(
private val token: String,
) {
fun fetchSnapshot(): BotSnapshot {
val health = getJson("/api/health")
val status = getJson("/api/status")
val markets = getJson("/api/markets")
val signals = getJson("/api/signals?limit=220")
val config = getJson("/api/config")
val trades = getJson("/api/trades?limit=10")
val retrain = getJson("/api/retrain")
val backtest = getJson("/api/backtest")
val snapshot = getJson("/api/mobile/snapshot")
val health = snapshot.optJSONObject("health") ?: JSONObject()
val status = snapshot.optJSONObject("status") ?: JSONObject()
val markets = snapshot.optJSONObject("markets") ?: JSONObject()
val signals = snapshot.optJSONObject("signals") ?: JSONObject()
val config = snapshot.optJSONObject("config") ?: JSONObject()
val trades = snapshot.optJSONObject("trades") ?: JSONObject()
val retrain = snapshot.optJSONObject("retrain") ?: JSONObject()
val backtest = snapshot.optJSONObject("backtest") ?: JSONObject()
val accountJson = status.optJSONObject("account") ?: JSONObject()
val readiness = status.optJSONObject("readiness") ?: JSONObject()
val readinessReasons = readiness.optJSONArray("reasons") ?: JSONArray()
val account = AccountData(
equity = accountJson.optDouble("equity", 0.0),
cash = accountJson.optDouble("cash", 0.0),
@@ -33,6 +36,10 @@ class TradeBotApi(
return BotSnapshot(
ok = health.optBoolean("ok", false),
running = health.optBoolean("running", false),
ready = readiness.optBoolean("ready", false),
readinessReasons = List(readinessReasons.length()) { index ->
readinessReasons.optString(index)
}.filter { it.isNotBlank() },
mode = health.optStringClean("mode"),
account = account,
positions = parsePositions(status.optJSONArray("positions") ?: JSONArray()),