Harden trading, training, and monitoring
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -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>,
|
||||
|
||||
+3
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-8
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user