From b50e43c239a88deef99728aa0cb46e1f0ec5b9c6 Mon Sep 17 00:00:00 2001 From: WrBug Date: Sat, 14 Feb 2026 15:16:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(crypto-tail):=20=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E6=9C=80=E5=B0=8F=E4=BB=B7=E5=B7=AE(=E6=97=A0/=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A/=E8=87=AA=E5=8A=A8)=20+=20=E5=89=8D=E7=AB=AF=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E4=B8=8E=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: 最小价差 DB/Entity/DTO、Binance K线 REST+WS、自动价差 IQR 预计算与执行时校验 - 前端: 最小价差(自动-固定-无),默认自动,label 旁 info 说明,选择自动不展示建议约 - i18n: minSpreadModeTip 说明不写死标的 - 文档: crypto-tail-strategy-min-spread-flow.md - scripts: Binance K线拉取与 WS 示例 Co-authored-by: Cursor --- .../com/wrbug/polymarketbot/api/BinanceApi.kt | 26 ++ .../CryptoTailStrategyController.kt | 29 ++ .../dto/CryptoTailStrategyDto.kt | 14 + .../entity/CryptoTailStrategy.kt | 6 + .../binance/BinanceKlineAutoSpreadService.kt | 94 +++++++ .../service/binance/BinanceKlineService.kt | 128 +++++++++ .../CryptoTailOrderbookWsService.kt | 26 +- .../CryptoTailStrategyExecutionService.kt | 24 +- .../cryptotail/CryptoTailStrategyService.kt | 23 ++ .../polymarketbot/util/RetrofitFactory.kt | 14 +- ...7__add_crypto_tail_strategy_min_spread.sql | 4 + .../crypto-tail-strategy-min-spread-flow.md | 247 ++++++++++++++++++ frontend/src/locales/en/common.json | 9 +- frontend/src/locales/zh-CN/common.json | 9 +- frontend/src/locales/zh-TW/common.json | 9 +- frontend/src/pages/CryptoTailStrategyList.tsx | 58 +++- frontend/src/services/api.ts | 8 +- frontend/src/types/index.ts | 10 + scripts/fetch_binance_btc_usdc_klines.py | 105 ++++++++ scripts/package-lock.json | 3 +- scripts/package.json | 6 +- scripts/ws_binance_btc_usdc_klines.js | 92 +++++++ 22 files changed, 930 insertions(+), 14 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/api/BinanceApi.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt create mode 100644 backend/src/main/resources/db/migration/V37__add_crypto_tail_strategy_min_spread.sql create mode 100644 docs/zh/crypto-tail-strategy-min-spread-flow.md create mode 100644 scripts/fetch_binance_btc_usdc_klines.py create mode 100644 scripts/ws_binance_btc_usdc_klines.js diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BinanceApi.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BinanceApi.kt new file mode 100644 index 0000000..9dc07d8 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/api/BinanceApi.kt @@ -0,0 +1,26 @@ +package com.wrbug.polymarketbot.api + +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * 币安现货公开 API(K 线等) + * Base URL: https://api.binance.com + * 文档: https://developers.binance.com/docs/binance-spot-api-docs/rest-api + */ +interface BinanceApi { + + /** + * K 线数据 + * 返回每根 K 线: [openTime, open, high, low, close, volume, closeTime, ...] + */ + @GET("/api/v3/klines") + fun getKlines( + @Query("symbol") symbol: String, + @Query("interval") interval: String, + @Query("limit") limit: Int = 30, + @Query("startTime") startTime: Long? = null, + @Query("endTime") endTime: Long? = null + ): Call>> +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt index cba1466..c8fb02c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/cryptotail/CryptoTailStrategyController.kt @@ -10,7 +10,9 @@ import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListRequest import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto +import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService import org.slf4j.LoggerFactory import org.springframework.context.MessageSource @@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/crypto-tail-strategy") class CryptoTailStrategyController( private val cryptoTailStrategyService: CryptoTailStrategyService, + private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService, private val messageSource: MessageSource ) { @@ -151,4 +154,30 @@ class CryptoTailStrategyController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) } } + + /** + * 自动最小价差预览:按「当前周期」计算一次并返回,仅用于前端展示参考。 + * 实际触发时按每个周期在需要时计算,不依赖此接口。 + */ + @PostMapping("/auto-min-spread") + fun getAutoMinSpread(@RequestBody request: java.util.Map): ResponseEntity> { + return try { + val intervalSeconds = (request["intervalSeconds"] as? Number)?.toInt() ?: 300 + if (intervalSeconds != 300 && intervalSeconds != 900) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource)) + } + val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong() + ?: (System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds + val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix) + ?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource)) + val body = CryptoTailAutoMinSpreadResponse( + minSpreadUp = pair.first.toPlainString(), + minSpreadDown = pair.second.toPlainString() + ) + ResponseEntity.ok(ApiResponse.success(body)) + } catch (e: Exception) { + logger.error("计算自动最小价差异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt index fb61155..088e47e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CryptoTailStrategyDto.kt @@ -15,6 +15,8 @@ data class CryptoTailStrategyCreateRequest( val maxPrice: String? = null, val amountMode: String = "RATIO", val amountValue: String = "0", + val minSpreadMode: String = "NONE", + val minSpreadValue: String? = null, val enabled: Boolean = true ) @@ -30,6 +32,8 @@ data class CryptoTailStrategyUpdateRequest( val maxPrice: String? = null, val amountMode: String? = null, val amountValue: String? = null, + val minSpreadMode: String? = null, + val minSpreadValue: String? = null, val enabled: Boolean? = null ) @@ -57,6 +61,8 @@ data class CryptoTailStrategyDto( val maxPrice: String = "1", val amountMode: String = "RATIO", val amountValue: String = "0", + val minSpreadMode: String = "NONE", + val minSpreadValue: String? = null, val enabled: Boolean = true, val lastTriggerAt: Long? = null, /** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */ @@ -127,6 +133,14 @@ data class CryptoTailStrategyTriggerListResponse( val total: Long = 0L ) +/** + * 自动最小价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.8) + */ +data class CryptoTailAutoMinSpreadResponse( + val minSpreadUp: String = "0", + val minSpreadDown: String = "0" +) + /** * 5/15 分钟市场项(供前端选择市场) */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt index 2b02ab7..7aa31cf 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategy.kt @@ -45,6 +45,12 @@ data class CryptoTailStrategy( @Column(name = "amount_value", nullable = false, precision = 20, scale = 8) val amountValue: BigDecimal = BigDecimal.ZERO, + @Column(name = "min_spread_mode", nullable = false, length = 16) + val minSpreadMode: String = "NONE", + + @Column(name = "min_spread_value", precision = 20, scale = 8) + val minSpreadValue: BigDecimal? = null, + @Column(name = "enabled", nullable = false) val enabled: Boolean = true, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt new file mode 100644 index 0000000..18ceb3a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt @@ -0,0 +1,94 @@ +package com.wrbug.polymarketbot.service.binance + +import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.concurrent.ConcurrentHashMap + +/** + * 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 30 根已收盘 K 线,按方向筛选、IQR 剔除后求平均 × 0.8,缓存 (interval, period)。 + * 不在保存策略时计算。 + */ +@Service +class BinanceKlineAutoSpreadService( + private val retrofitFactory: RetrofitFactory +) { + + private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java) + + private val symbol = "BTCUSDC" + private val historyLimit = 30 + private val autoSpreadCoefficient = BigDecimal("0.8") + private val minSamplesAfterIqr = 3 + + /** (intervalSeconds, periodStartUnix) -> (minSpreadUp, minSpreadDown) */ + private val cache = ConcurrentHashMap>() + + private fun cacheKey(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix" + + fun getAutoMinSpread(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? { + val key = cacheKey(intervalSeconds, periodStartUnix) + val (up, down) = cache[key] ?: run { + computeAndCache(intervalSeconds, periodStartUnix) ?: return null + } + return if (outcomeIndex == 0) up else down + } + + fun computeAndCache(intervalSeconds: Int, periodStartUnix: Long): Pair? { + val intervalStr = if (intervalSeconds == 300) "5m" else "15m" + val endTimeMs = periodStartUnix * 1000L + val klines = fetchKlines(intervalStr, historyLimit, endTime = endTimeMs) ?: return null + val spreadsUp = mutableListOf() + val spreadsDown = mutableListOf() + for (k in klines) { + if (k.size < 5) continue + val openP = k.getOrNull(1)?.toString()?.toSafeBigDecimal() ?: continue + val closeP = k.getOrNull(4)?.toString()?.toSafeBigDecimal() ?: continue + if (closeP > openP) spreadsUp.add(closeP.subtract(openP)) + if (closeP < openP) spreadsDown.add(openP.subtract(closeP)) + } + val avgUp = averageAfterIqr(spreadsUp).multiply(autoSpreadCoefficient).setScale(8, RoundingMode.HALF_UP) + val avgDown = averageAfterIqr(spreadsDown).multiply(autoSpreadCoefficient).setScale(8, RoundingMode.HALF_UP) + cache[cacheKey(intervalSeconds, periodStartUnix)] = avgUp to avgDown + logger.info( + "尾盘自动价差已计算并缓存(按周期): interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " + + "Up方向: 样本数=${spreadsUp.size}, minSpreadUp=${avgUp.toPlainString()} | " + + "Down方向: 样本数=${spreadsDown.size}, minSpreadDown=${avgDown.toPlainString()}" + ) + return avgUp to avgDown + } + + private fun fetchKlines(interval: String, limit: Int, endTime: Long? = null): List>? { + return try { + val api = retrofitFactory.createBinanceApi() + val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime) + val response = call.execute() + if (response.isSuccessful && response.body() != null) response.body() else null + } catch (e: Exception) { + logger.warn("拉取币安 K 线失败: ${e.message}") + null + } + } + + /** + * IQR 剔除异常值后求平均;若剔除后样本数 < minSamplesAfterIqr 则不剔除,用全量求平均。 + */ + private fun averageAfterIqr(list: List): BigDecimal { + if (list.isEmpty()) return BigDecimal.ZERO + val sorted = list.sorted() + val n = sorted.size + val q1Idx = (n * 0.25).toInt().coerceIn(0, n - 1) + val q3Idx = (n * 0.75).toInt().coerceIn(0, n - 1) + val q1 = sorted[q1Idx] + val q3 = sorted[q3Idx] + val iqr = q3.subtract(q1) + val lower = q1.subtract(iqr.multiply(BigDecimal("1.5"))) + val upper = q3.add(iqr.multiply(BigDecimal("1.5"))) + val filtered = sorted.filter { it >= lower && it <= upper } + val use = if (filtered.size < minSamplesAfterIqr) sorted else filtered + return use.fold(BigDecimal.ZERO) { a, b -> a.add(b) }.divide(BigDecimal(use.size), 18, RoundingMode.HALF_UP) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt new file mode 100644 index 0000000..97f271d --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineService.kt @@ -0,0 +1,128 @@ +package com.wrbug.polymarketbot.service.binance + +import com.wrbug.polymarketbot.util.createClient +import com.wrbug.polymarketbot.util.toSafeBigDecimal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.math.BigDecimal +import jakarta.annotation.PreDestroy +import java.util.concurrent.ConcurrentHashMap + +/** + * 币安 K 线 WebSocket:订阅 BTCUSDC 5m/15m,维护当前周期 (open, close),供尾盘策略价差校验使用。 + */ +@Service +class BinanceKlineService { + + private val logger = LoggerFactory.getLogger(BinanceKlineService::class.java) + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private val wsBase = "wss://stream.binance.com:9443" + private val client = createClient().build() + + /** (intervalSeconds, periodStartUnix) -> (open, close) */ + private val openCloseByPeriod = ConcurrentHashMap>() + private var ws5m: WebSocket? = null + private var ws15m: WebSocket? = null + private var reconnectJob: Job? = null + + init { + connectAll() + } + + private fun key(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix" + + fun getCurrentOpenClose(intervalSeconds: Int, periodStartUnix: Long): Pair? { + return openCloseByPeriod[key(intervalSeconds, periodStartUnix)] + } + + private fun connectAll() { + if (ws5m != null && ws15m != null) return + connectStream("btcusdc@kline_5m") { intervalSec, tMs, openP, closeP -> + val periodSec = tMs / 1000 + openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP + }.also { ws5m = it } + connectStream("btcusdc@kline_15m") { intervalSec, tMs, openP, closeP -> + val periodSec = tMs / 1000 + openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP + }.also { ws15m = it } + } + + private fun connectStream( + streamName: String, + onKline: (intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit + ): WebSocket { + val url = "$wsBase/ws/$streamName" + val intervalSeconds = when { + streamName.contains("kline_5m") -> 300 + streamName.contains("kline_15m") -> 900 + else -> 300 + } + val request = Request.Builder().url(url).build() + val ws = client.newWebSocket(request, object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + parseKlineMessage(text, intervalSeconds)?.let { (tMs, o, c) -> + onKline(intervalSeconds, tMs, o, c) + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { + logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}") + scheduleReconnect() + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + if (code != 1000) scheduleReconnect() + } + }) + logger.info("币安 K 线 WS 已连接: $streamName") + return ws + } + + private fun parseKlineMessage(text: String, intervalSeconds: Int): Triple? { + return try { + val json = com.google.gson.JsonParser.parseString(text).asJsonObject + if (json.get("e")?.asString != "kline") return null + val k = json.getAsJsonObject("k") ?: return null + val tMs = k.get("t")?.asLong ?: return null + val o = k.get("o")?.asString?.toSafeBigDecimal() ?: return null + val c = k.get("c")?.asString?.toSafeBigDecimal() ?: return null + Triple(tMs, o, c) + } catch (e: Exception) { + logger.debug("解析币安 K 线消息失败: ${e.message}") + null + } + } + + private fun scheduleReconnect() { + if (reconnectJob?.isActive == true) return + reconnectJob = scope.launch { + delay(10_000) + reconnectJob = null + ws5m?.close(1000, "reconnect") + ws15m?.close(1000, "reconnect") + ws5m = null + ws15m = null + logger.info("币安 K 线 WS 尝试重连") + connectAll() + } + } + + @PreDestroy + fun destroy() { + reconnectJob?.cancel() + ws5m?.close(1000, "shutdown") + ws15m?.close(1000, "shutdown") + ws5m = null + ws15m = null + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt index 7a3d576..fb24f2c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailOrderbookWsService.kt @@ -5,6 +5,7 @@ import com.wrbug.polymarketbot.constants.PolymarketConstants import com.wrbug.polymarketbot.entity.CryptoTailStrategy import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository +import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService import com.wrbug.polymarketbot.util.RetrofitFactory import com.wrbug.polymarketbot.util.createClient import com.wrbug.polymarketbot.util.fromJson @@ -36,7 +37,8 @@ import java.util.concurrent.atomic.AtomicReference class CryptoTailOrderbookWsService( private val strategyRepository: CryptoTailStrategyRepository, private val executionService: CryptoTailStrategyExecutionService, - private val retrofitFactory: RetrofitFactory + private val retrofitFactory: RetrofitFactory, + private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService ) { private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java) @@ -217,6 +219,28 @@ class CryptoTailOrderbookWsService( return } scheduleRefreshAtPeriodEnd(newMap) + precomputeAutoMinSpreadForCurrentPeriods(newMap) + } + + /** + * AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期最小价差,触发时直接用缓存。 + */ + private fun precomputeAutoMinSpreadForCurrentPeriods(newMap: Map>) { + val autoPeriods = newMap.values.asSequence().flatten() + .filter { it.strategy.minSpreadMode.uppercase() == "AUTO" } + .distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" } + .map { it.strategy.intervalSeconds to it.periodStartUnix } + .toList() + if (autoPeriods.isEmpty()) return + scope.launch { + for ((intervalSeconds, periodStartUnix) in autoPeriods) { + try { + binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix) + } catch (e: Exception) { + logger.warn("周期开始预计算 AUTO 价差失败: interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}") + } + } + } } /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt index c83881c..8d1c204 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyExecutionService.kt @@ -10,6 +10,8 @@ import com.wrbug.polymarketbot.repository.AccountRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository import com.wrbug.polymarketbot.service.accounts.AccountService +import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService +import com.wrbug.polymarketbot.service.binance.BinanceKlineService import com.wrbug.polymarketbot.service.common.PolymarketClobService import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService import com.wrbug.polymarketbot.util.CryptoUtils @@ -63,7 +65,9 @@ class CryptoTailStrategyExecutionService( private val retrofitFactory: RetrofitFactory, private val clobService: PolymarketClobService, private val orderSigningService: OrderSigningService, - private val cryptoUtils: CryptoUtils + private val cryptoUtils: CryptoUtils, + private val binanceKlineService: BinanceKlineService, + private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService ) { private val logger = LoggerFactory.getLogger(CryptoTailStrategyExecutionService::class.java) @@ -212,11 +216,29 @@ class CryptoTailStrategyExecutionService( val mutex = getTriggerMutex(strategy.id!!, periodStartUnix) mutex.withLock { if (triggerRepository.findByStrategyIdAndPeriodStartUnix(strategy.id!!, periodStartUnix) != null) return@withLock + if (!passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle) placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid) } } + private fun passMinSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean { + val mode = strategy.minSpreadMode.uppercase() + if (mode == "NONE") return true + val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix) + ?: return false + val (openP, closeP) = oc + val spreadAbs = closeP.subtract(openP).abs() + val effectiveMinSpread = when (mode) { + "FIXED" -> strategy.minSpreadValue?.takeIf { it > BigDecimal.ZERO } + "AUTO" -> binanceKlineAutoSpreadService.getAutoMinSpread(strategy.intervalSeconds, periodStartUnix, outcomeIndex) + ?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second } + else -> null + } + if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true + return spreadAbs >= effectiveMinSpread + } + private suspend fun placeOrderForTrigger( strategy: CryptoTailStrategy, periodStartUnix: Long, diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt index 05398f1..1ce29f4 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailStrategyService.kt @@ -62,6 +62,14 @@ class CryptoTailStrategyService( if (amountValue <= BigDecimal.ZERO) { return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) } + val minSpreadMode = (request.minSpreadMode ?: "NONE").uppercase() + if (minSpreadMode != "NONE" && minSpreadMode != "FIXED" && minSpreadMode != "AUTO") { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } + val minSpreadValue = request.minSpreadValue?.toSafeBigDecimal() + if (minSpreadMode == "FIXED" && (minSpreadValue == null || minSpreadValue < BigDecimal.ZERO)) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } val nameToSave = request.name?.takeIf { it.isNotBlank() } ?: generateStrategyName(request.marketSlugPrefix.trim()) @@ -77,6 +85,8 @@ class CryptoTailStrategyService( maxPrice = maxPrice, amountMode = amountMode, amountValue = amountValue, + minSpreadMode = minSpreadMode, + minSpreadValue = minSpreadValue, enabled = request.enabled ) val saved = strategyRepository.save(entity) @@ -111,6 +121,15 @@ class CryptoTailStrategyService( ?: existing.name?.takeIf { it.isNotBlank() } ?: generateStrategyName(existing.marketSlugPrefix) + val newMinSpreadMode = request.minSpreadMode?.uppercase() ?: existing.minSpreadMode + if (newMinSpreadMode != "NONE" && newMinSpreadMode != "FIXED" && newMinSpreadMode != "AUTO") { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } + val newMinSpreadValue = request.minSpreadValue?.toSafeBigDecimal() ?: existing.minSpreadValue + if (newMinSpreadMode == "FIXED" && (newMinSpreadValue == null || newMinSpreadValue < BigDecimal.ZERO)) { + return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey)) + } + val updated = existing.copy( name = nameToSave, windowStartSeconds = request.windowStartSeconds ?: existing.windowStartSeconds, @@ -119,6 +138,8 @@ class CryptoTailStrategyService( maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice, amountMode = request.amountMode?.uppercase() ?: existing.amountMode, amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue, + minSpreadMode = newMinSpreadMode, + minSpreadValue = newMinSpreadValue, enabled = request.enabled ?: existing.enabled, updatedAt = System.currentTimeMillis() ) @@ -224,6 +245,8 @@ class CryptoTailStrategyService( maxPrice = e.maxPrice.toPlainString(), amountMode = e.amountMode, amountValue = e.amountValue.toPlainString(), + minSpreadMode = e.minSpreadMode, + minSpreadValue = e.minSpreadValue?.toPlainString(), enabled = e.enabled, lastTriggerAt = lastTriggerAt, totalRealizedPnl = totalPnl?.toPlainString(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt index 2adb5b2..cbe587a 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/RetrofitFactory.kt @@ -1,6 +1,7 @@ package com.wrbug.polymarketbot.util import com.google.gson.Gson +import com.wrbug.polymarketbot.api.BinanceApi import com.wrbug.polymarketbot.api.BuilderRelayerApi import com.wrbug.polymarketbot.api.EthereumRpcApi import com.wrbug.polymarketbot.api.GitHubApi @@ -300,7 +301,18 @@ class RetrofitFactory( fun createDataApi(): PolymarketDataApi { return dataApi } - + + private val binanceApi: BinanceApi by lazy { + Retrofit.Builder() + .baseUrl("https://api.binance.com/") + .client(sharedOkHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(BinanceApi::class.java) + } + + fun createBinanceApi(): BinanceApi = binanceApi + /** * 创建 Builder Relayer API 客户端 * 按 relayerUrl 缓存,避免重复创建 diff --git a/backend/src/main/resources/db/migration/V37__add_crypto_tail_strategy_min_spread.sql b/backend/src/main/resources/db/migration/V37__add_crypto_tail_strategy_min_spread.sql new file mode 100644 index 0000000..712d212 --- /dev/null +++ b/backend/src/main/resources/db/migration/V37__add_crypto_tail_strategy_min_spread.sql @@ -0,0 +1,4 @@ +-- 尾盘策略最小价差:NONE=不校验, FIXED=固定值, AUTO=历史计算 +ALTER TABLE crypto_tail_strategy + ADD COLUMN min_spread_mode VARCHAR(16) NOT NULL DEFAULT 'NONE' COMMENT '最小价差模式: NONE, FIXED, AUTO', + ADD COLUMN min_spread_value DECIMAL(20, 8) NULL COMMENT '最小价差数值(FIXED 时必填;AUTO 时可存计算值)'; diff --git a/docs/zh/crypto-tail-strategy-min-spread-flow.md b/docs/zh/crypto-tail-strategy-min-spread-flow.md new file mode 100644 index 0000000..c158fd5 --- /dev/null +++ b/docs/zh/crypto-tail-strategy-min-spread-flow.md @@ -0,0 +1,247 @@ +# 尾盘策略 - 最小价差参数流程分析 + +## 一、需求摘要 + +在现有尾盘策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。 + +- **后端**:需订阅币安对应币对(如 BTC/USDC)的 K 线,维护当前周期的**开盘价**与**实时收盘价**,并在触发时做价差校验。 +- **前端**:可配置三种场景——无、固定、自动(见下)。 + +--- + +## 二、前端配置场景 + +| 场景 | 配置方式 | 校验逻辑 | +|------|----------|----------| +| **无** | 不进行价差校验 | 与现有一致:仅判断时间窗 + 价格区间,满足即下单。 | +| **固定** | 用户输入一个固定价差(如 30) | 当 \|收盘价 − 开盘价\| ≥ 该固定值时,校验通过,再下单。 | +| **自动** | 由系统根据历史数据计算最小价差 | 见下文「自动模式计算逻辑」;得到数值后,后续与固定模式一致:\|收盘价 − 开盘价\| ≥ 计算值 则通过。 | + +### 自动模式计算逻辑 + +- 通过币安 API 获取**历史 30 根** K 线(与策略周期一致:5m 取 5m K 线,15m 取 15m K 线)。 +- **下单方向 = Down**(outcomeIndex = 1):只取「收盘价 < 开盘价」的 K 线,得到价差序列(开盘价 − 收盘价)。 +- **下单方向 = Up**(outcomeIndex = 0):只取「收盘价 > 开盘价」的 K 线,得到价差序列(收盘价 − 开盘价)。 +- **异常值剔除**:对上述价差序列做异常值过滤(见下文「异常值剔除」),再用**剩余样本**求平均价差,乘以系数 **80%** 得到最小价差;后续用该值做 \|收盘价 − 开盘价\| ≥ 该值 的校验。 +- **历史数据获取时机**:**在该周期开始时就拉取并计算**,不在保存策略时计算。订单簿 WS 在周期开始时刷新订阅(含每 25 秒或周期切换时的 refreshAndSubscribe),此时对当前周期内所有启用且为 AUTO 的策略,按 (intervalSeconds, periodStartUnix) 预拉该周期前 30 根已收盘 K 线并计算 minSpreadUp/minSpreadDown 写入缓存;该周期内触发时直接用缓存,无需在触发时再调 REST。 + +### 异常值剔除 + +- **目的**:避免少数极端 K 线(如 14 组价差在 50 以内、1 组价差 200)拉高平均价差,导致最小价差偏大、难以触发。 +- **做法**:在按方向得到价差序列后,先**剔除异常值**,再对剩余价差求平均并 × 0.8。 +- **推荐方法:IQR(四分位距)** + - 对价差序列排序,计算 Q1(25% 分位)、Q3(75% 分位)、IQR = Q3 − Q1。 + - 保留区间 **[Q1 − 1.5×IQR, Q3 + 1.5×IQR]** 内的价差,剔除该区间外的点。 + - 示例:15 组价差,14 组在 50 以内、1 组为 200 → 200 会超出上界被剔除,只用 14 组参与平均。 +- **边界与降级** + - 若剔除后剩余样本数过少(如 < 3),则**不剔除**:用全部价差样本求平均 × 0.8。 + - 若无满足方向的 K 线(如 30 根里没有 close < open),仍按原文档降级处理(全量 \|close−open\| 或返回 0)。 + +--- + +## 三、整体流程(含价差校验) + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 1. 数据源与订阅 │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ • CLOB 订单簿 WS(现有):Polymarket 订单簿 → bestBid。 │ +│ • 币安 K 线 WS(新增):订阅 BTCUSDC 对应周期(5m/15m),维护「当前周期」的开盘价 │ +│ open、实时收盘价 close(每根 K 线未收盘前 close 会持续更新)。 │ +└─────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 2. 触发入口(与现有一致) │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ • 入口 A:CryptoTailOrderbookWsService.onBestBid(tokenId, bestBid) │ +│ • 入口 B:CryptoTailStrategyExecutionService.runCycle()(HTTP 拉订单簿) │ +│ 两者在「时间窗 + 价格区间 + 本周期未触发」通过后,都会调用执行层「尝试下单」。 │ +└─────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ 3. 执行层增加「价差校验」 │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ 在现有 tryTriggerWithPriceFromWs / runCycle → placeOrderForTrigger 之前增加: │ +│ │ +│ if (策略.minSpreadMode == NONE) → 直接进入 placeOrderForTrigger。 │ +│ else: │ +│ • 从「币安 K 线服务」取当前周期(与 strategy.intervalSeconds 对齐)的 open、 │ +│ close(实时)。 │ +│ • 若取不到 open/close(例如该周期尚未有数据)→ 本轮不下单,等待下次 WS 更新。 │ +│ • 计算 effectiveMinSpread: │ +│ - FIXED:effectiveMinSpread = 策略.minSpreadValue(用户填的固定值) │ +│ - AUTO:effectiveMinSpread = 按当前下单方向(outcomeIndex)取「自动计算 │ +│ 的最小价差」(见下节;若尚未计算则先拉 30 根历史 K 线并计算、缓存)。 │ +│ • 若 |close − open| < effectiveMinSpread → 本轮不下单,等待价差满足。 │ +│ • 若 |close − open| >= effectiveMinSpread → 通过价差校验,进入 │ +│ placeOrderForTrigger(与现有逻辑一致:预签/签名、提交 CLOB 订单、写触发记录)。│ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +- **「等待价差满足」**:不主动轮询;下次 CLOB 订单簿或币安 K 线有推送时,会再次进入上述判断,此时 close 可能已更新,价差可能已满足,再决定是否下单。 +- **每周期最多触发一次**:仍由现有「本周期是否已触发」保证;价差不满足时**不写触发记录**,也不占「已触发」名额,直到某次检查同时满足价格区间与价差后才下单并标记已触发。 + +--- + +## 四、自动模式:何时拉历史、如何算、如何用 + +- **何时拉 30 根历史 K 线并计算** + - **在该周期开始时就预计算**,不在保存策略时计算。 + - 订单簿 WS 在**周期开始时**会刷新订阅(`refreshAndSubscribe`:每 25 秒或检测到周期切换时),此时对当前周期内所有启用且 minSpreadMode=AUTO 的策略,按 `(intervalSeconds, periodStartUnix)` 异步拉取该周期前 30 根已收盘 K 线(REST `endTime = periodStartUnix * 1000`),按 Up/Down 分别算 avgSpread × 0.8(含 IQR 剔除)并写入缓存。该周期内后续触发时直接用缓存,**不在触发时再调 REST**。 + - 若某周期未做预计算(如服务刚启动且尚未到刷新时机),触发时仍会按需调用 `computeAndCache` 并缓存,保证逻辑正确。 + - 前端「自动最小价差」接口仅作**预览**,实际下单校验不依赖该接口。 + +- **计算细节** + - 历史 30 根:币安 REST `GET /api/v3/klines?symbol=BTCUSDC&interval=5m|15m&limit=30`(或 31 取前 30 根已收盘),每根格式为 [openTime, open, high, low, close, ...]。 + - **Down(outcomeIndex=1)**:筛选 close < open,价差 = open − close,得到价差序列 → **异常值剔除(IQR)** → 对剩余价差求平均,再 × 0.8 → minSpreadDown。 + - **Up(outcomeIndex=0)**:筛选 close > open,价差 = close − open,得到价差序列 → **异常值剔除(IQR)** → 对剩余价差求平均,再 × 0.8 → minSpreadUp。 + - **异常值剔除**:见上文「异常值剔除」;剔除后再平均。若剔除后剩余样本 < 3,则不剔除,用全部价差样本求平均。 + - 若无满足方向的 K 线(例如 30 根里没有一根 close < open),可降级:用全部 30 根的 |close−open| 平均 × 0.8,或返回 0/不校验,具体产品可定。 + +- **触发时使用** + - 当前要下单的是 outcomeIndex(0=Up, 1=Down),取对应的 minSpreadUp 或 minSpreadDown 作为 effectiveMinSpread,再与 |close − open| 比较。 + +--- + +## 五、后端模块与数据流 + +| 模块 | 职责 | +|------|------| +| **BinanceKlineService(新)** | 1)订阅币安 WS:BTCUSDC 的 5m、15m K 线流(可按需只订阅有策略使用的周期)。
2)维护「当前周期」数据:以 periodStartUnix(或 K 线 t 对齐)为 key,存 (open, close);K 线 WS 推送时更新 close,新周期首条推送时更新 open。
3)提供 getCurrentOpenClose(symbol, intervalSeconds, periodStartUnix) → (open, close)?,供执行层价差校验使用。 | +| **BinanceKlineAutoSpreadService 或合入上者(新)** | 1)按**周期**拉取:以 periodStartUnix 为界,REST 拉取该周期前的 30 根已收盘 K 线。
2)按 Up/Down 得到价差序列 → **IQR 异常值剔除** → 对剩余价差求平均 × 0.8,缓存 (intervalSeconds, periodStartUnix) → (minSpreadUp, minSpreadDown)。
3)提供 getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex) 与 computeAndCache(intervalSeconds, periodStartUnix)。**周期开始时**由 CryptoTailOrderbookWsService 在 refreshAndSubscribe 后对当前周期内 AUTO 策略预调 computeAndCache;触发时直接用缓存,未命中时再按需计算。 | +| **CryptoTailStrategy(实体)** | 新增字段建议:minSpreadMode(NONE/FIXED/AUTO)、minSpreadValue(固定时使用;AUTO 时可为空或存上次计算值用于展示)。 | +| **CryptoTailStrategyExecutionService(现有)** | 在 tryTriggerWithPriceFromWs 与 runCycle 分支中,在调用 placeOrderForTrigger 前:若 minSpreadMode != NONE,则取 open/close 与 effectiveMinSpread,校验 \|close−open\| >= effectiveMinSpread;不通过则 return,不调用 placeOrderForTrigger。 | +| **CryptoTailOrderbookWsService(现有)** | 仍只根据 CLOB bestBid 触发;价差校验在执行层统一做。**新增**:refreshAndSubscribe 完成后,对当前周期内所有启用且 minSpreadMode=AUTO 的策略,异步调用 BinanceKlineAutoSpreadService.computeAndCache,在周期开始即预计算最小价差。 | + +- **币安 K 线与周期对齐** + - 策略周期:periodStartUnix 为秒(如 5m 周期 = 300 的倍数,15m = 900 的倍数)。 + - 币安 K 线:t 为毫秒,同一周期:t_ms = periodStartUnix * 1000。 + - 用 (intervalSeconds, periodStartUnix) 或 (interval, t_ms) 对齐即可从 BinanceKlineService 取到「当前周期」的 open 和实时 close。 + +--- + +## 六、固定(FIXED)与自动(AUTO)时序图 + +### 6.1 固定(FIXED)时序图 + +固定模式:用户保存策略时写入 `minSpreadValue`(如 30);触发时直接用该值与当前周期 \|close−open\| 比较,不拉历史 K 线。 + +```mermaid +sequenceDiagram + participant User as 用户 + participant API as Controller + participant Svc as CryptoTailStrategyService + participant DB as 数据库 + participant CLOB_WS as CLOB 订单簿 WS + participant Orderbook as CryptoTailOrderbookWsService + participant Exec as CryptoTailStrategyExecutionService + participant BinanceWS as BinanceKlineService + participant CLOB as Polymarket CLOB + + User->>API: 保存策略 minSpreadMode=FIXED, minSpreadValue=30 + API->>Svc: create/update + Svc->>DB: 写入 min_spread_mode, min_spread_value + Svc-->>API: 成功 + API-->>User: 成功 + + Note over BinanceWS: 后台持续:币安 K 线 WS 更新当前周期 (open, close) + + CLOB_WS->>Orderbook: onMessage(book/price_change) → bestBid + Orderbook->>Orderbook: 时间窗内?价格在 [min,max]?本周期未触发? + Orderbook->>Exec: tryTriggerWithPriceFromWs(strategy, periodStartUnix, ..., bestBid) + Exec->>Exec: mutex 锁 + Exec->>Exec: 本周期已触发?→ 是则 return + Exec->>Exec: passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex) + Exec->>Exec: mode==FIXED → effectiveMinSpread = strategy.minSpreadValue (30) + Exec->>BinanceWS: getCurrentOpenClose(intervalSeconds, periodStartUnix) + BinanceWS-->>Exec: (open, close) 来自内存 + Exec->>Exec: |close−open| >= 30 ? 否 → return,不下单 + Exec->>Exec: 是 → 通过价差校验 + Exec->>Exec: ensurePeriodContext → placeOrderForTrigger + Exec->>CLOB: 提交订单 + CLOB-->>Exec: orderId + Exec->>DB: 写入触发记录 (本周期已触发) +``` + +--- + +### 6.2 自动(AUTO)时序图 + +自动模式:不在保存策略时计算。**在该周期开始时就预计算**(订单簿 WS 刷新订阅时对该周期内 AUTO 策略异步拉 30 根历史 K 线并计算、缓存);触发时直接用缓存,同一周期内复用。 + +```mermaid +sequenceDiagram + participant User as 用户 + participant API as Controller + participant Svc as CryptoTailStrategyService + participant DB as 数据库 + participant CLOB_WS as CLOB 订单簿 WS + participant Orderbook as CryptoTailOrderbookWsService + participant Exec as CryptoTailStrategyExecutionService + participant BinanceWS as BinanceKlineService + participant AutoSpread as BinanceKlineAutoSpreadService + participant BinanceREST as 币安 REST API + participant CLOB as Polymarket CLOB + + User->>API: 保存策略 minSpreadMode=AUTO(不填 minSpreadValue) + API->>Svc: create/update + Svc->>DB: 写入 min_spread_mode=AUTO + Svc-->>API: 成功 + API-->>User: 成功 + + Note over BinanceWS: 后台持续:币安 K 线 WS 更新当前周期 (open, close) + + CLOB_WS->>Orderbook: onMessage → bestBid + Orderbook->>Orderbook: 时间窗 + 价格区间 + 本周期未触发 ✓ + Orderbook->>Exec: tryTriggerWithPriceFromWs(strategy, periodStartUnix, ..., bestBid) + Exec->>Exec: mutex 锁 + Exec->>Exec: passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex) + Exec->>BinanceWS: getCurrentOpenClose(intervalSeconds, periodStartUnix) + BinanceWS-->>Exec: (open, close) + Note over Orderbook,AutoSpread: 周期开始时 refreshAndSubscribe 已对该周期预计算(见下) + Exec->>AutoSpread: getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex) + AutoSpread->>AutoSpread: 查缓存 (intervalSeconds, periodStartUnix) → 命中(周期开始已预计算) + AutoSpread-->>Exec: effectiveMinSpread + Exec->>Exec: |close−open| >= effectiveMinSpread ? 否 → return + Exec->>Exec: 是 → 通过价差校验 + Exec->>Exec: placeOrderForTrigger → CLOB 下单 + Exec->>DB: 写入触发记录 + + Note over Orderbook,AutoSpread: 周期开始时(refreshAndSubscribe 或周期切换) + Orderbook->>Orderbook: refreshAndSubscribe() → buildSubscriptionMap() → newMap + Orderbook->>Orderbook: precomputeAutoMinSpreadForCurrentPeriods(newMap) + Orderbook->>AutoSpread: computeAndCache(intervalSeconds, periodStartUnix) [异步] + AutoSpread->>BinanceREST: GET /api/v3/klines?symbol=BTCUSDC&interval=15m&limit=30&endTime=periodStart*1000 + BinanceREST-->>AutoSpread: 30 根已收盘 K 线 + AutoSpread->>AutoSpread: 按 Up/Down 拆价差 → IQR 剔除 → 平均×0.8 → 缓存 + + Note over CLOB_WS,Exec: 同一周期内再次触发(如另一 outcome 或再次 bestBid) + CLOB_WS->>Orderbook: onMessage → bestBid + Orderbook->>Exec: tryTriggerWithPriceFromWs(...) + Exec->>AutoSpread: getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex) + AutoSpread->>AutoSpread: 查缓存 → 命中 + AutoSpread-->>Exec: effectiveMinSpread(不再调 REST) + Exec->>Exec: 价差校验 → 通过则下单(或本周期已触发则跳过) +``` + +--- + +## 七、流程小结(按执行顺序) + +1. **策略配置** + - 用户选择:无 / 固定(输入数值)/ 自动。 + - 固定:必填 minSpreadValue,保存到 DB。 + - 自动:不填 minSpreadValue,**不在保存时计算**;按周期在首次需要时计算并缓存。 + +2. **运行时** + - 币安 WS 持续更新当前周期的 (open, close)。 + - CLOB 订单簿(或 HTTP)带来 bestBid;若时间窗 + 价格区间 + 本周期未触发 均满足: + - 若 minSpreadMode == NONE → 直接 placeOrderForTrigger。 + - 否则取当前周期 open/close 与 effectiveMinSpread(固定值或自动缓存值),若 \|close−open\| >= effectiveMinSpread → placeOrderForTrigger;否则本轮不下单,等后续推送再判。 + +3. **下单与去重** + - 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。 + +按上述流程即可在现有尾盘策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。 diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 3b33065..4bf4d5b 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -1445,7 +1445,14 @@ "create": "Create", "update": "Update", "timeWindowStartLEEnd": "Window start must not be greater than end", - "timeWindowExceed": "Time window must not exceed period length" + "timeWindowExceed": "Time window must not exceed period length", + "minSpreadMode": "Min spread", + "minSpreadModeTip": "Whether to place an order is based on the spread between open and close in the current period. Auto: system computes a suggested spread from the last 30 klines (updated each period); Fixed: you enter a value (e.g. 30), order only when spread ≥ that value; None: no spread check, order when price is in range.", + "minSpreadModeNone": "None", + "minSpreadModeFixed": "Fixed", + "minSpreadModeAuto": "Auto", + "minSpreadValue": "Min spread value (USDC)", + "minSpreadValuePlaceholder": "e.g. 30" }, "redeemRequiredModal": { "title": "Configure Auto Redeem First", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 5ac5aa6..060c9f9 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1444,7 +1444,14 @@ "create": "创建", "update": "更新", "timeWindowStartLEEnd": "时间区间开始不能大于结束", - "timeWindowExceed": "时间区间不能超过周期长度" + "timeWindowExceed": "时间区间不能超过周期长度", + "minSpreadMode": "最小价差", + "minSpreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 30 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30),仅当价差 ≥ 该值时才下单;无:不校验价差,满足价格区间即下单。", + "minSpreadModeNone": "无", + "minSpreadModeFixed": "固定", + "minSpreadModeAuto": "自动", + "minSpreadValue": "最小价差数值 (USDC)", + "minSpreadValuePlaceholder": "如 30" }, "redeemRequiredModal": { "title": "请先配置自动赎回", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index bcf95ce..a5c640a 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1445,7 +1445,14 @@ "create": "創建", "update": "更新", "timeWindowStartLEEnd": "時間區間開始不能大於結束", - "timeWindowExceed": "時間區間不能超過週期長度" + "timeWindowExceed": "時間區間不能超過週期長度", + "minSpreadMode": "最小價差", + "minSpreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 30 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30),僅當價差 ≥ 該值時才下單;無:不校驗價差,滿足價格區間即下單。", + "minSpreadModeNone": "無", + "minSpreadModeFixed": "固定", + "minSpreadModeAuto": "自動", + "minSpreadValue": "最小價差數值 (USDC)", + "minSpreadValuePlaceholder": "如 30" }, "redeemRequiredModal": { "title": "請先配置自動贖回", diff --git a/frontend/src/pages/CryptoTailStrategyList.tsx b/frontend/src/pages/CryptoTailStrategyList.tsx index 3549a8f..fa00653 100644 --- a/frontend/src/pages/CryptoTailStrategyList.tsx +++ b/frontend/src/pages/CryptoTailStrategyList.tsx @@ -16,9 +16,10 @@ import { Input, InputNumber, Radio, - Spin + Spin, + Tooltip } from 'antd' -import { PlusOutlined, EditOutlined, UnorderedListOutlined } from '@ant-design/icons' +import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useMediaQuery } from 'react-responsive' import { apiService } from '../services/api' @@ -107,6 +108,7 @@ const CryptoTailStrategyList: React.FC = () => { enabled: true, amountMode: 'RATIO', maxPrice: '1', + minSpreadMode: 'AUTO', windowStartMinutes: 0, windowStartSeconds: 0 }) @@ -127,6 +129,8 @@ const CryptoTailStrategyList: React.FC = () => { maxPrice: record.maxPrice, amountMode: record.amountMode, amountValue: record.amountValue, + minSpreadMode: record.minSpreadMode ?? 'AUTO', + minSpreadValue: record.minSpreadValue ?? undefined, enabled: record.enabled }) setFormModalOpen(true) @@ -159,6 +163,8 @@ const CryptoTailStrategyList: React.FC = () => { maxPrice: v.maxPrice != null ? String(v.maxPrice) : undefined, amountMode: v.amountMode as string, amountValue: String(v.amountValue ?? 0), + minSpreadMode: (v.minSpreadMode as string) || 'AUTO', + minSpreadValue: v.minSpreadMode === 'FIXED' && v.minSpreadValue != null ? String(v.minSpreadValue) : (v.minSpreadMode === 'AUTO' && v.minSpreadValue != null ? String(v.minSpreadValue) : undefined), enabled: v.enabled !== false } if (editingId) { @@ -171,6 +177,8 @@ const CryptoTailStrategyList: React.FC = () => { maxPrice: payload.maxPrice, amountMode: payload.amountMode, amountValue: payload.amountValue, + minSpreadMode: payload.minSpreadMode, + minSpreadValue: payload.minSpreadValue, enabled: payload.enabled }) if (res.data.code === 0) { @@ -181,7 +189,10 @@ const CryptoTailStrategyList: React.FC = () => { message.error(res.data.msg || t('common.failed')) } } else { - const res = await apiService.cryptoTailStrategy.create(payload) + const res = await apiService.cryptoTailStrategy.create({ + ...payload, + minSpreadValue: payload.minSpreadMode === 'FIXED' ? payload.minSpreadValue : undefined + }) if (res.data.code === 0) { message.success(t('common.success')) setFormModalOpen(false) @@ -532,7 +543,7 @@ const CryptoTailStrategyList: React.FC = () => { destroyOnClose > -
+