feat(crypto-tail): 策略最小价差(无/固定/自动) + 前端默认与文案

- 后端: 最小价差 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 <cursoragent@cursor.com>
This commit is contained in:
WrBug
2026-02-14 15:16:44 +08:00
parent 7ec9311df2
commit b50e43c239
22 changed files with 930 additions and 14 deletions
@@ -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<List<List<Any>>>
}
@@ -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<String, Any>): ResponseEntity<ApiResponse<CryptoTailAutoMinSpreadResponse>> {
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))
}
}
}
@@ -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 分钟市场项(供前端选择市场)
*/
@@ -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,
@@ -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<String, Pair<BigDecimal, BigDecimal>>()
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<BigDecimal, BigDecimal>? {
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
val endTimeMs = periodStartUnix * 1000L
val klines = fetchKlines(intervalStr, historyLimit, endTime = endTimeMs) ?: return null
val spreadsUp = mutableListOf<BigDecimal>()
val spreadsDown = mutableListOf<BigDecimal>()
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<List<Any>>? {
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>): 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)
}
}
@@ -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<String, Pair<BigDecimal, BigDecimal>>()
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<BigDecimal, BigDecimal>? {
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<Long, BigDecimal, BigDecimal>? {
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
}
}
@@ -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<String, List<WsBookEntry>>) {
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}")
}
}
}
}
/**
@@ -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,
@@ -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(),
@@ -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 缓存,避免重复创建
@@ -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 时可存计算值)';
@@ -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 组参与平均。
- **边界与降级**
- 若剔除后剩余样本数过少(如 &lt; 3),则**不剔除**:用全部价差样本求平均 × 0.8。
- 若无满足方向的 K 线(如 30 根里没有 close &lt; open),仍按原文档降级处理(全量 \|close−open\| 或返回 0)。
---
## 三、整体流程(含价差校验)
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 1. 数据源与订阅 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ • CLOB 订单簿 WS(现有):Polymarket 订单簿 → bestBid。 │
│ • 币安 K 线 WS(新增):订阅 BTCUSDC 对应周期(5m/15m),维护「当前周期」的开盘价 │
│ open、实时收盘价 close(每根 K 线未收盘前 close 会持续更新)。 │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 2. 触发入口(与现有一致) │
├─────────────────────────────────────────────────────────────────────────────────┤
│ • 入口 ACryptoTailOrderbookWsService.onBestBid(tokenId, bestBid) │
│ • 入口 BCryptoTailStrategyExecutionService.runCycle()HTTP 拉订单簿) │
│ 两者在「时间窗 + 价格区间 + 本周期未触发」通过后,都会调用执行层「尝试下单」。 │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 3. 执行层增加「价差校验」 │
├─────────────────────────────────────────────────────────────────────────────────┤
│ 在现有 tryTriggerWithPriceFromWs / runCycle → placeOrderForTrigger 之前增加: │
│ │
│ if (策略.minSpreadMode == NONE) → 直接进入 placeOrderForTrigger。 │
│ else: │
│ • 从「币安 K 线服务」取当前周期(与 strategy.intervalSeconds 对齐)的 open、 │
│ close(实时)。 │
│ • 若取不到 open/close(例如该周期尚未有数据)→ 本轮不下单,等待下次 WS 更新。 │
│ • 计算 effectiveMinSpread
│ - FIXEDeffectiveMinSpread = 策略.minSpreadValue(用户填的固定值) │
│ - AUTOeffectiveMinSpread = 按当前下单方向(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, ...]。
- **DownoutcomeIndex=1**:筛选 close < open,价差 = open close,得到价差序列 → **异常值剔除(IQR** → 对剩余价差求平均,再 × 0.8 → minSpreadDown。
- **UpoutcomeIndex=0**:筛选 close > open,价差 = close open,得到价差序列 → **异常值剔除(IQR** → 对剩余价差求平均,再 × 0.8 → minSpreadUp。
- **异常值剔除**:见上文「异常值剔除」;剔除后再平均。若剔除后剩余样本 &lt; 3,则不剔除,用全部价差样本求平均。
- 若无满足方向的 K 线(例如 30 根里没有一根 close < open),可降级:用全部 30 根的 |close−open| 平均 × 0.8,或返回 0/不校验,具体产品可定。
- **触发时使用**
- 当前要下单的是 outcomeIndex0=Up, 1=Down),取对应的 minSpreadUp 或 minSpreadDown 作为 effectiveMinSpread,再与 |close open| 比较。
---
## 五、后端模块与数据流
| 模块 | 职责 |
|------|------|
| **BinanceKlineService(新)** | 1)订阅币安 WSBTCUSDC 的 5m、15m K 线流(可按需只订阅有策略使用的周期)。<br>2)维护「当前周期」数据:以 periodStartUnix(或 K 线 t 对齐)为 key,存 (open, close)K 线 WS 推送时更新 close,新周期首条推送时更新 open。<br>3)提供 getCurrentOpenClose(symbol, intervalSeconds, periodStartUnix) → (open, close)?,供执行层价差校验使用。 |
| **BinanceKlineAutoSpreadService 或合入上者(新)** | 1)按**周期**拉取:以 periodStartUnix 为界,REST 拉取该周期前的 30 根已收盘 K 线。<br>2)按 Up/Down 得到价差序列 → **IQR 异常值剔除** → 对剩余价差求平均 × 0.8,缓存 (intervalSeconds, periodStartUnix) → (minSpreadUp, minSpreadDown)。<br>3)提供 getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex) 与 computeAndCache(intervalSeconds, periodStartUnix)。**周期开始时**由 CryptoTailOrderbookWsService 在 refreshAndSubscribe 后对当前周期内 AUTO 策略预调 computeAndCache;触发时直接用缓存,未命中时再按需计算。 |
| **CryptoTailStrategy(实体)** | 新增字段建议:minSpreadModeNONE/FIXED/AUTO)、minSpreadValue(固定时使用;AUTO 时可为空或存上次计算值用于展示)。 |
| **CryptoTailStrategyExecutionService(现有)** | 在 tryTriggerWithPriceFromWs 与 runCycle 分支中,在调用 placeOrderForTrigger 前:若 minSpreadMode != NONE,则取 open/close 与 effectiveMinSpread,校验 \|closeopen\| >= 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: |closeopen| >= 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: |closeopen| >= 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(固定值或自动缓存值),若 \|closeopen\| >= effectiveMinSpread → placeOrderForTrigger;否则本轮不下单,等后续推送再判。
3. **下单与去重**
- 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。
按上述流程即可在现有尾盘策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -1444,7 +1444,14 @@
"create": "创建",
"update": "更新",
"timeWindowStartLEEnd": "时间区间开始不能大于结束",
"timeWindowExceed": "时间区间不能超过周期长度"
"timeWindowExceed": "时间区间不能超过周期长度",
"minSpreadMode": "最小价差",
"minSpreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 30 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30),仅当价差 ≥ 该值时才下单;无:不校验价差,满足价格区间即下单。",
"minSpreadModeNone": "无",
"minSpreadModeFixed": "固定",
"minSpreadModeAuto": "自动",
"minSpreadValue": "最小价差数值 (USDC)",
"minSpreadValuePlaceholder": "如 30"
},
"redeemRequiredModal": {
"title": "请先配置自动赎回",
+8 -1
View File
@@ -1445,7 +1445,14 @@
"create": "創建",
"update": "更新",
"timeWindowStartLEEnd": "時間區間開始不能大於結束",
"timeWindowExceed": "時間區間不能超過週期長度"
"timeWindowExceed": "時間區間不能超過週期長度",
"minSpreadMode": "最小價差",
"minSpreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 30 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30),僅當價差 ≥ 該值時才下單;無:不校驗價差,滿足價格區間即下單。",
"minSpreadModeNone": "無",
"minSpreadModeFixed": "固定",
"minSpreadModeAuto": "自動",
"minSpreadValue": "最小價差數值 (USDC)",
"minSpreadValuePlaceholder": "如 30"
},
"redeemRequiredModal": {
"title": "請先配置自動贖回",
+54 -4
View File
@@ -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
>
<Alert type="warning" showIcon message={t('cryptoTailStrategy.form.walletTip')} style={{ marginBottom: 16 }} />
<Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', enabled: true }}>
<Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', minSpreadMode: 'AUTO', enabled: true }}>
<Form.Item name="accountId" label={t('cryptoTailStrategy.form.selectAccount')} rules={[{ required: true }]}>
<Select
placeholder={t('cryptoTailStrategy.form.selectAccount')}
@@ -624,6 +635,45 @@ const CryptoTailStrategyList: React.FC = () => {
)
}
</Form.Item>
<Form.Item
name="minSpreadMode"
label={
<Space size={4}>
<span>{t('cryptoTailStrategy.form.minSpreadMode')}</span>
<Tooltip title={t('cryptoTailStrategy.form.minSpreadModeTip')}>
<InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} />
</Tooltip>
</Space>
}
>
<Radio.Group>
<Radio value="AUTO">{t('cryptoTailStrategy.form.minSpreadModeAuto')}</Radio>
<Radio value="FIXED">{t('cryptoTailStrategy.form.minSpreadModeFixed')}</Radio>
<Radio value="NONE">{t('cryptoTailStrategy.form.minSpreadModeNone')}</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.minSpreadMode !== curr.minSpreadMode}
>
{({ getFieldValue }) =>
getFieldValue('minSpreadMode') === 'FIXED' ? (
<Form.Item
name="minSpreadValue"
label={t('cryptoTailStrategy.form.minSpreadValue')}
rules={[{ required: true }]}
>
<InputNumber
min={0}
step={1}
placeholder={t('cryptoTailStrategy.form.minSpreadValuePlaceholder')}
style={{ width: '100%' }}
stringMode
/>
</Form.Item>
) : null
}
</Form.Item>
<Form.Item name="enabled" valuePropName="checked">
<Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} />
</Form.Item>
+7 -1
View File
@@ -447,6 +447,8 @@ export const apiService = {
maxPrice?: string
amountMode: string
amountValue: string
minSpreadMode?: string
minSpreadValue?: string | null
enabled?: boolean
}) =>
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data),
@@ -459,6 +461,8 @@ export const apiService = {
maxPrice?: string
amountMode?: string
amountValue?: string
minSpreadMode?: string
minSpreadValue?: string | null
enabled?: boolean
}) =>
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
@@ -467,7 +471,9 @@ export const apiService = {
triggers: (data: { strategyId: number; page?: number; pageSize?: number; status?: string }) =>
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
marketOptions: () =>
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {})
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
autoMinSpread: (data: { intervalSeconds: number }) =>
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data)
},
/**
+10
View File
@@ -1051,6 +1051,10 @@ export interface CryptoTailStrategyDto {
maxPrice: string
amountMode: string
amountValue: string
/** 最小价差模式: NONE, FIXED, AUTO */
minSpreadMode?: string
/** 最小价差数值(FIXED 时必填;AUTO 时可为计算值) */
minSpreadValue?: string | null
enabled: boolean
lastTriggerAt?: number
/** 已实现总收益 USDC */
@@ -1063,6 +1067,12 @@ export interface CryptoTailStrategyDto {
updatedAt: number
}
/** 自动最小价差计算响应 */
export interface CryptoTailAutoMinSpreadResponse {
minSpreadUp: string
minSpreadDown: string
}
/**
* 尾盘策略触发记录
*/
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
从币安现货 API 获取 BTC/USDC 15 分钟 K 线数据。
API: https://api.binance.com/api/v3/klines
无需 API Key,公开行情接口。
使用方法:
python3 scripts/fetch_binance_btc_usdc_klines.py
python3 scripts/fetch_binance_btc_usdc_klines.py --limit 96
python3 scripts/fetch_binance_btc_usdc_klines.py --limit 10 --interval 15m
"""
import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BINANCE_BASE = "https://api.binance.com"
def fetch_klines(
symbol: str = "BTCUSDC",
interval: str = "15m",
limit: int = 500,
start_time: int | None = None,
end_time: int | None = None,
) -> list[list] | None:
"""
获取 K 线数据。
返回每根 K 线: [ openTime, open, high, low, close, volume, closeTime, ... ]
"""
params = {"symbol": symbol, "interval": interval, "limit": limit}
if start_time is not None:
params["startTime"] = start_time
if end_time is not None:
params["endTime"] = end_time
qs = urllib.parse.urlencode(params)
url = f"{BINANCE_BASE}/api/v3/klines?{qs}"
req = urllib.request.Request(url, headers={"User-Agent": "PolymarketBot/1.0 (script)"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
body = e.read().decode() if e.fp else ""
try:
err = json.loads(body)
except json.JSONDecodeError:
err = {"msg": body}
print(f"Request failed: {e.code} - {err}", file=sys.stderr)
return None
except Exception as e:
print(f"Request error: {e}", file=sys.stderr)
return None
def main():
parser = argparse.ArgumentParser(description="Fetch Binance BTC/USDC 15m klines")
parser.add_argument("--symbol", default="BTCUSDC", help="Trading pair (default: BTCUSDC)")
parser.add_argument("--interval", default="15m", help="Kline interval (default: 15m)")
parser.add_argument("--limit", type=int, default=20, help="Number of klines (default: 20, max 1000)")
parser.add_argument("--start", type=int, default=None, help="Start time (ms)")
parser.add_argument("--end", type=int, default=None, help="End time (ms)")
args = parser.parse_args()
limit = max(1, min(1000, args.limit))
print("=== Binance BTC/USDC K-line (15m) ===\n")
print(f"Symbol: {args.symbol} Interval: {args.interval} Limit: {limit}")
if args.start:
print(f"Start: {args.start} ({time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(args.start // 1000))})")
if args.end:
print(f"End: {args.end} ({time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(args.end // 1000))})")
print()
klines = fetch_klines(
symbol=args.symbol,
interval=args.interval,
limit=limit,
start_time=args.start,
end_time=args.end,
)
if not klines:
print("No kline data returned")
sys.exit(1)
print(f"Got {len(klines)} kline(s)\n")
print("Columns: openTime, open, high, low, close, volume, closeTime, ...")
print("-" * 72)
for k in klines:
open_ts_ms = k[0]
open_ts = open_ts_ms // 1000
ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(open_ts))
o, h, l, c, v = k[1], k[2], k[3], k[4], k[5]
print(f" {ts_str} O:{o} H:{h} L:{l} C:{c} V:{v}")
print("-" * 72)
last = klines[-1]
print(f"Latest: open={last[1]}, high={last[2]}, low={last[3]}, close={last[4]}, volume={last[5]}")
if __name__ == "__main__":
main()
+2 -1
View File
@@ -9,7 +9,8 @@
"version": "1.0.0",
"dependencies": {
"@ethersproject/wallet": "^5.7.0",
"@polymarket/clob-client": "^5.2.1"
"@polymarket/clob-client": "^5.2.1",
"ws": "^8.18.0"
}
},
"node_modules/@adraffy/ens-normalize": {
+4 -2
View File
@@ -5,10 +5,12 @@
"type": "module",
"scripts": {
"get-order-detail": "node get-order-detail.js",
"verify-backtest-data": "node verify-backtest-data.js"
"verify-backtest-data": "node verify-backtest-data.js",
"ws-binance-klines": "node ws_binance_btc_usdc_klines.js"
},
"dependencies": {
"@ethersproject/wallet": "^5.7.0",
"@polymarket/clob-client": "^5.2.1"
"@polymarket/clob-client": "^5.2.1",
"ws": "^8.18.0"
}
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* 通过币安 WebSocket 订阅 BTC/USDC 15 分钟 K 线推送
*
* 文档: https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams
* - 现货 K 线流: wss://stream.binance.com:9443/ws/btcusdc@kline_15m
* - 服务端约每 20 秒发 pingws 库会自动回 pong
* - K 线推送频率: 15m 约每 2 秒更新一次x=true 表示该根 K 线已收盘
*
* 依赖: npm install ws scripts 目录下执行
*
* 使用方法:
* node scripts/ws_binance_btc_usdc_klines.js
* node scripts/ws_binance_btc_usdc_klines.js --interval 1m
* node scripts/ws_binance_btc_usdc_klines.js --url "wss://stream.binance.com:9443/ws/btcusdc@kline_15m"
* Ctrl+C 退出
*/
import WebSocket from 'ws';
const BINANCE_WS_BASE = 'wss://stream.binance.com:9443';
function parseArgs() {
const args = process.argv.slice(2);
const out = { symbol: 'btcusdc', interval: '15m', url: null };
for (let i = 0; i < args.length; i++) {
if (args[i] === '--symbol' && args[i + 1]) {
out.symbol = String(args[i + 1]).toLowerCase();
i++;
} else if (args[i] === '--interval' && args[i + 1]) {
out.interval = args[i + 1];
i++;
} else if (args[i] === '--url' && args[i + 1]) {
out.url = args[i + 1];
i++;
}
}
return out;
}
function formatKline(msg) {
if (msg.e !== 'kline') {
return JSON.stringify(msg);
}
const k = msg.k || {};
const tMs = Number(k.t) || 0;
const tsStr = new Date(tMs).toISOString().replace('T', ' ').slice(0, 19);
const closed = k.x ? ' [CLOSED]' : '';
return ` ${tsStr} O:${k.o} H:${k.h} L:${k.l} C:${k.c} V:${k.v}${closed}`;
}
function run(wsUrl) {
console.log(`Connecting: ${wsUrl}`);
console.log('(Ctrl+C to exit)\n');
const ws = new WebSocket(wsUrl);
ws.on('open', () => {
// ws 库收到 ping 会自动回 pong,无需手动处理
});
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.result !== undefined && msg.id !== undefined) return;
if (msg.code !== undefined && msg.code !== 0) {
console.error('Error:', msg);
return;
}
console.log(formatKline(msg));
} catch {
console.log(data.toString());
}
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
process.exit(1);
});
ws.on('close', (code, reason) => {
if (code !== 1000) {
console.error(`Connection closed: ${code} ${reason?.toString() || ''}`);
process.exit(1);
}
});
}
const args = parseArgs();
const wsUrl = args.url || `${BINANCE_WS_BASE}/ws/${args.symbol}@kline_${args.interval}`;
run(wsUrl);