From 5cdcc487d4d183e20744c8177e27a80aec8572ce Mon Sep 17 00:00:00 2001 From: WrBug Date: Sat, 14 Feb 2026 20:48:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(cryptotail):=20AUTO=20=E6=9C=80=E5=B0=8F?= =?UTF-8?q?=E4=BB=B7=E5=B7=AE=20100%=E2=86=9250%=20=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E7=B3=BB=E6=95=B0=EF=BC=8Cprogress=20=E6=8C=89=E6=AF=AB?= =?UTF-8?q?=E7=A7=92=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BinanceKlineAutoSpreadService: 缓存 100% 基准价差,新增 getAutoMinSpreadBase - CryptoTailStrategyExecutionService: 按窗口内毫秒进度算 coefficient,effectiveMinSpread = baseSpread × (1 - 0.5×progress) - 新增方案文档 docs/crypto-tail-auto-spread-dynamic-coefficient.md Co-authored-by: Cursor --- .../binance/BinanceKlineAutoSpreadService.kt | 25 ++-- .../CryptoTailStrategyExecutionService.kt | 28 +++- ...to-tail-auto-spread-dynamic-coefficient.md | 131 ++++++++++++++++++ 3 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 docs/crypto-tail-auto-spread-dynamic-coefficient.md 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 index 63b78ba..23e6a2b 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/binance/BinanceKlineAutoSpreadService.kt @@ -9,8 +9,8 @@ import java.math.RoundingMode import java.util.concurrent.ConcurrentHashMap /** - * 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均 × 0.7,缓存 (interval, period)。 - * 不在保存策略时计算。 + * 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (interval, period)。 + * 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。 */ @Service class BinanceKlineAutoSpreadService( @@ -21,15 +21,15 @@ class BinanceKlineAutoSpreadService( private val symbol = "BTCUSDC" private val historyLimit = 20 - private val autoSpreadCoefficient = BigDecimal("0.7") private val minSamplesAfterIqr = 3 - /** (intervalSeconds, periodStartUnix) -> (minSpreadUp, minSpreadDown) */ + /** (intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */ private val cache = ConcurrentHashMap>() private fun cacheKey(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix" - fun getAutoMinSpread(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? { + /** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */ + fun getAutoMinSpreadBase(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? { val key = cacheKey(intervalSeconds, periodStartUnix) val (up, down) = cache[key] ?: run { computeAndCache(intervalSeconds, periodStartUnix) ?: return null @@ -37,6 +37,7 @@ class BinanceKlineAutoSpreadService( return if (outcomeIndex == 0) up else down } + /** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */ fun computeAndCache(intervalSeconds: Int, periodStartUnix: Long): Pair? { val intervalStr = if (intervalSeconds == 300) "5m" else "15m" val endTimeMs = periodStartUnix * 1000L @@ -50,15 +51,15 @@ class BinanceKlineAutoSpreadService( 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 + val baseUp = averageAfterIqr(spreadsUp).setScale(8, RoundingMode.HALF_UP) + val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP) + cache[cacheKey(intervalSeconds, periodStartUnix)] = baseUp to baseDown logger.info( - "尾盘自动价差已计算并缓存(按周期): interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " + - "Up方向: 样本数=${spreadsUp.size}, minSpreadUp=${avgUp.toPlainString()} | " + - "Down方向: 样本数=${spreadsDown.size}, minSpreadDown=${avgDown.toPlainString()}" + "尾盘自动价差已计算并缓存(100%基准): interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " + + "Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " + + "Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}" ) - return avgUp to avgDown + return baseUp to baseDown } private fun fetchKlines(interval: String, limit: Int, endTime: Long? = null): List>? { 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 395d1f5..ad4b8e9 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 @@ -16,7 +16,9 @@ import com.wrbug.polymarketbot.service.common.PolymarketClobService import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService import com.wrbug.polymarketbot.util.CryptoUtils import com.wrbug.polymarketbot.util.RetrofitFactory +import com.wrbug.polymarketbot.util.div import com.wrbug.polymarketbot.util.fromJson +import com.wrbug.polymarketbot.util.multi import com.wrbug.polymarketbot.util.toSafeBigDecimal import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -188,14 +190,36 @@ class CryptoTailStrategyExecutionService( 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 } + "AUTO" -> computeAutoEffectiveMinSpread(strategy, periodStartUnix, outcomeIndex) else -> null } if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true return spreadAbs >= effectiveMinSpread } + /** + * AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效最小价差。 + */ + private fun computeAutoEffectiveMinSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? { + val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.intervalSeconds, periodStartUnix, outcomeIndex) + ?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second } + ?: return null + if (baseSpread <= BigDecimal.ZERO) return null + val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L + val windowEndMs = (periodStartUnix + strategy.windowEndSeconds) * 1000L + val windowLenMs = windowEndMs - windowStartMs + val coefficient = if (windowLenMs <= 0) { + BigDecimal.ONE + } else { + val nowMs = System.currentTimeMillis() + val elapsedMs = (nowMs - windowStartMs).toBigDecimal() + val progress = elapsedMs.div(windowLenMs.toBigDecimal(), 18, RoundingMode.HALF_UP) + .let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) } + BigDecimal.ONE.subtract(progress.multi("0.5")) + } + return baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP) + } + private suspend fun placeOrderForTrigger( strategy: CryptoTailStrategy, periodStartUnix: Long, diff --git a/docs/crypto-tail-auto-spread-dynamic-coefficient.md b/docs/crypto-tail-auto-spread-dynamic-coefficient.md new file mode 100644 index 0000000..f9a1f30 --- /dev/null +++ b/docs/crypto-tail-auto-spread-dynamic-coefficient.md @@ -0,0 +1,131 @@ +# AUTO 最小价差:100%→50% 动态系数方案 + +## 现状 + +- **BinanceKlineAutoSpreadService**:拉取历史 K 线 → IQR 剔除异常值 → 求平均得到「基础价差」→ **固定 ×0.7** 后缓存。 +- 预加载(周期开始时):`computeAndCache()` 计算并缓存的是 **已乘 0.7** 的值。 +- 触发时:`getAutoMinSpread()` 直接返回缓存值,等价于始终用 **70%** 的系数。 + +问题:70% 固定,无法随周期内时间变化放宽或收紧。 + +--- + +## 目标 + +1. **预加载提供 100% 数值**:缓存里存「基础价差」(IQR 平均),不再乘 0.7,即预加载 = 100% 基准。 +2. **系数随区间时间点动态递减**:从 **100%** 线性递减到 **50%**,根据「当前时间在区间内的进度」计算。 + +--- + +## 方案一:按「触发窗口」进度(推荐) + +**区间**:策略的触发窗口 `[periodStartUnix + windowStartSeconds, periodStartUnix + windowEndSeconds]`。 + +- 窗口起始:系数 = **100%**(最严,价差要求最高)。 +- 窗口内时间越靠后,系数越小;窗口结束:系数 = **50%**(最松,更容易触发)。 + +公式(**progress 按毫秒计算**,保证精度): + +``` +windowStartMs = (periodStartUnix + windowStartSeconds) * 1000 +windowEndMs = (periodStartUnix + windowEndSeconds) * 1000 +windowLenMs = windowEndMs - windowStartMs +nowMs = System.currentTimeMillis() + +progress = (nowMs - windowStartMs) / windowLenMs +progress = clamp(progress, 0, 1) + +// 比例系数 = progress × (100% - 50%),即已「消耗」的系数降幅 +// 真正系数 = 100% - 比例系数 +coefficient = 1.0 - progress × (1.0 - 0.5) = 1.0 - 0.5 × progress + +effectiveMinSpread = baseSpread × coefficient +``` + +**计算示例**(时间区间 14分0秒~15分0秒,窗口 60 秒 = 60000 ms): + +| 时刻 | 进入窗口的毫秒数 | progress(按毫秒) | 比例系数 | 真正系数 | +|------------|------------------|--------------------|--------------------|------------| +| 14:00 | 0 | 0/60000 = 0% | 0% × 50% = 0% | 100% | +| 14:15 | 15000 | 15000/60000 = 25% | 25% × 50% = 12.5% | **87.5%** | +| 14:30 | 30000 | 30000/60000 = 50% | 50% × 50% = 25% | 75% | +| 15:00 | 60000 | 60000/60000 = 100% | 100% × 50% = 50% | 50% | + +即:在 14分15秒 时,progress = 15000ms / 60000ms = 25%,比例系数 = 12.5%,真正系数 = **87.5%**。实现时统一用毫秒计算 progress,避免秒级舍入误差。 + +- 需要策略的 `windowStartSeconds`、`windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。 + +**优点**:与「尾盘只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。 +**缺点**:`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。 + +--- + +## 方案二:按「整周期」进度 + +**区间**:整个周期 `[periodStartUnix, periodStartUnix + intervalSeconds]`。**progress 按毫秒计算**。 + +``` +periodStartMs = periodStartUnix * 1000 +periodEndMs = (periodStartUnix + intervalSeconds) * 1000 +periodLenMs = intervalSeconds * 1000L +nowMs = System.currentTimeMillis() + +progress = (nowMs - periodStartMs) / periodLenMs +progress = clamp(progress, 0, 1) + +coefficient = 1.0 - 0.5 * progress +effectiveMinSpread = baseSpread × coefficient +``` + +**优点**:只依赖 `intervalSeconds`、`periodStartUnix`、`nowSeconds`,不依赖窗口配置。 +**缺点**:若窗口只占周期后半段,周期前半段也会在算系数,语义上不如按窗口精确。 + +--- + +## 实现要点 + +### 1. 缓存 100% 基准值 + +- **BinanceKlineAutoSpreadService**: + - `computeAndCache()`:缓存 **不乘 0.7** 的 (avgUp, avgDown),即 IQR 平均后的原始值(100% 基准)。 + - 可保留方法名与入参不变,仅去掉 `autoSpreadCoefficient` 的乘法;或新增 `getBaseSpread()` 语义,内部仍用同一缓存。 + +### 2. 动态系数计算位置 + +- 系数依赖「当前时间」和「区间定义」,适合在 **触发校验处** 算,而不是在 AutoSpread 服务里写死。 +- **CryptoTailStrategyExecutionService.passMinSpreadCheck()**: + - 当前:`getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)` 得到已乘系数的值。 + - 改为: + - 取「基础价差」:`getAutoMinSpreadBase(intervalSeconds, periodStartUnix, outcomeIndex)` 或由现有缓存返回 100% 值。 + - 在 `passMinSpreadCheck` 内根据 `strategy.windowStartSeconds/windowEndSeconds` 和 `System.currentTimeMillis()`(毫秒)算 `progress`(按毫秒)→ `coefficient` → `effectiveMinSpread = baseSpread × coefficient`。 + +### 3. 接口形态建议 + +- **BinanceKlineAutoSpreadService**: + - `computeAndCache(interval, periodStartUnix)`:只缓存 100% 基准 (baseUp, baseDown),不再乘 0.7。 + - `getAutoMinSpreadBase(interval, periodStartUnix, outcomeIndex): BigDecimal?`:仅返回缓存的基础价差;若需兼容旧名,可保留 `getAutoMinSpread` 但增加可选参数 `coefficient`,默认 1.0。 +- **CryptoTailStrategyExecutionService**: + - 在 `passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)` 内: + - 取 `baseSpread = getAutoMinSpreadBase(...)`。 + - 计算 `progress`(按方案一用 windowStart/End,或方案二用 interval)。 + - `coefficient = 1.0 - 0.5 * progress`,再 `effectiveMinSpread = baseSpread * coefficient` 做比较。 + +### 4. 边界与兼容 + +- 窗口长度为 0:可约定 `coefficient = 0.5` 或 1.0,避免除零。 +- 已有策略未配置窗口(全 0):若用方案一,可退化为「整周期」或固定 0.5/1.0」。 +- 预加载逻辑(如 CryptoTailOrderbookWsService 的 `precomputeAutoMinSpreadForCurrentPeriods`)无需改,仍调用 `computeAndCache`,只是缓存内容变为 100% 基准。 + +--- + +## 小结 + +| 项目 | 内容 | +|------------|------| +| 预加载 | 缓存 100% 基础价差(去掉固定 0.7) | +| 系数范围 | 100% → 50% 线性递减 | +| 推荐区间 | 按触发窗口 `windowStartSeconds`~`windowEndSeconds` 计算进度(方案一) | +| progress | **按毫秒计算**:`(nowMs - windowStartMs) / windowLenMs`,避免秒级舍入误差 | +| 计算位置 | 触发时在 `passMinSpreadCheck` 中算 progress → coefficient → effectiveMinSpread | + +按上述实现后,AUTO 模式即为「预加载提供 100% 数值 + 随区间时间点从 100% 递减到 50%」的动态方案。