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:
@@ -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>>>
|
||||
}
|
||||
+29
@@ -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,
|
||||
|
||||
|
||||
+94
@@ -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)
|
||||
}
|
||||
}
|
||||
+128
@@ -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
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+23
-1
@@ -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,
|
||||
|
||||
+23
@@ -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 时可存计算值)';
|
||||
Reference in New Issue
Block a user