feat: production paper trading platform

This commit is contained in:
Курнат Андрей
2026-07-14 22:52:47 +03:00
parent 7186acb9a1
commit 5c4aecfe5f
29 changed files with 396 additions and 564 deletions
+2 -2
View File
@@ -11,7 +11,7 @@
- Kelly/размер позиции: текущий размер, Kelly-цель, занятая экспозиция, остаток, множители edge/P(up)/skill.
- Обзор equity/cash/exposure/PnL и последних решений.
- Удалённый запуск retrain через очередь заданий на боте и закреплённый Windows-компьютер обучения.
- Расписание retrain на телефоне: Android отправляет команду по расписанию, но обучение идёт на Windows-машине.
- Расписание retrain через Android WorkManager: команда отправляется только при наличии сети, а обучение идёт на Windows-машине.
- Настройки API, токена команд, тёмной/светлой темы.
- Live-чеклист: приложение показывает, готов ли сервер к реальной торговле, и не включает live одной опасной кнопкой.
@@ -42,7 +42,7 @@ https://tb.kusoft.xyz
## Переобучение
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на закреплённой машине `SEVENHILL` (`G:\Repos\TradeBot`) сам выходит в интернет, забирает задание, обучает модель и отправляет артефакты обратно боту. Так телефон становится пультом запуска/расписания, а тяжёлый PyTorch retrain остаётся на нормальном компьютере даже если он находится в другой сети.
Телефон не обучает модель локально. Вкладка `Обучение` ставит задание в очередь на `tb.kusoft.xyz`, а Windows-agent на этой машине сам выходит в интернет, забирает задание, обучает модель и отправляет проверенный bundle обратно боту. Имя и путь активного worker приложение получает от сервера, без прошитого имени компьютера.
## Live-торговля
+8 -4
View File
@@ -4,13 +4,17 @@ plugins {
android {
namespace = "xyz.kusoft.tradebotmonitor"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "xyz.kusoft.tradebotmonitor"
minSdk = 26
targetSdk = 36
versionCode = 21
versionName = "0.4.2"
targetSdk = 37
versionCode = 22
versionName = "0.5.0"
}
}
dependencies {
implementation("androidx.work:work-runtime:2.11.2")
}
@@ -2,12 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher"
android:label="TradeBot AI"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/AppTheme"
@@ -22,16 +23,5 @@
</intent-filter>
</activity>
<receiver
android:name=".RetrainAlarmReceiver"
android:exported="false" />
<receiver
android:name=".BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -18,12 +18,9 @@ class AppPrefs(context: Context) {
}
val trainingComputerName = prefs.getString("training_computer_name", null)?.trim()
val trainingComputerPath = prefs.getString("training_computer_path", null)?.trim()
if (
trainingComputerName.isNullOrBlank() ||
trainingComputerName == LEGACY_TRAINING_COMPUTER_NAME ||
trainingComputerPath.isNullOrBlank() ||
trainingComputerPath == LEGACY_TRAINING_COMPUTER_PATH
) {
val staleFallback = trainingComputerName in setOf("SEVENHILL", "DESKTOP-TMFDL0H") ||
trainingComputerPath in setOf("G:\\Repos\\TradeBot", "C:\\Repos\\TradeBot")
if (trainingComputerName.isNullOrBlank() || trainingComputerPath.isNullOrBlank() || staleFallback) {
pinDefaultTrainingComputer()
}
}
@@ -141,10 +138,8 @@ class AppPrefs(context: Context) {
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 = "SEVENHILL"
const val DEFAULT_TRAINING_COMPUTER_PATH = "G:\\Repos\\TradeBot"
const val LEGACY_TRAINING_COMPUTER_NAME = "DESKTOP-TMFDL0H"
const val LEGACY_TRAINING_COMPUTER_PATH = "C:\\Repos\\TradeBot"
const val DEFAULT_TRAINING_COMPUTER_NAME = "Ожидание Windows-agent"
const val DEFAULT_TRAINING_COMPUTER_PATH = "Имя и путь поступят от сервера"
const val TOKEN_KEY_ALIAS = "tradebot_api_auth_v1"
}
}
@@ -1512,12 +1512,15 @@ class MainActivity : Activity() {
private fun rankedMarkets(data: BotSnapshot): List<MarketItem> =
orderedMarkets(data.markets).sortedWith(
compareByDescending<MarketItem> { marketRankScore(it, data.signalsBySymbol[it.symbol]) }
.thenBy { fixedSymbolIndex(it.symbol) }
.thenByDescending { it.ticker?.turnover24h ?: 0.0 }
.thenBy { it.symbol },
)
private fun orderedMarkets(markets: List<MarketItem>): List<MarketItem> =
markets.sortedWith(compareBy({ fixedSymbolIndex(it.symbol) }, { it.symbol }))
markets.sortedWith(
compareByDescending<MarketItem> { it.ticker?.turnover24h ?: 0.0 }
.thenBy { it.symbol },
)
private fun marketRankScore(market: MarketItem, signal: SignalData?): Double {
val actionScore = when (normalizedAction(signal?.action)) {
@@ -1615,11 +1618,6 @@ class MainActivity : Activity() {
}
}
private fun fixedSymbolIndex(symbol: String): Int {
val index = FIXED_SYMBOLS.indexOf(symbol.uppercase(Locale.US))
return if (index >= 0) index else FIXED_SYMBOLS.size + 1
}
private fun trainingStatusSignature(retrain: JSONObject): String =
(retrain.optJSONObject("coordination") ?: JSONObject()).toString()
@@ -1895,20 +1893,4 @@ class MainActivity : Activity() {
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
private companion object {
val FIXED_SYMBOLS = listOf(
"BTCUSDT",
"ETHUSDT",
"HYPEUSDT",
"SOLUSDT",
"XRPUSDT",
"XPLUSDT",
"WLDUSDT",
"MNTUSDT",
"HUSDT",
"XAUTUSDT",
"IPUSDT",
"AAVEUSDT",
)
}
}
@@ -1,65 +1,55 @@
package xyz.kusoft.tradebotmonitor
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import java.util.concurrent.Executors
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import java.util.concurrent.TimeUnit
object RetrainScheduler {
private const val ACTION_RETRAIN = "xyz.kusoft.tradebotmonitor.RETRAIN"
private const val REQUEST_CODE = 6406
private const val UNIQUE_WORK_NAME = "tradebot-periodic-retrain"
fun schedule(context: Context, hours: Int) {
val interval = hours.coerceAtLeast(1) * 60L * 60L * 1000L
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
manager.setInexactRepeating(
AlarmManager.RTC_WAKEUP,
System.currentTimeMillis() + interval,
interval,
pendingIntent(context),
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequest.Builder(
RetrainWorker::class.java,
hours.coerceAtLeast(1).toLong(),
TimeUnit.HOURS,
)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
fun cancel(context: Context) {
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
manager.cancel(pendingIntent(context))
WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_WORK_NAME)
}
private fun pendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
REQUEST_CODE,
Intent(context, RetrainAlarmReceiver::class.java).setAction(ACTION_RETRAIN),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
class RetrainAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
val executor = Executors.newSingleThreadExecutor()
executor.execute {
try {
val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled) {
TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
}
} finally {
pending.finish()
executor.shutdown()
}
class RetrainWorker(
context: Context,
parameters: WorkerParameters,
) : Worker(context, parameters) {
override fun doWork(): Result {
val prefs = AppPrefs(applicationContext)
if (!prefs.retrainScheduleEnabled || prefs.commandToken.isBlank()) {
return Result.success()
}
}
}
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val prefs = AppPrefs(context)
if (prefs.retrainScheduleEnabled) {
RetrainScheduler.schedule(context, prefs.retrainIntervalHours)
return try {
TradeBotApi(prefs.apiBaseUrl, prefs.commandToken).requestRetrain()
Result.success()
} catch (_: Exception) {
Result.retry()
}
}
}
@@ -183,7 +183,7 @@ class TradeBotApi(
qualityScore = quality.optDouble("score", 0.0),
)
}
return output.sortedBy { it.symbol }
return output
}
private fun parseTicker(row: JSONObject): TickerData =
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</full-backup-content>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<exclude domain="file" path="." />
<exclude domain="database" path="." />
<exclude domain="sharedpref" path="." />
<exclude domain="external" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -142,7 +142,7 @@
<rect x="168" y="29" width="84" height="8" rx="4" fill="#151922"/>
<text x="338" y="48" class="text small">91%</text>
<text x="34" y="88" class="text h2">Рынки</text>
<text x="34" y="114" class="muted small">12 фиксированных spot-пар</text>
<text x="34" y="114" class="muted small">Динамические Bybit spot-пары</text>
<rect x="34" y="136" width="340" height="42" rx="7" fill="#11141a" stroke="#242a36"/>
<text x="52" y="162" class="dim small">Поиск пары или сигнала</text>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
networkTimeout=60000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists