Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3196a783f | |||
| 4c989a48c4 | |||
| 5bb46ebb97 | |||
| cbcebf6e28 | |||
| 2013a2eb70 | |||
| de89175c8e | |||
| 4e1bb0bbcf | |||
| 24a4487d0b | |||
| 24bb7bed40 | |||
| 6b56f62532 | |||
| c38529546f | |||
| 08e1219f77 | |||
| 62842f5aa8 | |||
| 13662dd0d5 | |||
| 07c3548401 | |||
| 364db124d1 |
@@ -92,7 +92,7 @@ interface BuilderRelayerApi {
|
||||
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
|
||||
|
||||
@SerializedName("nonce")
|
||||
val nonce: String, // Safe nonce(字符串)
|
||||
val nonce: String? = null, // Safe nonce(SAFE 必填,SAFE-CREATE 不传)
|
||||
|
||||
@SerializedName("signature")
|
||||
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
|
||||
@@ -138,7 +138,17 @@ interface BuilderRelayerApi {
|
||||
val relayHub: String? = null,
|
||||
|
||||
@SerializedName("relay")
|
||||
val relay: String? = null
|
||||
val relay: String? = null,
|
||||
|
||||
/** SAFE-CREATE 签名参数 */
|
||||
@SerializedName("paymentToken")
|
||||
val paymentToken: String? = null,
|
||||
|
||||
@SerializedName("payment")
|
||||
val payment: String? = null,
|
||||
|
||||
@SerializedName("paymentReceiver")
|
||||
val paymentReceiver: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,5 +44,14 @@ object PolymarketConstants {
|
||||
* 用于 Gasless 交易
|
||||
*/
|
||||
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
|
||||
|
||||
/**
|
||||
* Polymarket Safe 代理工厂合约地址(Polygon 主网)
|
||||
* 用于 Safe 类型账户的代理部署(SAFE-CREATE)
|
||||
*/
|
||||
const val SAFE_PROXY_FACTORY_ADDRESS = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
||||
|
||||
/** SafeCreate 用 EIP-712 domain name,与 builder-relayer-client 一致 */
|
||||
const val SAFE_FACTORY_EIP712_NAME = "Polymarket Contract Proxy Factory"
|
||||
}
|
||||
|
||||
|
||||
+76
@@ -204,6 +204,82 @@ class AccountController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||
*/
|
||||
@PostMapping("/check-setup-status")
|
||||
fun checkSetupStatus(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountSetupStatusDto>> {
|
||||
return try {
|
||||
if (request.accountId == null || request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
val result = runBlocking { accountService.checkAccountSetupStatus(request.accountId) }
|
||||
result.fold(
|
||||
onSuccess = { status ->
|
||||
ResponseEntity.ok(ApiResponse.success(status))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("检查账户设置状态失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
else -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查账户设置状态异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行设置步骤(步骤1 返回跳转 URL,步骤2/3 由后端执行)
|
||||
*/
|
||||
@PostMapping("/execute-setup-step")
|
||||
fun executeSetupStep(@RequestBody request: ExecuteSetupStepRequest): ResponseEntity<ApiResponse<ExecuteSetupStepResponse>> {
|
||||
return try {
|
||||
if (request.accountId == null || request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
val step = request.step ?: 0
|
||||
if (step !in 1..3) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "步骤必须为 1、2 或 3", messageSource))
|
||||
}
|
||||
val result = runBlocking { accountService.executeSetupStep(request.accountId, step) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("执行设置步骤失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource)
|
||||
)
|
||||
else -> ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行设置步骤异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户详情
|
||||
*/
|
||||
|
||||
+10
-2
@@ -146,7 +146,13 @@ class CryptoTailStrategyController(
|
||||
return try {
|
||||
val options = listOf(
|
||||
CryptoTailMarketOptionDto(slug = "btc-updown-5m", title = "Bitcoin Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
|
||||
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "eth-updown-5m", title = "Ethereum Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "eth-updown-15m", title = "Ethereum Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "sol-updown-5m", title = "Solana Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "sol-updown-15m", title = "Solana Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "xrp-updown-5m", title = "XRP Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "xrp-updown-15m", title = "XRP Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
|
||||
)
|
||||
ResponseEntity.ok(ApiResponse.success(options))
|
||||
} catch (e: Exception) {
|
||||
@@ -168,7 +174,9 @@ class CryptoTailStrategyController(
|
||||
}
|
||||
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
|
||||
?: (System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
||||
// 默认使用 BTC 市场(向后兼容)
|
||||
val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown"
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
|
||||
val body = CryptoTailAutoMinSpreadResponse(
|
||||
minSpreadUp = pair.first.toPlainString(),
|
||||
|
||||
@@ -68,9 +68,9 @@ data class SystemConfigDto(
|
||||
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
||||
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
||||
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
|
||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
|
||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
|
||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(完整,用于前端展示)
|
||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(完整,用于前端展示)
|
||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(完整,用于前端展示)
|
||||
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 账户设置状态检查结果
|
||||
*/
|
||||
data class AccountSetupStatusDto(
|
||||
/**
|
||||
* 步骤1:代理钱包是否已部署
|
||||
*/
|
||||
val proxyDeployed: Boolean,
|
||||
|
||||
/**
|
||||
* 步骤2:交易是否已启用(API Key 是否已配置)
|
||||
*/
|
||||
val tradingEnabled: Boolean,
|
||||
|
||||
/**
|
||||
* 步骤3:代币是否已批准
|
||||
*/
|
||||
val tokensApproved: Boolean,
|
||||
|
||||
/**
|
||||
* 代币批准详情(各合约的授权额度)
|
||||
* Key: 合约名称(CTF_CONTRACT, CTF_EXCHANGE, NEG_RISK_EXCHANGE, NEG_RISK_ADAPTER)
|
||||
* Value: 授权额度(USDC,6位小数)
|
||||
*/
|
||||
val approvalDetails: Map<String, String>? = null,
|
||||
|
||||
/**
|
||||
* 检查错误信息(如果有)
|
||||
*/
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 执行设置步骤请求
|
||||
*/
|
||||
data class ExecuteSetupStepRequest(
|
||||
/** 账户 ID */
|
||||
val accountId: Long? = null,
|
||||
/** 步骤:1=部署代理, 2=启用交易, 3=批准代币 */
|
||||
val step: Int? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 执行设置步骤响应
|
||||
*/
|
||||
data class ExecuteSetupStepResponse(
|
||||
/** 是否由后端执行成功(步骤1 仅返回跳转链接,为 false) */
|
||||
val success: Boolean = false,
|
||||
/** 需跳转时由后端提供的 URL(步骤1 使用) */
|
||||
val redirectUrl: String? = null,
|
||||
/** 链上交易哈希(步骤3 批准代币成功时返回) */
|
||||
val transactionHash: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户导入响应(扩展,包含设置状态)
|
||||
*/
|
||||
data class AccountImportResponse(
|
||||
val account: AccountDto,
|
||||
val setupStatus: AccountSetupStatusDto? = null // 设置状态检查结果(可选)
|
||||
)
|
||||
@@ -15,8 +15,12 @@ data class CryptoTailStrategyCreateRequest(
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
val spreadMode: String = "NONE",
|
||||
/** 价差数值 */
|
||||
val spreadValue: String? = null,
|
||||
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||
val spreadDirection: String = "MIN",
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
@@ -32,8 +36,12 @@ data class CryptoTailStrategyUpdateRequest(
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String? = null,
|
||||
val amountValue: String? = null,
|
||||
val minSpreadMode: String? = null,
|
||||
val minSpreadValue: String? = null,
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
val spreadMode: String? = null,
|
||||
/** 价差数值 */
|
||||
val spreadValue: String? = null,
|
||||
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||
val spreadDirection: String? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
@@ -61,8 +69,12 @@ data class CryptoTailStrategyDto(
|
||||
val maxPrice: String = "1",
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
val spreadMode: String = "NONE",
|
||||
/** 价差数值 */
|
||||
val spreadValue: String? = null,
|
||||
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
|
||||
val spreadDirection: String = "MIN",
|
||||
val enabled: Boolean = true,
|
||||
val lastTriggerAt: Long? = null,
|
||||
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
|
||||
@@ -138,7 +150,7 @@ data class CryptoTailStrategyTriggerListResponse(
|
||||
)
|
||||
|
||||
/**
|
||||
* 自动最小价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||
* 自动价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||
*/
|
||||
data class CryptoTailAutoMinSpreadResponse(
|
||||
val minSpreadUp: String = "0",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirectionConverter
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.enums.SpreadModeConverter
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 加密市场尾盘策略实体
|
||||
@@ -45,11 +48,19 @@ 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",
|
||||
/** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
|
||||
@Convert(converter = SpreadModeConverter::class)
|
||||
@Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT")
|
||||
val spreadMode: SpreadMode = SpreadMode.NONE,
|
||||
|
||||
@Column(name = "min_spread_value", precision = 20, scale = 8)
|
||||
val minSpreadValue: BigDecimal? = null,
|
||||
/** 价差数值(FIXED 时必填;AUTO 时可存计算值) */
|
||||
@Column(name = "spread_value", precision = 20, scale = 8)
|
||||
val spreadValue: BigDecimal? = null,
|
||||
|
||||
/** 价差方向: MIN=最小价差(价差>=配置值触发),MAX=最大价差(价差<=配置值触发) */
|
||||
@Convert(converter = SpreadDirectionConverter::class)
|
||||
@Column(name = "spread_direction", nullable = false, columnDefinition = "TINYINT")
|
||||
val spreadDirection: SpreadDirection = SpreadDirection.MIN,
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
val enabled: Boolean = true,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* 价差方向枚举
|
||||
*/
|
||||
enum class SpreadDirection(val value: Int, val description: String) {
|
||||
/**
|
||||
* 最小价差:价差 >= 配置值时触发,买入价固定 0.99
|
||||
*/
|
||||
MIN(0, "最小价差"),
|
||||
|
||||
/**
|
||||
* 最大价差:价差 <= 配置值时触发,买入价 = 触发价 + 0.02
|
||||
*/
|
||||
MAX(1, "最大价差");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 从数值解析价差方向
|
||||
*/
|
||||
fun fromValue(value: Int?): SpreadDirection {
|
||||
if (value == null) {
|
||||
return MIN // 默认返回 MIN
|
||||
}
|
||||
return values().find { it.value == value }
|
||||
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地从数值解析价差方向,解析失败返回默认值
|
||||
*/
|
||||
fun fromValueOrDefault(value: Int?, default: SpreadDirection = MIN): SpreadDirection {
|
||||
if (value == null) {
|
||||
return default
|
||||
}
|
||||
return values().find { it.value == value } ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串解析价差方向(兼容旧逻辑)
|
||||
*/
|
||||
fun fromString(value: String?): SpreadDirection {
|
||||
if (value.isNullOrBlank()) {
|
||||
return MIN
|
||||
}
|
||||
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
import jakarta.persistence.AttributeConverter
|
||||
import jakarta.persistence.Converter
|
||||
|
||||
/**
|
||||
* SpreadDirection 枚举的 JPA 转换器
|
||||
* 数据库存储为 TINYINT (0 = MIN, 1 = MAX)
|
||||
*/
|
||||
@Converter(autoApply = false)
|
||||
class SpreadDirectionConverter : AttributeConverter<SpreadDirection, Int> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: SpreadDirection?): Int {
|
||||
return attribute?.value ?: SpreadDirection.MIN.value
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: Int?): SpreadDirection {
|
||||
return SpreadDirection.fromValueOrDefault(dbData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* 价差模式枚举
|
||||
*/
|
||||
enum class SpreadMode(val value: Int, val description: String) {
|
||||
/**
|
||||
* 不校验价差
|
||||
*/
|
||||
NONE(0, "无"),
|
||||
|
||||
/**
|
||||
* 固定值:用户输入一个数值
|
||||
*/
|
||||
FIXED(1, "固定"),
|
||||
|
||||
/**
|
||||
* 自动:系统按历史 K 线计算建议价差
|
||||
*/
|
||||
AUTO(2, "自动");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 从数值解析价差模式
|
||||
*/
|
||||
fun fromValue(value: Int?): SpreadMode {
|
||||
if (value == null) {
|
||||
return NONE // 默认返回 NONE
|
||||
}
|
||||
return values().find { it.value == value }
|
||||
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地从数值解析价差模式,解析失败返回默认值
|
||||
*/
|
||||
fun fromValueOrDefault(value: Int?, default: SpreadMode = NONE): SpreadMode {
|
||||
if (value == null) {
|
||||
return default
|
||||
}
|
||||
return values().find { it.value == value } ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串解析价差模式(兼容旧逻辑)
|
||||
*/
|
||||
fun fromString(value: String?): SpreadMode {
|
||||
if (value.isNullOrBlank()) {
|
||||
return NONE
|
||||
}
|
||||
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
import jakarta.persistence.AttributeConverter
|
||||
import jakarta.persistence.Converter
|
||||
|
||||
/**
|
||||
* SpreadMode 枚举的 JPA 转换器
|
||||
* 数据库存储为 TINYINT (0 = NONE, 1 = FIXED, 2 = AUTO)
|
||||
*/
|
||||
@Converter(autoApply = false)
|
||||
class SpreadModeConverter : AttributeConverter<SpreadMode, Int> {
|
||||
|
||||
override fun convertToDatabaseColumn(attribute: SpreadMode?): Int {
|
||||
return attribute?.value ?: SpreadMode.NONE.value
|
||||
}
|
||||
|
||||
override fun convertToEntityAttribute(dbData: Int?): SpreadMode {
|
||||
return SpreadMode.fromValueOrDefault(dbData)
|
||||
}
|
||||
}
|
||||
+289
-13
@@ -361,6 +361,215 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polymarket 代币批准检查:USDC.e 需授权的 spender 合约地址(Polygon 主网)
|
||||
* 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals
|
||||
* 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
|
||||
*/
|
||||
private val setupApprovalSpenders = mapOf(
|
||||
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
|
||||
"CTF_EXCHANGE" to "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", // 普通市场交易所
|
||||
"NEG_RISK_EXCHANGE" to "0xC5d563A36AE78145C45a50134d48A1215220f80a", // 负风险市场交易所
|
||||
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
|
||||
)
|
||||
|
||||
/** USDC 精度(6 位小数) */
|
||||
private val usdcDecimals = java.math.BigDecimal("1000000")
|
||||
|
||||
/** ERC20 无限授权额度(type(uint256).max),Polymarket 默认使用无限授权 */
|
||||
private val unlimitedAllowance = BigInteger("115792089237316195423570985008687907853269984665640564039457584007913129639935")
|
||||
|
||||
/**
|
||||
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||
* @param accountId 账户 ID
|
||||
* @return AccountSetupStatusDto
|
||||
*/
|
||||
suspend fun checkAccountSetupStatus(accountId: Long): Result<AccountSetupStatusDto> {
|
||||
return try {
|
||||
if (accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||
}
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
val proxyAddress = account.proxyAddress
|
||||
if (proxyAddress.isBlank()) {
|
||||
return Result.success(
|
||||
AccountSetupStatusDto(
|
||||
proxyDeployed = false,
|
||||
tradingEnabled = account.apiKey != null && account.apiSecret != null && account.apiPassphrase != null,
|
||||
tokensApproved = false,
|
||||
approvalDetails = null,
|
||||
error = "代理地址为空"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 步骤1:代理钱包是否已部署
|
||||
val proxyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||
|
||||
// 步骤2:交易是否已启用(API 凭证是否已配置)
|
||||
val tradingEnabled = account.apiKey != null &&
|
||||
account.apiSecret != null &&
|
||||
account.apiPassphrase != null
|
||||
|
||||
// 步骤3:代币是否已批准(USDC 对各 spender 的 allowance,默认无限授权)
|
||||
val approvalDetails = mutableMapOf<String, String>()
|
||||
var tokensApproved = true
|
||||
for ((name, spender) in setupApprovalSpenders) {
|
||||
val allowanceResult = blockchainService.getUsdcAllowance(proxyAddress, spender)
|
||||
val allowance = allowanceResult.getOrNull() ?: BigInteger.ZERO
|
||||
val displayAmount = if (allowance >= unlimitedAllowance) {
|
||||
"unlimited"
|
||||
} else {
|
||||
java.math.BigDecimal(allowance).divide(usdcDecimals, 6, java.math.RoundingMode.DOWN).toPlainString()
|
||||
}
|
||||
approvalDetails[name] = displayAmount
|
||||
if (allowance <= BigInteger.ZERO) {
|
||||
tokensApproved = false
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(
|
||||
AccountSetupStatusDto(
|
||||
proxyDeployed = proxyDeployed,
|
||||
tradingEnabled = tradingEnabled,
|
||||
tokensApproved = tokensApproved,
|
||||
approvalDetails = approvalDetails,
|
||||
error = null
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查账户设置状态失败: accountId=$accountId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 步骤1 跳转 URL(代理部署需在 Polymarket 完成) */
|
||||
private val setupStep1RedirectUrl = "https://polymarket.com/settings/wallet"
|
||||
|
||||
/**
|
||||
* 执行设置步骤(由后端实现或返回跳转)
|
||||
* 步骤1:仅返回跳转 URL,由用户前往 Polymarket 完成部署
|
||||
* 步骤2:创建/派生 API Key 并更新账户
|
||||
* 步骤3:通过代理钱包批量执行 USDC 授权
|
||||
*/
|
||||
suspend fun executeSetupStep(accountId: Long, step: Int): Result<ExecuteSetupStepResponse> {
|
||||
return try {
|
||||
if (accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||
}
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
when (step) {
|
||||
1 -> {
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.MAGIC)
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
Result.success(
|
||||
ExecuteSetupStepResponse(
|
||||
success = false,
|
||||
redirectUrl = setupStep1RedirectUrl
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val proxyAddress = account.proxyAddress
|
||||
if (proxyAddress.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("代理地址为空"))
|
||||
}
|
||||
val alreadyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||
if (alreadyDeployed) {
|
||||
Result.success(ExecuteSetupStepResponse(success = true))
|
||||
} else {
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val deployResult = relayClientService.deploySafeViaBuilderRelayer(
|
||||
privateKey = privateKey,
|
||||
proxyAddress = proxyAddress,
|
||||
fromAddress = account.walletAddress
|
||||
)
|
||||
deployResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
Result.success(
|
||||
ExecuteSetupStepResponse(
|
||||
success = true,
|
||||
transactionHash = txHash
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("Safe 部署失败: accountId=$accountId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val result = apiKeyService.createOrDeriveApiKey(
|
||||
privateKey = privateKey,
|
||||
walletAddress = account.walletAddress,
|
||||
chainId = 137L
|
||||
)
|
||||
if (result.isFailure) {
|
||||
val e = result.exceptionOrNull()
|
||||
logger.error("启用交易(API Key)失败: accountId=$accountId, ${e?.message}", e)
|
||||
return Result.failure(e ?: IllegalStateException("获取 API Key 失败"))
|
||||
}
|
||||
val creds = result.getOrNull()
|
||||
?: return Result.failure(IllegalStateException("API Key 返回为空"))
|
||||
val encryptedSecret = creds.secret?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedPassphrase = creds.passphrase?.let { cryptoUtils.encrypt(it) }
|
||||
val updated = account.copy(
|
||||
apiKey = creds.apiKey,
|
||||
apiSecret = encryptedSecret,
|
||||
apiPassphrase = encryptedPassphrase,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
accountRepository.save(updated)
|
||||
orderPushService.refreshSubscriptions()
|
||||
Result.success(ExecuteSetupStepResponse(success = true))
|
||||
}
|
||||
3 -> {
|
||||
val proxyAddress = account.proxyAddress
|
||||
if (proxyAddress.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("代理地址为空,请先完成步骤1"))
|
||||
}
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
val approveTxs = setupApprovalSpenders.values.map { spender ->
|
||||
relayClientService.createUsdcApproveTx(spender, unlimitedAllowance)
|
||||
}
|
||||
val multiSendTx = relayClientService.createMultiSendTx(approveTxs)
|
||||
val executeResult = relayClientService.execute(
|
||||
privateKey = privateKey,
|
||||
proxyAddress = proxyAddress,
|
||||
safeTx = multiSendTx,
|
||||
walletType = walletType
|
||||
)
|
||||
executeResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
Result.success(
|
||||
ExecuteSetupStepResponse(
|
||||
success = true,
|
||||
transactionHash = txHash
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("代币授权执行失败: accountId=$accountId, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
}
|
||||
else -> Result.failure(IllegalArgumentException("无效的步骤: $step,应为 1、2 或 3"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行设置步骤失败: accountId=$accountId, step=$step, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@@ -727,7 +936,38 @@ class AccountService(
|
||||
throw RuntimeException("解密私钥失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e。
|
||||
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
|
||||
*/
|
||||
suspend fun runWcolUnwrapForAllAccounts() {
|
||||
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||
if (accounts.isEmpty()) return
|
||||
for (account in accounts) {
|
||||
try {
|
||||
val privateKey = decryptPrivateKey(account)
|
||||
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
blockchainService.unwrapWcolForProxy(
|
||||
privateKey = privateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
walletType = walletType
|
||||
).fold(
|
||||
onSuccess = { txHash ->
|
||||
if (txHash != null) {
|
||||
logger.info("轮询解包 WCOL: accountId=${account.id}, proxy=${account.proxyAddress.take(10)}..., txHash=$txHash")
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.warn("轮询解包 WCOL 失败 accountId=${account.id}: ${e.message}")
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("轮询解包 WCOL 跳过 accountId=${account.id}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密账户 API Secret
|
||||
*/
|
||||
@@ -1464,21 +1704,30 @@ class AccountService(
|
||||
// 按市场分组(同一市场的仓位可以批量赎回)
|
||||
val positionsByMarket = positions.groupBy { it.first.marketId }
|
||||
|
||||
// 对每个市场执行赎回
|
||||
// 获取钱包类型
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
|
||||
// 解密私钥(只需解密一次)
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 执行赎回
|
||||
var lastTxHash: String? = null
|
||||
for ((marketId, marketPositions) in positionsByMarket) {
|
||||
val indexSets = marketPositions.map { it.second }
|
||||
|
||||
// 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
// Safe 钱包且有多个市场:使用 MultiSend 批量赎回
|
||||
if (walletTypeEnum == WalletType.SAFE && positionsByMarket.size > 1) {
|
||||
val redeemRequests = mutableListOf<Triple<String, List<BigInteger>, Boolean>>()
|
||||
for ((marketId, marketPositions) in positionsByMarket) {
|
||||
val indexSets = marketPositions.map { it.second }
|
||||
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
|
||||
redeemRequests.add(Triple(marketId, indexSets, isNegRisk))
|
||||
}
|
||||
|
||||
// 调用区块链服务赎回仓位
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
val redeemResult = blockchainService.redeemPositions(
|
||||
logger.info("账户 $accountId: 使用 MultiSend 批量赎回 ${redeemRequests.size} 个市场")
|
||||
|
||||
val redeemResult = blockchainService.redeemPositionsBatch(
|
||||
privateKey = decryptedPrivateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
conditionId = marketId,
|
||||
indexSets = indexSets,
|
||||
redeemRequests = redeemRequests,
|
||||
walletType = walletTypeEnum
|
||||
)
|
||||
|
||||
@@ -1487,11 +1736,38 @@ class AccountService(
|
||||
lastTxHash = txHash
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
|
||||
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
|
||||
logger.error("账户 $accountId MultiSend 批量赎回失败: ${e.message}", e)
|
||||
return Result.failure(Exception("赎回失败: 账户 $accountId - ${e.message}"))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Magic 钱包或单个市场:逐笔赎回
|
||||
for ((marketId, marketPositions) in positionsByMarket) {
|
||||
val indexSets = marketPositions.map { it.second }
|
||||
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
|
||||
|
||||
val redeemResult = blockchainService.redeemPositions(
|
||||
privateKey = decryptedPrivateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
conditionId = marketId,
|
||||
indexSets = indexSets,
|
||||
isNegRisk = isNegRisk,
|
||||
walletType = walletTypeEnum
|
||||
)
|
||||
|
||||
redeemResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
lastTxHash = txHash
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
|
||||
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// WCOL 解包由 WcolUnwrapJobService 每 20 秒轮询统一处理,赎回流程不再等待确认与解包
|
||||
|
||||
// 计算该账户的赎回总价值
|
||||
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
|
||||
|
||||
+23
-5
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* 仓位检查服务
|
||||
@@ -77,7 +78,10 @@ class PositionCheckService(
|
||||
|
||||
// 同步锁,确保订阅任务的启动和停止是线程安全的
|
||||
private val lock = Any()
|
||||
|
||||
|
||||
// 防止 checkRedeemablePositions 重入:上一轮检查未完成时,新一轮轮询直接跳过
|
||||
private val redeemCheckInProgress = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
|
||||
*/
|
||||
@@ -328,18 +332,23 @@ class PositionCheckService(
|
||||
|
||||
/**
|
||||
* 逻辑1:处理待赎回仓位
|
||||
https://clob.polymarket.com * 按照以下逻辑处理:
|
||||
* 按照以下逻辑处理:
|
||||
* 1. 无待赎回仓位:跳过
|
||||
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
|
||||
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||
* 防重入:上一轮检查未完成时,本轮直接跳过,避免并发赎回。
|
||||
*/
|
||||
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
|
||||
if (!redeemCheckInProgress.compareAndSet(false, true)) {
|
||||
logger.debug("跳过本次待赎回仓位检查:上一次检查尚未完成")
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 1. 无待赎回仓位:跳过
|
||||
if (redeemablePositions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 检查系统级别的自动赎回配置
|
||||
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
|
||||
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
|
||||
@@ -373,7 +382,14 @@ class PositionCheckService(
|
||||
}
|
||||
return // 未配置时直接返回,不进行后续处理
|
||||
}
|
||||
|
||||
|
||||
// Builder Relayer 配额冷却期内不再发起赎回(如 API 返回 quota exceeded, resets in N seconds)
|
||||
if (relayClientService.isBuilderRelayerQuotaBlocked()) {
|
||||
val remaining = relayClientService.getBuilderRelayerQuotaBlockedRemainingSeconds()
|
||||
logger.info("Builder Relayer 配额冷却中,跳过本次自动赎回,约 ${remaining} 秒后恢复")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
|
||||
// 先执行赎回,赎回成功后再查找订单并更新订单状态
|
||||
@@ -451,9 +467,11 @@ class PositionCheckService(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理待赎回仓位异常: ${e.message}", e)
|
||||
} finally {
|
||||
redeemCheckInProgress.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 逻辑2:处理未卖出订单
|
||||
* 检查所有未卖出的订单,匹配仓位
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.wrbug.polymarketbot.service.accounts
|
||||
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* WCOL 解包轮询任务
|
||||
* 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则解包为 USDC.e。
|
||||
* 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。
|
||||
* 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。
|
||||
*/
|
||||
@Service
|
||||
class WcolUnwrapJobService(
|
||||
private val accountService: AccountService,
|
||||
private val relayClientService: RelayClientService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(WcolUnwrapJobService::class.java)
|
||||
private val scope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var unwrapJob: Job? = null
|
||||
|
||||
/**
|
||||
* 每 20 秒触发一次;若未配置 Builder Key 或当前任务仍在执行则跳过本次
|
||||
*/
|
||||
@Scheduled(fixedRate = 20_000)
|
||||
fun runWcolUnwrapPolling() {
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
logger.debug("Builder API Key 未配置,跳过 WCOL 解包轮询")
|
||||
return
|
||||
}
|
||||
if (unwrapJob?.isActive == true) {
|
||||
logger.debug("上一轮 WCOL 解包任务仍在执行,跳过本次")
|
||||
return
|
||||
}
|
||||
unwrapJob = scope.launch {
|
||||
try {
|
||||
accountService.runWcolUnwrapForAllAccounts()
|
||||
} catch (e: Exception) {
|
||||
logger.error("WCOL 解包轮询异常: ${e.message}", e)
|
||||
} finally {
|
||||
unwrapJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-12
@@ -9,7 +9,7 @@ import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (interval, period)。
|
||||
* 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (marketSlugPrefix, interval, period)。
|
||||
* 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。
|
||||
*/
|
||||
@Service
|
||||
@@ -19,29 +19,68 @@ class BinanceKlineAutoSpreadService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
|
||||
|
||||
private val symbol = "BTCUSDC"
|
||||
/** 市场 slug 前缀 -> Binance 交易对映射 */
|
||||
private val marketToSymbol = mapOf(
|
||||
"btc-updown" to "BTCUSDC",
|
||||
"eth-updown" to "ETHUSDC",
|
||||
"sol-updown" to "SOLUSDC",
|
||||
"xrp-updown" to "XRPUSDC"
|
||||
)
|
||||
|
||||
private val historyLimit = 20
|
||||
private val minSamplesAfterIqr = 3
|
||||
|
||||
/** (intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
||||
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
||||
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||
|
||||
private fun cacheKey(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
|
||||
/** 缓存保留时间(秒),超过则清理,防止无界增长 */
|
||||
private val cacheExpireSeconds = 3600L
|
||||
|
||||
/** 从市场 slug 前缀获取 Binance 交易对;支持完整 slug(如 eth-updown-5m)或前缀(如 eth-updown) */
|
||||
private fun getSymbol(marketSlugPrefix: String): String? {
|
||||
val base = marketSlugPrefix.lowercase().removeSuffix("-15m").removeSuffix("-5m")
|
||||
return marketToSymbol[base]
|
||||
}
|
||||
|
||||
private fun cacheKey(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
|
||||
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
|
||||
}
|
||||
|
||||
/** 清理已过期的价差缓存,避免内存泄漏 */
|
||||
private fun cleanExpiredCache() {
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val expireThreshold = nowSeconds - cacheExpireSeconds
|
||||
val keysToRemove = cache.keys.filter { key ->
|
||||
// key 格式: marketSlugPrefix-intervalSeconds-periodStartUnix
|
||||
val parts = key.split('-')
|
||||
if (parts.size >= 3) {
|
||||
parts.last().toLongOrNull()?.let { it < expireThreshold } ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach { cache.remove(it) }
|
||||
}
|
||||
|
||||
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
|
||||
fun getAutoMinSpreadBase(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
||||
val key = cacheKey(intervalSeconds, periodStartUnix)
|
||||
fun getAutoMinSpreadBase(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
||||
val key = cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||
val (up, down) = cache[key] ?: run {
|
||||
computeAndCache(intervalSeconds, periodStartUnix) ?: return null
|
||||
computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix) ?: return null
|
||||
}
|
||||
return if (outcomeIndex == 0) up else down
|
||||
}
|
||||
|
||||
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
|
||||
fun computeAndCache(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||
fun computeAndCache(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||
cleanExpiredCache()
|
||||
val symbol = getSymbol(marketSlugPrefix) ?: run {
|
||||
logger.warn("不支持的市场 slug 前缀: $marketSlugPrefix")
|
||||
return null
|
||||
}
|
||||
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
|
||||
val endTimeMs = periodStartUnix * 1000L
|
||||
val klines = fetchKlines(intervalStr, historyLimit, endTime = endTimeMs) ?: return null
|
||||
val klines = fetchKlines(symbol, intervalStr, historyLimit, endTime = endTimeMs) ?: return null
|
||||
val spreadsUp = mutableListOf<BigDecimal>()
|
||||
val spreadsDown = mutableListOf<BigDecimal>()
|
||||
for (k in klines) {
|
||||
@@ -53,16 +92,16 @@ class BinanceKlineAutoSpreadService(
|
||||
}
|
||||
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
|
||||
cache[cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)] = baseUp to baseDown
|
||||
logger.info(
|
||||
"尾盘自动价差已计算并缓存(100%基准): interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
|
||||
"尾盘自动价差已计算并缓存(100%基准): market=$marketSlugPrefix symbol=$symbol interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
|
||||
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
|
||||
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
|
||||
)
|
||||
return baseUp to baseDown
|
||||
}
|
||||
|
||||
private fun fetchKlines(interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
|
||||
private fun fetchKlines(symbol: String, 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)
|
||||
|
||||
+97
-62
@@ -16,10 +16,11 @@ import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 币安 K 线 WebSocket:订阅 BTCUSDC 5m/15m,维护当前周期 (open, close),供尾盘策略价差校验使用。
|
||||
* 币安 K 线 WebSocket:按需订阅尾盘策略使用的币种 5m/15m,维护当前周期 (open, close),供价差校验使用。
|
||||
* 仅当存在启用策略且策略使用到某市场时才订阅对应币种,无策略时不建立连接。
|
||||
*/
|
||||
@Service
|
||||
class BinanceKlineService {
|
||||
@@ -30,86 +31,125 @@ class BinanceKlineService {
|
||||
private val wsBase = "wss://stream.binance.com:9443"
|
||||
private val client = createClient().build()
|
||||
|
||||
/** (intervalSeconds, periodStartUnix) -> (open, close) */
|
||||
/** (marketSlugPrefix, 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
|
||||
private val connected5m = AtomicBoolean(false)
|
||||
private val connected15m = AtomicBoolean(false)
|
||||
|
||||
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)]
|
||||
}
|
||||
|
||||
/** 供 API 健康检查使用:5m / 15m 连接是否正常 */
|
||||
fun getConnectionStatuses(): Map<String, Boolean> = mapOf(
|
||||
"5m" to connected5m.get(),
|
||||
"15m" to connected15m.get()
|
||||
|
||||
/** 市场 slug 前缀(如 btc-updown)-> Binance 交易对映射 */
|
||||
private val marketToSymbol = mapOf(
|
||||
"btc-updown" to "BTCUSDC",
|
||||
"eth-updown" to "ETHUSDC",
|
||||
"sol-updown" to "SOLUSDC",
|
||||
"xrp-updown" to "XRPUSDC"
|
||||
)
|
||||
|
||||
/** 已连接的 WebSocket: wsKey (symbol-interval) -> WebSocket */
|
||||
private val connectedWebSockets = ConcurrentHashMap<String, WebSocket>()
|
||||
/** 当前需要订阅的完整市场集合(如 btc-updown-5m、btc-updown-15m),由尾盘策略刷新时更新 */
|
||||
private val requiredMarketPrefixes = AtomicReference<Set<String>>(emptySet())
|
||||
private val subscriptionLock = Any()
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
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 }
|
||||
/** 解析完整市场 slug(如 btc-updown-5m)为 (basePrefix, interval),不支持则返回 null */
|
||||
private fun parseMarketSlug(full: String): Pair<String, String>? {
|
||||
val lower = full.lowercase()
|
||||
return when {
|
||||
lower.endsWith("-5m") -> Pair(lower.removeSuffix("-5m"), "5m")
|
||||
lower.endsWith("-15m") -> Pair(lower.removeSuffix("-15m"), "15m")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** 从市场 base 前缀(如 btc-updown)获取 Binance 交易对 */
|
||||
private fun getSymbol(basePrefix: String): String? = marketToSymbol[basePrefix]
|
||||
|
||||
private fun key(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
|
||||
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
|
||||
}
|
||||
|
||||
fun getCurrentOpenClose(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||
return openCloseByPeriod[key(marketSlugPrefix, intervalSeconds, periodStartUnix)]
|
||||
}
|
||||
|
||||
/** 供 API 健康检查使用:各币种各周期的连接状态 */
|
||||
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||
return connectedWebSockets.keys.associateWith { connectedWebSockets[it] != null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按需更新订阅:仅订阅策略用到的 (币种, 周期),例如只开 btc 5min 则只建 btc 5min K 线连接。
|
||||
* 由 CryptoTailOrderbookWsService 在刷新订阅时根据启用策略的 marketSlugPrefix 调用。
|
||||
* @param marketPrefixes 当前启用策略用到的完整市场集合,如 ["btc-updown-5m"] 或 ["btc-updown-5m", "eth-updown-15m"];空集合时关闭所有连接
|
||||
*/
|
||||
fun updateSubscriptions(marketPrefixes: Set<String>) {
|
||||
val normalized = marketPrefixes.map { it.lowercase() }.toSet()
|
||||
val parsed = normalized.mapNotNull { full ->
|
||||
parseMarketSlug(full)?.let { (base, interval) ->
|
||||
getSymbol(base)?.let { symbol -> Triple(full, symbol, interval) }
|
||||
}
|
||||
}.toSet()
|
||||
val wsKeysNeeded = parsed.map { (_, symbol, interval) -> "$symbol-$interval" }.toSet()
|
||||
if (normalized == requiredMarketPrefixes.get()) return
|
||||
requiredMarketPrefixes.set(normalized)
|
||||
synchronized(subscriptionLock) {
|
||||
connectedWebSockets.keys.toList().forEach { wsKey ->
|
||||
if (wsKey !in wsKeysNeeded) {
|
||||
connectedWebSockets.remove(wsKey)?.close(1000, "subscription_update")
|
||||
logger.info("币安 K 线 WS 已关闭(无策略使用): $wsKey")
|
||||
}
|
||||
}
|
||||
parsed.forEach { (fullPrefix, symbol, interval) ->
|
||||
connectStream(symbol, interval, fullPrefix) { marketPrefixParam, intervalSec, tMs, openP, closeP ->
|
||||
val periodSec = tMs / 1000
|
||||
openCloseByPeriod[key(marketPrefixParam, intervalSec, periodSec)] = openP to closeP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectStream(
|
||||
streamName: String,
|
||||
onKline: (intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
|
||||
): WebSocket {
|
||||
symbol: String,
|
||||
interval: String,
|
||||
marketPrefix: String,
|
||||
onKline: (marketPrefix: String, intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
|
||||
) {
|
||||
val streamName = "${symbol.lowercase()}@kline_$interval"
|
||||
val wsKey = "$symbol-$interval"
|
||||
if (connectedWebSockets[wsKey] != null) return
|
||||
|
||||
val url = "$wsBase/ws/$streamName"
|
||||
val intervalSeconds = when {
|
||||
streamName.contains("kline_5m") -> 300
|
||||
streamName.contains("kline_15m") -> 900
|
||||
val intervalSeconds = when (interval) {
|
||||
"5m" -> 300
|
||||
"15m" -> 900
|
||||
else -> 300
|
||||
}
|
||||
val request = Request.Builder().url(url).build()
|
||||
val connectedFlag = when {
|
||||
streamName.contains("kline_5m") -> connected5m
|
||||
streamName.contains("kline_15m") -> connected15m
|
||||
else -> null
|
||||
}
|
||||
val ws = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
connectedFlag?.set(true)
|
||||
connectedWebSockets[wsKey] = webSocket
|
||||
logger.info("币安 K 线 WS 已连接: $streamName")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
parseKlineMessage(text, intervalSeconds)?.let { (tMs, o, c) ->
|
||||
onKline(intervalSeconds, tMs, o, c)
|
||||
onKline(marketPrefix, intervalSeconds, tMs, o, c)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
connectedFlag?.set(false)
|
||||
connectedWebSockets.remove(wsKey)
|
||||
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
connectedFlag?.set(false)
|
||||
connectedWebSockets.remove(wsKey)
|
||||
if (code != 1000) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
connectedFlag?.set(false)
|
||||
connectedWebSockets.remove(wsKey)
|
||||
}
|
||||
})
|
||||
logger.info("币安 K 线 WS 已连接: $streamName")
|
||||
return ws
|
||||
}
|
||||
|
||||
private fun parseKlineMessage(text: String, intervalSeconds: Int): Triple<Long, BigDecimal, BigDecimal>? {
|
||||
@@ -132,23 +172,18 @@ class BinanceKlineService {
|
||||
reconnectJob = scope.launch {
|
||||
delay(3_000)
|
||||
reconnectJob = null
|
||||
ws5m?.close(1000, "reconnect")
|
||||
ws15m?.close(1000, "reconnect")
|
||||
ws5m = null
|
||||
ws15m = null
|
||||
connected5m.set(false)
|
||||
connected15m.set(false)
|
||||
val current = requiredMarketPrefixes.get()
|
||||
connectedWebSockets.values.forEach { it.close(1000, "reconnect") }
|
||||
connectedWebSockets.clear()
|
||||
logger.info("币安 K 线 WS 尝试重连")
|
||||
connectAll()
|
||||
updateSubscriptions(current)
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
reconnectJob?.cancel()
|
||||
ws5m?.close(1000, "shutdown")
|
||||
ws15m?.close(1000, "shutdown")
|
||||
ws5m = null
|
||||
ws15m = null
|
||||
connectedWebSockets.values.forEach { it.close(1000, "shutdown") }
|
||||
connectedWebSockets.clear()
|
||||
}
|
||||
}
|
||||
|
||||
+241
-2
@@ -18,6 +18,7 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
import kotlinx.coroutines.delay
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
@@ -54,6 +55,9 @@ class BlockchainService(
|
||||
|
||||
// ConditionalTokens 合约地址(Polygon 主网)
|
||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
|
||||
// Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e)
|
||||
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||
|
||||
// 空集合ID(用于计算collectionId)
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
@@ -243,6 +247,62 @@ class BlockchainService(
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理钱包是否已部署(链上有合约代码)
|
||||
* @param proxyAddress 代理钱包地址
|
||||
* @return 已部署返回 true
|
||||
*/
|
||||
suspend fun isProxyDeployed(proxyAddress: String): Boolean {
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return false
|
||||
}
|
||||
return isContract(proxyAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 ERC20 USDC 授权额度 allowance(owner, spender)
|
||||
* @param owner 代币持有者地址(代理钱包地址)
|
||||
* @param spender 被授权方地址(如 CTF Exchange)
|
||||
* @return 授权额度(原始值,USDC 为 6 位小数,需除以 1e6 为显示值)
|
||||
*/
|
||||
suspend fun getUsdcAllowance(owner: String, spender: String): Result<BigInteger> {
|
||||
return try {
|
||||
if (owner.isBlank() || spender.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("owner 或 spender 不能为空"))
|
||||
}
|
||||
val rpcApi = polygonRpcApi
|
||||
// ERC20 allowance(address owner, address spender) 选择器
|
||||
val functionSelector = "0xdd62ed3e"
|
||||
val ownerEncoded = EthereumUtils.encodeAddress(owner)
|
||||
val spenderEncoded = EthereumUtils.encodeAddress(spender)
|
||||
val data = functionSelector + ownerEncoded + spenderEncoded
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to usdcContractAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
|
||||
}
|
||||
val hexResult = rpcResponse.result?.asString ?: return Result.failure(Exception("RPC 响应 result 为空"))
|
||||
val allowance = EthereumUtils.decodeUint256(hexResult)
|
||||
Result.success(allowance)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询 USDC 授权额度失败: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询账户 USDC 余额
|
||||
@@ -587,6 +647,7 @@ class BlockchainService(
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex)
|
||||
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 作为抵押品)
|
||||
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
|
||||
* @return 交易哈希
|
||||
*/
|
||||
@@ -595,6 +656,7 @@ class BlockchainService(
|
||||
proxyAddress: String,
|
||||
conditionId: String,
|
||||
indexSets: List<BigInteger>,
|
||||
isNegRisk: Boolean = false,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
@@ -608,14 +670,191 @@ class BlockchainService(
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
|
||||
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
|
||||
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
|
||||
} catch (e: Exception) {
|
||||
logger.error("赎回仓位失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 批量赎回多个市场的仓位(使用 MultiSend 合并为一笔交易)
|
||||
* 仅支持 Safe 钱包类型,Magic 钱包不支持 MultiSend
|
||||
*
|
||||
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||
* @param proxyAddress 代理地址(Safe 代理钱包地址)
|
||||
* @param redeemRequests 赎回请求列表,每个元素是 (conditionId, indexSets, isNegRisk)
|
||||
* @param walletType 钱包类型:仅支持 SAFE
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun redeemPositionsBatch(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
redeemRequests: List<Triple<String, List<BigInteger>, Boolean>>,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
if (redeemRequests.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("redeemRequests 不能为空"))
|
||||
}
|
||||
|
||||
// Magic 钱包不支持 MultiSend
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
return Result.failure(IllegalArgumentException("Magic 钱包不支持 MultiSend 批量赎回,请使用逐笔赎回"))
|
||||
}
|
||||
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 验证所有 conditionId 格式
|
||||
for ((conditionId, _, _) in redeemRequests) {
|
||||
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||
return Result.failure(IllegalArgumentException("conditionId 格式错误: $conditionId"))
|
||||
}
|
||||
}
|
||||
|
||||
// 创建每个市场的赎回交易(Neg Risk 市场使用 WrappedCollateral)
|
||||
val redeemTxs = redeemRequests.map { (conditionId, indexSets, isNegRisk) ->
|
||||
if (indexSets.isEmpty()) {
|
||||
throw IllegalArgumentException("indexSets 不能为空: $conditionId")
|
||||
}
|
||||
relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
|
||||
}
|
||||
|
||||
// 使用 MultiSend 合并所有交易
|
||||
val multiSendTx = relayClientService.createMultiSendTx(redeemTxs)
|
||||
|
||||
logger.info("批量赎回: 合并 ${redeemRequests.size} 个市场为一笔交易")
|
||||
|
||||
relayClientService.execute(privateKey, proxyAddress, multiSendTx, walletType)
|
||||
} catch (e: Exception) {
|
||||
logger.error("批量赎回仓位失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询等待交易上链并确认成功
|
||||
* @param txHash 交易 hash(0x 开头)
|
||||
* @param maxWaitMs 最大等待毫秒数
|
||||
* @param pollIntervalMs 轮询间隔毫秒数
|
||||
* @return 成功返回 Unit,超时或 revert 返回 Result.failure
|
||||
*/
|
||||
suspend fun waitForTransactionConfirmed(
|
||||
txHash: String,
|
||||
maxWaitMs: Long = 120_000,
|
||||
pollIntervalMs: Long = 3_000
|
||||
): Result<Unit> {
|
||||
val rpcApi = polygonRpcApi
|
||||
val start = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - start < maxWaitMs) {
|
||||
val req = JsonRpcRequest(method = "eth_getTransactionReceipt", params = listOf(txHash))
|
||||
val response = rpcApi.call(req)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
val body = response.body()!!
|
||||
if (body.error != null) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
val result = body.result
|
||||
if (result == null || result.isJsonNull) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
val status = result.asJsonObject?.get("status")?.asString
|
||||
if (status == null) {
|
||||
delay(pollIntervalMs)
|
||||
continue
|
||||
}
|
||||
return when (status) {
|
||||
"0x1" -> Result.success(Unit)
|
||||
"0x0" -> Result.failure(Exception("交易已上链但执行失败 (revert)"))
|
||||
else -> Result.failure(Exception("交易状态异常: $status"))
|
||||
}
|
||||
}
|
||||
return Result.failure(Exception("等待交易确认超时 (${maxWaitMs}ms)"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询代理地址的 WCOL(Wrapped Collateral)余额(raw,6 位小数)
|
||||
*/
|
||||
suspend fun getWcolBalance(proxyAddress: String): Result<BigInteger> {
|
||||
val rpcApi = polygonRpcApi
|
||||
val functionSelector = "0x70a08231" // balanceOf(address)
|
||||
val paddedAddress = proxyAddress.removePrefix("0x").lowercase().padStart(64, '0')
|
||||
val data = functionSelector + paddedAddress
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_call",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"to" to wcolContractAddress,
|
||||
"data" to data
|
||||
),
|
||||
"latest"
|
||||
)
|
||||
)
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
return Result.failure(Exception("查询 WCOL 余额失败: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
return Result.failure(Exception("查询 WCOL 余额失败: ${rpcResponse.error.message}"))
|
||||
}
|
||||
val hexBalance = rpcResponse.result?.asString ?: return Result.failure(Exception("WCOL 余额结果为空"))
|
||||
val balance = EthereumUtils.decodeUint256(hexBalance)
|
||||
return Result.success(balance)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将代理钱包内的 WCOL 解包为 USDC.e(解包后转入代理地址)
|
||||
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可转为 USDC.e 以便显示/使用。
|
||||
*
|
||||
* Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute];
|
||||
* Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入 proxyAddress。
|
||||
*
|
||||
* @param privateKey 主钱包私钥
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理)
|
||||
* @param walletType 钱包类型(SAFE / MAGIC),用于选择 Relayer 执行路径
|
||||
* @return 成功返回交易 hash,余额为 0 返回 null,失败返回 Result.failure
|
||||
*/
|
||||
suspend fun unwrapWcolForProxy(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
walletType: WalletType
|
||||
): Result<String?> {
|
||||
return try {
|
||||
val balanceResult = getWcolBalance(proxyAddress)
|
||||
val balance = balanceResult.getOrElse {
|
||||
logger.warn("查询 WCOL 余额失败,跳过解包: ${it.message}")
|
||||
return Result.success(null)
|
||||
}
|
||||
if (balance == BigInteger.ZERO) {
|
||||
return Result.success(null)
|
||||
}
|
||||
val unwrapTx = relayClientService.createUnwrapWcolTx(proxyAddress, balance)
|
||||
val executeResult = relayClientService.execute(privateKey, proxyAddress, unwrapTx, walletType)
|
||||
executeResult.fold(
|
||||
onSuccess = { txHash ->
|
||||
logger.info("WCOL 解包成功: proxy=${proxyAddress.take(10)}..., txHash=$txHash")
|
||||
Result.success(txHash)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("WCOL 解包失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("WCOL 解包异常: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
||||
*/
|
||||
|
||||
+10
-1
@@ -19,6 +19,7 @@ import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import jakarta.annotation.PreDestroy
|
||||
|
||||
/**
|
||||
* 尾盘策略订单 TG 通知轮询服务(与跟单一致)
|
||||
@@ -36,7 +37,8 @@ class CryptoTailOrderNotificationPollingService(
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationPollingService::class.java)
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val scopeJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + scopeJob)
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
|
||||
@@ -143,4 +145,11 @@ class CryptoTailOrderNotificationPollingService(
|
||||
logger.info("尾盘订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
|
||||
return true
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
notificationJob?.cancel()
|
||||
notificationJob = null
|
||||
scopeJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+118
-64
@@ -3,12 +3,15 @@ package com.wrbug.polymarketbot.service.cryptotail
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.toJson
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -26,6 +29,7 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Service
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
@@ -38,12 +42,14 @@ class CryptoTailOrderbookWsService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val executionService: CryptoTailStrategyExecutionService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||
private val binanceKlineService: BinanceKlineService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private val scopeJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + scopeJob)
|
||||
|
||||
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
|
||||
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
|
||||
@@ -64,6 +70,12 @@ class CryptoTailOrderbookWsService(
|
||||
/** 保护 connect() 的互斥锁,避免多线程并发创建连接 */
|
||||
private val connectLock = Any()
|
||||
|
||||
/** 保护 refreshAndSubscribe() 的互斥锁,避免多线程并发刷新订阅 */
|
||||
private val refreshLock = Any()
|
||||
|
||||
/** 标记是否正在刷新订阅,避免重复调用 */
|
||||
private val isRefreshing = AtomicBoolean(false)
|
||||
|
||||
data class WsBookEntry(
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
@@ -77,6 +89,26 @@ class CryptoTailOrderbookWsService(
|
||||
if (strategyRepository.findAllByEnabledTrue().isNotEmpty()) connect()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
periodEndCountdownJob?.cancel()
|
||||
periodEndCountdownJob = null
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
synchronized(precomputeJobs) {
|
||||
precomputeJobs.forEach { it.cancel() }
|
||||
precomputeJobs.clear()
|
||||
}
|
||||
closedForNoStrategies.set(true)
|
||||
try {
|
||||
webSocket?.close(1000, "shutdown")
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}")
|
||||
}
|
||||
webSocket = null
|
||||
scopeJob.cancel()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
synchronized(connectLock) {
|
||||
if (webSocket != null) return
|
||||
@@ -135,8 +167,13 @@ class CryptoTailOrderbookWsService(
|
||||
val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||
val bids = json.get("bids") as? com.google.gson.JsonArray
|
||||
if (bids == null || bids.isEmpty) return
|
||||
val firstBid = bids.get(0) as? com.google.gson.JsonObject
|
||||
val bestBid = (firstBid?.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal()
|
||||
// Polymarket book 的 bids 为价格升序,bids[0] 为最低买价;bestBid 应取最高买价
|
||||
var bestBid: BigDecimal? = null
|
||||
for (i in 0 until bids.size()) {
|
||||
val level = bids.get(i) as? com.google.gson.JsonObject ?: continue
|
||||
val p = (level.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal() ?: continue
|
||||
if (bestBid == null || p.gt(bestBid)) bestBid = p
|
||||
}
|
||||
if (bestBid != null) onBestBid(assetId, bestBid)
|
||||
}
|
||||
|
||||
@@ -156,10 +193,7 @@ class CryptoTailOrderbookWsService(
|
||||
private fun onBestBid(tokenId: String, bestBid: BigDecimal) {
|
||||
if (closedForNoStrategies.get()) return
|
||||
val entries = tokenToEntries.get()[tokenId]
|
||||
if (entries == null) {
|
||||
logger.debug("tokenToEntries null: $tokenId")
|
||||
return
|
||||
}
|
||||
if (entries == null) return
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
for (e in entries) {
|
||||
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
|
||||
@@ -167,16 +201,14 @@ class CryptoTailOrderbookWsService(
|
||||
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
|
||||
scope.launch {
|
||||
try {
|
||||
runBlocking {
|
||||
executionService.tryTriggerWithPriceFromWs(
|
||||
strategy = e.strategy,
|
||||
periodStartUnix = e.periodStartUnix,
|
||||
marketTitle = e.marketTitle,
|
||||
tokenIds = e.tokenIds,
|
||||
outcomeIndex = e.outcomeIndex,
|
||||
bestBid = bestBid
|
||||
)
|
||||
}
|
||||
executionService.tryTriggerWithPriceFromWs(
|
||||
strategy = e.strategy,
|
||||
periodStartUnix = e.periodStartUnix,
|
||||
marketTitle = e.marketTitle,
|
||||
tokenIds = e.tokenIds,
|
||||
outcomeIndex = e.outcomeIndex,
|
||||
bestBid = bestBid
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
||||
}
|
||||
@@ -209,42 +241,56 @@ class CryptoTailOrderbookWsService(
|
||||
}
|
||||
|
||||
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
|
||||
periodEndCountdownJob?.cancel()
|
||||
periodEndCountdownJob = null
|
||||
val oldTokenIds = tokenToEntries.get().keys.toSet()
|
||||
val (tokenIds, newMap) = buildSubscriptionMap()
|
||||
tokenToEntries.set(newMap)
|
||||
if (tokenIds.isEmpty()) {
|
||||
closeWebSocketForNoStrategies()
|
||||
return
|
||||
}
|
||||
if (!fromConnect) {
|
||||
if (webSocket == null) {
|
||||
connect()
|
||||
synchronized(refreshLock) {
|
||||
// 如果正在刷新,直接返回,避免重复调用
|
||||
if (isRefreshing.get()) {
|
||||
logger.debug("尾盘策略订阅刷新已在进行中,跳过本次调用")
|
||||
return
|
||||
}
|
||||
if (oldTokenIds == tokenIds.toSet()) {
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
||||
return
|
||||
}
|
||||
closeWebSocketAndReconnect()
|
||||
return
|
||||
isRefreshing.set(true)
|
||||
}
|
||||
val marketSlugs = newMap.values.asSequence().flatten()
|
||||
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.map { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.toList()
|
||||
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||
try {
|
||||
webSocket?.send(msg)
|
||||
logger.info("尾盘策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订阅失败: ${e.message}")
|
||||
return
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
binanceKlineService.updateSubscriptions(strategies.map { it.marketSlugPrefix }.toSet())
|
||||
periodEndCountdownJob?.cancel()
|
||||
periodEndCountdownJob = null
|
||||
val oldTokenIds = tokenToEntries.get().keys.toSet()
|
||||
val (tokenIds, newMap) = buildSubscriptionMap()
|
||||
tokenToEntries.set(newMap)
|
||||
if (tokenIds.isEmpty()) {
|
||||
closeWebSocketForNoStrategies()
|
||||
return
|
||||
}
|
||||
if (!fromConnect) {
|
||||
if (webSocket == null) {
|
||||
connect()
|
||||
return
|
||||
}
|
||||
if (oldTokenIds == tokenIds.toSet()) {
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoSpreadForCurrentPeriods(newMap)
|
||||
return
|
||||
}
|
||||
closeWebSocketAndReconnect()
|
||||
return
|
||||
}
|
||||
val marketSlugs = newMap.values.asSequence().flatten()
|
||||
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.map { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.toList()
|
||||
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||
try {
|
||||
webSocket?.send(msg)
|
||||
logger.info("尾盘策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订阅失败: ${e.message}")
|
||||
return
|
||||
}
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoSpreadForCurrentPeriods(newMap)
|
||||
} finally {
|
||||
isRefreshing.set(false)
|
||||
}
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,31 +309,39 @@ class CryptoTailOrderbookWsService(
|
||||
}
|
||||
}
|
||||
|
||||
/** 跟踪预计算价差的协程 Job,用于在关闭时取消 */
|
||||
private val precomputeJobs = mutableSetOf<Job>()
|
||||
|
||||
/**
|
||||
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期最小价差,触发时直接用缓存。
|
||||
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期价差,触发时直接用缓存。
|
||||
*/
|
||||
private fun precomputeAutoMinSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
||||
private fun precomputeAutoSpreadForCurrentPeriods(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 }
|
||||
.filter { it.strategy.spreadMode == SpreadMode.AUTO }
|
||||
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
|
||||
.map { Triple(it.strategy.marketSlugPrefix, it.strategy.intervalSeconds, it.periodStartUnix) }
|
||||
.toList()
|
||||
if (autoPeriods.isEmpty()) return
|
||||
scope.launch {
|
||||
for ((intervalSeconds, periodStartUnix) in autoPeriods) {
|
||||
val job = scope.launch {
|
||||
for ((marketPrefix, intervalSeconds, periodStartUnix) in autoPeriods) {
|
||||
try {
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(marketPrefix, intervalSeconds, periodStartUnix)
|
||||
if (pair != null) {
|
||||
logger.info(
|
||||
"周期开始初始价差: interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
|
||||
"周期开始初始价差: market=$marketPrefix interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
|
||||
"baseSpreadUp=${pair.first.toPlainString()} baseSpreadDown=${pair.second.toPlainString()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("周期开始预计算 AUTO 价差失败: interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
|
||||
logger.warn("周期开始预计算 AUTO 价差失败: market=$marketPrefix interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
synchronized(precomputeJobs) {
|
||||
precomputeJobs.add(job)
|
||||
// 清理已完成的 Job,避免集合无限增长
|
||||
precomputeJobs.removeIf { !it.isActive }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,7 +395,7 @@ class CryptoTailOrderbookWsService(
|
||||
continue
|
||||
}
|
||||
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||
val event = fetchEventBySlugWithRetry(slug).getOrNull()
|
||||
val event = runBlocking { fetchEventBySlugWithRetry(slug).getOrNull() }
|
||||
if (event == null) {
|
||||
logger.warn("尾盘策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试")
|
||||
continue
|
||||
@@ -368,21 +422,21 @@ class CryptoTailOrderbookWsService(
|
||||
}
|
||||
|
||||
/** 拉取事件,失败时重试最多 2 次(间隔 1s),避免瞬时失败导致多策略只订阅到其中一个 */
|
||||
private fun fetchEventBySlugWithRetry(slug: String, maxAttempts: Int = 3): Result<GammaEventBySlugResponse> {
|
||||
private suspend fun fetchEventBySlugWithRetry(slug: String, maxAttempts: Int = 3): Result<GammaEventBySlugResponse> {
|
||||
var lastFailure: Exception? = null
|
||||
repeat(maxAttempts) { attempt ->
|
||||
val result = fetchEventBySlug(slug)
|
||||
if (result.isSuccess) return result
|
||||
lastFailure = result.exceptionOrNull() as? Exception
|
||||
if (attempt < maxAttempts - 1) runBlocking { delay(1000L) }
|
||||
if (attempt < maxAttempts - 1) delay(1000L)
|
||||
}
|
||||
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
|
||||
}
|
||||
|
||||
private fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val api = retrofitFactory.createGammaApi()
|
||||
val response = runBlocking { api.getEventBySlug(slug) }
|
||||
val response = api.getEventBySlug(slug)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
|
||||
+10
-1
@@ -22,6 +22,7 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
@@ -44,7 +45,8 @@ class CryptoTailSettlementService(
|
||||
private val triggerFixedPrice = BigDecimal("0.99")
|
||||
private val pnlScale = 8
|
||||
|
||||
private val settlementScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val settlementScopeJob = SupervisorJob()
|
||||
private val settlementScope = CoroutineScope(Dispatchers.IO + settlementScopeJob)
|
||||
|
||||
/** 跟踪上一轮结算任务的 Job,防止并发执行(与 OrderStatusUpdateService 一致) */
|
||||
@Volatile
|
||||
@@ -273,4 +275,11 @@ class CryptoTailSettlementService(
|
||||
amountUsdc.negate()
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
settlementJob?.cancel()
|
||||
settlementJob = null
|
||||
settlementScopeJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+95
-32
@@ -6,6 +6,8 @@ import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
@@ -26,6 +28,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -34,6 +37,9 @@ import java.util.regex.Pattern
|
||||
/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
||||
private const val TRIGGER_FIXED_PRICE = "0.99"
|
||||
|
||||
/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */
|
||||
private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02"
|
||||
|
||||
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
||||
private const val SIZE_DECIMAL_SCALE = 2
|
||||
|
||||
@@ -78,10 +84,25 @@ class CryptoTailStrategyExecutionService(
|
||||
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
|
||||
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
/** 过期锁 key 保留时间(秒),超过则清理,防止 map 无界增长 */
|
||||
private val triggerMutexExpireSeconds = 3600L
|
||||
|
||||
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
|
||||
|
||||
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex =
|
||||
triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
|
||||
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex {
|
||||
cleanExpiredTriggerMutexKeys()
|
||||
return triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
|
||||
}
|
||||
|
||||
/** 清理已过期的 (strategyId, periodStartUnix) 锁,避免内存泄漏 */
|
||||
private fun cleanExpiredTriggerMutexKeys() {
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val expireThreshold = nowSeconds - triggerMutexExpireSeconds
|
||||
val keysToRemove = triggerMutexMap.keys.filter { key ->
|
||||
key.substringAfterLast('-').toLongOrNull()?.let { it < expireThreshold } ?: false
|
||||
}
|
||||
keysToRemove.forEach { triggerMutexMap.remove(it) }
|
||||
}
|
||||
|
||||
/** 周期预置上下文缓存:(strategyId-periodStartUnix) -> PeriodContext,过期周期在读取时剔除 */
|
||||
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
|
||||
@@ -160,11 +181,20 @@ class CryptoTailStrategyExecutionService(
|
||||
val ctx = periodContextCache[key] ?: return null
|
||||
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
|
||||
periodContextCache.remove(key)
|
||||
cleanExpiredPeriodContextCache(nowSeconds)
|
||||
return null
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** 清理已过期的周期上下文缓存,避免内存泄漏 */
|
||||
private fun cleanExpiredPeriodContextCache(nowSeconds: Long) {
|
||||
val keysToRemove = periodContextCache.entries
|
||||
.filter { (_, ctx) -> ctx.periodStartUnix + ctx.strategy.intervalSeconds <= nowSeconds }
|
||||
.map { it.key }
|
||||
keysToRemove.forEach { periodContextCache.remove(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 由订单簿 WebSocket 触发:当收到某 token 的 bestBid 且满足区间时调用,若本周期未触发则下单。
|
||||
*/
|
||||
@@ -185,58 +215,65 @@ class CryptoTailStrategyExecutionService(
|
||||
val logKey = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
if (conditionLoggedCache.getIfPresent(logKey) == null) {
|
||||
conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds)
|
||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
|
||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.marketSlugPrefix, strategy.intervalSeconds, periodStartUnix)
|
||||
val openPrice = oc?.first?.toPlainString() ?: "-"
|
||||
val closePrice = oc?.second?.toPlainString() ?: "-"
|
||||
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}"
|
||||
val direction = if (outcomeIndex == 0) "Up" else "Down"
|
||||
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
|
||||
logger.info(
|
||||
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
|
||||
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
|
||||
"direction=$direction, outcomeIndex=$outcomeIndex"
|
||||
"direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr"
|
||||
)
|
||||
}
|
||||
if (!passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
||||
if (!passSpreadCheck(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)
|
||||
private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
||||
if (strategy.spreadMode == SpreadMode.NONE) return true
|
||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.marketSlugPrefix, strategy.intervalSeconds, periodStartUnix)
|
||||
?: return false
|
||||
val (openP, closeP) = oc
|
||||
val spreadAbs = closeP.subtract(openP).abs()
|
||||
when (mode) {
|
||||
"FIXED" -> {
|
||||
val effectiveMinSpread = strategy.minSpreadValue?.takeIf { it > BigDecimal.ZERO }
|
||||
if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true
|
||||
return spreadAbs >= effectiveMinSpread
|
||||
|
||||
// 获取有效价差
|
||||
val effectiveSpread = when (strategy.spreadMode) {
|
||||
SpreadMode.FIXED -> {
|
||||
strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||
}
|
||||
"AUTO" -> {
|
||||
val result = computeAutoEffectiveMinSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
||||
val effectiveMinSpread = result.effectiveMinSpread
|
||||
if (effectiveMinSpread <= BigDecimal.ZERO) return true
|
||||
return spreadAbs >= effectiveMinSpread
|
||||
SpreadMode.AUTO -> {
|
||||
val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
||||
result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||
}
|
||||
else -> return true
|
||||
SpreadMode.NONE -> return true
|
||||
}
|
||||
|
||||
// 根据价差方向判断
|
||||
return if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||
// 最大价差模式:价差 <= 配置值时触发
|
||||
spreadAbs <= effectiveSpread
|
||||
} else {
|
||||
// 最小价差模式:价差 >= 配置值时触发
|
||||
spreadAbs >= effectiveSpread
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效最小价差。
|
||||
* AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效价差。
|
||||
*/
|
||||
private data class AutoSpreadResult(
|
||||
val baseSpread: BigDecimal,
|
||||
val coefficient: BigDecimal,
|
||||
val effectiveMinSpread: BigDecimal
|
||||
val effectiveSpread: BigDecimal
|
||||
)
|
||||
|
||||
private fun computeAutoEffectiveMinSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? {
|
||||
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.intervalSeconds, periodStartUnix, outcomeIndex)
|
||||
?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second }
|
||||
private fun computeAutoEffectiveSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? {
|
||||
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.marketSlugPrefix, strategy.intervalSeconds, periodStartUnix, outcomeIndex)
|
||||
?: binanceKlineAutoSpreadService.computeAndCache(strategy.marketSlugPrefix, 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
|
||||
@@ -251,8 +288,8 @@ class CryptoTailStrategyExecutionService(
|
||||
.let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) }
|
||||
BigDecimal.ONE.subtract(progress.multi("0.5"))
|
||||
}
|
||||
val effectiveMinSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
|
||||
return AutoSpreadResult(baseSpread, coefficient, effectiveMinSpread)
|
||||
val effectiveSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
|
||||
return AutoSpreadResult(baseSpread, coefficient, effectiveSpread)
|
||||
}
|
||||
|
||||
private suspend fun placeOrderForTrigger(
|
||||
@@ -284,7 +321,15 @@ class CryptoTailStrategyExecutionService(
|
||||
return
|
||||
}
|
||||
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
// 根据价差方向确定下单价格
|
||||
val price = if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||
// 最大价差模式:触发价格 + 0.02
|
||||
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
// 最小价差模式:固定价格 0.99
|
||||
BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
}
|
||||
val priceStr = price.toPlainString()
|
||||
val size = computeSize(amountUsdc, price)
|
||||
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
@@ -292,7 +337,7 @@ class CryptoTailStrategyExecutionService(
|
||||
makerAddress = ctx.account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
price = priceStr,
|
||||
size = size,
|
||||
signatureType = ctx.signatureType,
|
||||
nonce = "0",
|
||||
@@ -335,7 +380,7 @@ class CryptoTailStrategyExecutionService(
|
||||
failReason = body.errorMsg ?: "unknown"
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
failReason = "HTTP ${response.code()} $errorBody"
|
||||
failReason = errorBody.ifEmpty { "请求失败" }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failReason = e.message ?: e.toString()
|
||||
@@ -380,7 +425,16 @@ class CryptoTailStrategyExecutionService(
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
||||
return
|
||||
}
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
|
||||
// 根据价差方向确定下单价格
|
||||
val price = if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||
// 最大价差模式:触发价格 + 0.02
|
||||
triggerPrice.add(BigDecimal(SPREAD_MAX_PRICE_ADJUSTMENT)).setScale(8, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
// 最小价差模式:固定价格 0.99
|
||||
BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
}
|
||||
val priceStr = price.toPlainString()
|
||||
val size = computeSize(amountUsdc, price)
|
||||
|
||||
val decryptedKey = try {
|
||||
@@ -405,7 +459,7 @@ class CryptoTailStrategyExecutionService(
|
||||
makerAddress = account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
price = priceStr,
|
||||
size = size,
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
@@ -466,4 +520,13 @@ class CryptoTailStrategyExecutionService(
|
||||
)
|
||||
triggerRepository.save(record)
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
// 清理所有周期上下文缓存,避免敏感信息(明文私钥、API Secret)在内存中保留
|
||||
periodContextCache.clear()
|
||||
// 清理所有锁,避免内存泄漏
|
||||
triggerMutexMap.clear()
|
||||
logger.debug("尾盘策略执行服务已清理缓存和锁")
|
||||
}
|
||||
}
|
||||
|
||||
+40
-15
@@ -4,6 +4,8 @@ import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
@@ -62,12 +64,18 @@ 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") {
|
||||
val spreadMode = try {
|
||||
SpreadMode.fromString(request.spreadMode)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val minSpreadValue = request.minSpreadValue?.toSafeBigDecimal()
|
||||
if (minSpreadMode == "FIXED" && (minSpreadValue == null || minSpreadValue < BigDecimal.ZERO)) {
|
||||
val spreadValue = request.spreadValue?.toSafeBigDecimal()
|
||||
if (spreadMode == SpreadMode.FIXED && (spreadValue == null || spreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val spreadDirection = try {
|
||||
SpreadDirection.fromString(request.spreadDirection)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
|
||||
@@ -85,8 +93,9 @@ class CryptoTailStrategyService(
|
||||
maxPrice = maxPrice,
|
||||
amountMode = amountMode,
|
||||
amountValue = amountValue,
|
||||
minSpreadMode = minSpreadMode,
|
||||
minSpreadValue = minSpreadValue,
|
||||
spreadMode = spreadMode,
|
||||
spreadValue = spreadValue,
|
||||
spreadDirection = spreadDirection,
|
||||
enabled = request.enabled
|
||||
)
|
||||
val saved = strategyRepository.save(entity)
|
||||
@@ -121,13 +130,27 @@ class CryptoTailStrategyService(
|
||||
?: existing.name?.takeIf { it.isNotBlank() }
|
||||
?: generateStrategyName(existing.marketSlugPrefix)
|
||||
|
||||
val newMinSpreadMode = request.minSpreadMode?.uppercase() ?: existing.minSpreadMode
|
||||
if (newMinSpreadMode != "NONE" && newMinSpreadMode != "FIXED" && newMinSpreadMode != "AUTO") {
|
||||
val newSpreadMode = if (request.spreadMode != null) {
|
||||
try {
|
||||
SpreadMode.fromString(request.spreadMode)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
} else {
|
||||
existing.spreadMode
|
||||
}
|
||||
val newSpreadValue = request.spreadValue?.toSafeBigDecimal() ?: existing.spreadValue
|
||||
if (newSpreadMode == SpreadMode.FIXED && (newSpreadValue == null || newSpreadValue < BigDecimal.ZERO)) {
|
||||
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 newSpreadDirection = if (request.spreadDirection != null) {
|
||||
try {
|
||||
SpreadDirection.fromString(request.spreadDirection)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
} else {
|
||||
existing.spreadDirection
|
||||
}
|
||||
|
||||
val updated = existing.copy(
|
||||
@@ -138,8 +161,9 @@ class CryptoTailStrategyService(
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
|
||||
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
|
||||
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
|
||||
minSpreadMode = newMinSpreadMode,
|
||||
minSpreadValue = newMinSpreadValue,
|
||||
spreadMode = newSpreadMode,
|
||||
spreadValue = newSpreadValue,
|
||||
spreadDirection = newSpreadDirection,
|
||||
enabled = request.enabled ?: existing.enabled,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
@@ -263,8 +287,9 @@ class CryptoTailStrategyService(
|
||||
maxPrice = e.maxPrice.toPlainString(),
|
||||
amountMode = e.amountMode,
|
||||
amountValue = e.amountValue.toPlainString(),
|
||||
minSpreadMode = e.minSpreadMode,
|
||||
minSpreadValue = e.minSpreadValue?.toPlainString(),
|
||||
spreadMode = e.spreadMode.name,
|
||||
spreadValue = e.spreadValue?.toPlainString(),
|
||||
spreadDirection = e.spreadDirection.name,
|
||||
enabled = e.enabled,
|
||||
lastTriggerAt = lastTriggerAt,
|
||||
totalRealizedPnl = totalPnl?.toPlainString(),
|
||||
|
||||
+8
-1
@@ -243,7 +243,14 @@ class ApiHealthCheckService(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "success",
|
||||
message = "连接正常 (5m、15m)"
|
||||
message = "连接正常 (按策略订阅)"
|
||||
)
|
||||
} else if (total == 0) {
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "success",
|
||||
message = "无尾盘策略,未订阅"
|
||||
)
|
||||
} else if (connected > 0) {
|
||||
val which = statuses.filter { it.value }.keys.joinToString("、")
|
||||
|
||||
+321
-20
@@ -5,12 +5,16 @@ import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.util.Eip712Encoder
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import kotlinx.coroutines.delay
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import retrofit2.Response
|
||||
import java.math.BigInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* RelayClient 服务
|
||||
@@ -20,8 +24,8 @@ import java.math.BigInteger
|
||||
* 如果需要真正的 Gasless 功能,需要集成 Builder Relayer API
|
||||
*
|
||||
* 参考:
|
||||
* - TypeScript: @polymarket/builder-relayer-client
|
||||
* - TypeScript: utils/redeem.ts
|
||||
* - TypeScript: https://github.com/Polymarket/builder-relayer-client(client.execute、src/encode/safe.ts MultiSend)
|
||||
* - 赎回 calldata 由本服务构建,官方仓库无 redeem 工具;Neg Risk 逻辑见 docs/neg-risk-redeem.md
|
||||
*/
|
||||
@Service
|
||||
class RelayClientService(
|
||||
@@ -35,9 +39,12 @@ class RelayClientService(
|
||||
// ConditionalTokens 合约地址
|
||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
|
||||
// USDC.e 合约地址
|
||||
// USDC.e 合约地址(普通市场抵押品)
|
||||
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
// Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygon,neg-risk-ctf-adapter)
|
||||
private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||
|
||||
// 空集合ID
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
@@ -45,16 +52,76 @@ class RelayClientService(
|
||||
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
||||
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
|
||||
private val defaultProxyGasLimit = "10000000"
|
||||
|
||||
// Safe MultiSend 合约地址(Polygon 主网)
|
||||
private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
|
||||
|
||||
// Builder Relayer API 交易类型常量
|
||||
private val RELAYER_TYPE_PROXY = "PROXY"
|
||||
private val RELAYER_TYPE_SAFE = "SAFE"
|
||||
private val RELAYER_TYPE_SAFE_CREATE = "SAFE-CREATE"
|
||||
|
||||
// Safe 代理工厂(用于 SAFE-CREATE 部署)
|
||||
private val safeProxyFactoryAddress = PolymarketConstants.SAFE_PROXY_FACTORY_ADDRESS
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
||||
}
|
||||
|
||||
/** 遇到 429 限流时的重试次数 */
|
||||
private val builderRelayerRateLimitMaxAttempts = 3
|
||||
|
||||
/** 429 限流重试退避基数(毫秒),第 n 次重试等待 baseMs * 2^(n-1) */
|
||||
private val builderRelayerRateLimitBackoffMs = 2000L
|
||||
|
||||
/** Builder Relayer 配额用尽后的冷却截止时间(毫秒时间戳),在此时间前不再发起赎回 */
|
||||
private val builderRelayerQuotaBlockedUntilMs = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 是否处于 Builder Relayer 配额冷却期(配额用尽后在该时间内不再发起赎回)。
|
||||
*/
|
||||
fun isBuilderRelayerQuotaBlocked(): Boolean = System.currentTimeMillis() < builderRelayerQuotaBlockedUntilMs.get()
|
||||
|
||||
/**
|
||||
* 配额冷却剩余秒数,未在冷却期时返回 0。
|
||||
*/
|
||||
fun getBuilderRelayerQuotaBlockedRemainingSeconds(): Long {
|
||||
val remaining = (builderRelayerQuotaBlockedUntilMs.get() - System.currentTimeMillis()) / 1000
|
||||
return maxOf(0, remaining)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 API 错误响应中解析 "quota exceeded... resets in N seconds",并设置配额冷却截止时间。
|
||||
*/
|
||||
private fun updateQuotaBlockedFromErrorBody(errorBody: String) {
|
||||
if (!errorBody.contains("quota exceeded", ignoreCase = true)) return
|
||||
val regex = Regex("resets\\s+in\\s+(\\d+)\\s+seconds", RegexOption.IGNORE_CASE)
|
||||
regex.find(errorBody)?.groupValues?.getOrNull(1)?.toLongOrNull()?.let { seconds ->
|
||||
val untilMs = System.currentTimeMillis() + seconds * 1000
|
||||
builderRelayerQuotaBlockedUntilMs.set(untilMs)
|
||||
logger.warn("Builder Relayer 配额已用尽,${seconds}秒内不再发起赎回")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 Builder Relayer API 调用进行 429 限流重试(指数退避)。
|
||||
* 当 HTTP 状态为 429(Too Many Requests,如 Cloudflare 1015)时等待后重试,避免瞬时限流导致赎回失败。
|
||||
*/
|
||||
private suspend fun <T> withBuilderRelayerRateLimitRetry(block: suspend () -> Response<T>): Response<T> {
|
||||
var lastResponse: Response<T>? = null
|
||||
for (attempt in 1..builderRelayerRateLimitMaxAttempts) {
|
||||
val response = block()
|
||||
lastResponse = response
|
||||
if (response.code() != 429) return response
|
||||
if (attempt == builderRelayerRateLimitMaxAttempts) return response
|
||||
val delayMs = builderRelayerRateLimitBackoffMs * (1L shl (attempt - 1))
|
||||
logger.warn("Builder Relayer API 限流(429),${delayMs}ms 后重试 (${attempt}/${builderRelayerRateLimitMaxAttempts})")
|
||||
delay(delayMs)
|
||||
}
|
||||
return lastResponse!!
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Builder Relayer API 客户端(动态获取,因为配置可能更新)
|
||||
*/
|
||||
@@ -125,6 +192,7 @@ class RelayClientService(
|
||||
Result.success(responseTime)
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -170,19 +238,22 @@ class RelayClientService(
|
||||
/**
|
||||
* 创建赎回交易(支持多个 indexSets,用于批量赎回)
|
||||
* 参考 TypeScript: utils/redeem.ts 的 createRedeemTx
|
||||
* Neg Risk 市场使用 WrappedCollateral 作为抵押品,需传 isNegRisk=true
|
||||
*
|
||||
* @param conditionId 市场条件ID
|
||||
* @param indexSets 索引集合列表(每个元素是 2^outcomeIndex)
|
||||
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 地址)
|
||||
* @return Safe 交易对象
|
||||
*/
|
||||
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>): SafeTransaction {
|
||||
fun createRedeemTx(conditionId: String, indexSets: List<BigInteger>, isNegRisk: Boolean = false): SafeTransaction {
|
||||
// 编码 redeemPositions 函数调用
|
||||
val functionSelector = EthereumUtils.getFunctionSelector(
|
||||
"redeemPositions(address,bytes32,bytes32,uint256[])"
|
||||
)
|
||||
|
||||
// 编码参数
|
||||
val encodedCollateral = EthereumUtils.encodeAddress(usdcContractAddress)
|
||||
// Neg Risk 市场仓位由 WrappedCollateral 抵押,普通市场由 USDC 抵押
|
||||
val collateralAddress = if (isNegRisk) negRiskWrappedCollateralAddress else usdcContractAddress
|
||||
val encodedCollateral = EthereumUtils.encodeAddress(collateralAddress)
|
||||
val encodedParentCollection = EthereumUtils.encodeBytes32(EMPTY_SET)
|
||||
val encodedConditionId = EthereumUtils.encodeBytes32(conditionId)
|
||||
|
||||
@@ -210,6 +281,115 @@ class RelayClientService(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 WCOL 解包交易(将 Wrapped Collateral 解包为 USDC.e)
|
||||
* 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
|
||||
* 方法: unwrap(address _to, uint256 _amount),解包后 USDC.e 转到 _to
|
||||
*
|
||||
* Safe 与 Magic 共用此交易对象:Safe 走 [executeViaBuilderRelayer] / [executeManually](execTransaction),
|
||||
* Magic 走 [executeViaBuilderRelayerProxy](encodeProxyTransactionData),语义一致。
|
||||
*
|
||||
* @param toAddress 接收 USDC.e 的地址(通常为 proxy 自身,使余额留在代理钱包)
|
||||
* @param amountWei WCOL 数量(6 位小数对应的 raw 值,与 balanceOf 返回一致)
|
||||
* @return Safe 交易对象
|
||||
*/
|
||||
fun createUnwrapWcolTx(toAddress: String, amountWei: BigInteger): SafeTransaction {
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("unwrap(address,uint256)")
|
||||
val encodedTo = EthereumUtils.encodeAddress(toAddress)
|
||||
val encodedAmount = EthereumUtils.encodeUint256(amountWei)
|
||||
val callData = "0x" + functionSelector.removePrefix("0x") + encodedTo + encodedAmount
|
||||
return SafeTransaction(
|
||||
to = negRiskWrappedCollateralAddress,
|
||||
operation = 0, // CALL
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 USDC approve 交易(ERC20 approve(spender, amount))
|
||||
* 用于 Polymarket 设置步骤3:代币授权
|
||||
*/
|
||||
fun createUsdcApproveTx(spender: String, amount: BigInteger): SafeTransaction {
|
||||
val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)")
|
||||
val encodedSpender = EthereumUtils.encodeAddress(spender)
|
||||
val encodedAmount = EthereumUtils.encodeUint256(amount)
|
||||
val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount
|
||||
return SafeTransaction(
|
||||
to = usdcContractAddress,
|
||||
operation = 0, // CALL
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MultiSend 交易(合并多个 SafeTransaction 为一笔交易)
|
||||
* 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction
|
||||
*
|
||||
* 使用 Gnosis Safe 的 MultiSend 合约将多个交易合并为一笔 DelegateCall 交易
|
||||
*
|
||||
* @param safeTxs 多个 Safe 交易
|
||||
* @return 合并后的 MultiSend 交易(operation = 1 = DelegateCall)
|
||||
*/
|
||||
fun createMultiSendTx(safeTxs: List<SafeTransaction>): SafeTransaction {
|
||||
if (safeTxs.isEmpty()) {
|
||||
throw IllegalArgumentException("safeTxs 不能为空")
|
||||
}
|
||||
|
||||
// 单个交易直接返回,不需要 MultiSend
|
||||
if (safeTxs.size == 1) {
|
||||
logger.debug("单个交易,不使用 MultiSend")
|
||||
return safeTxs.first()
|
||||
}
|
||||
|
||||
logger.debug("创建 MultiSend 交易: ${safeTxs.size} 个交易待合并")
|
||||
|
||||
// MultiSend 函数选择器:multiSend(bytes)
|
||||
val multiSendSelector = EthereumUtils.getFunctionSelector("multiSend(bytes)")
|
||||
|
||||
// 编码每个交易:encodePacked([uint8 operation, address to, uint256 value, uint256 dataLength, bytes data])
|
||||
// 与 builder-relayer-client encode/safe.ts 完全一致
|
||||
val encodedTransactions = safeTxs.map { tx ->
|
||||
val operation = tx.operation.toByte()
|
||||
// address: 20 字节,右对齐(取最后 40 个十六进制字符)
|
||||
val toHex = tx.to.removePrefix("0x").lowercase().padStart(40, '0').takeLast(40)
|
||||
val to = EthereumUtils.hexToBytes(toHex)
|
||||
// value: 32 字节大端
|
||||
val valueHex = BigInteger(tx.value).toString(16).padStart(64, '0')
|
||||
val value = EthereumUtils.hexToBytes(valueHex)
|
||||
|
||||
val dataBytes = EthereumUtils.hexToBytes(tx.data.removePrefix("0x"))
|
||||
// dataLength: 32 字节大端,表示 data 的字节数
|
||||
val dataLengthHex = BigInteger.valueOf(dataBytes.size.toLong()).toString(16).padStart(64, '0')
|
||||
val dataLength = EthereumUtils.hexToBytes(dataLengthHex)
|
||||
|
||||
// encodePacked: operation(1) + to(20) + value(32) + dataLength(32) + data(variable)
|
||||
byteArrayOf(operation) + to + value + dataLength + dataBytes
|
||||
}
|
||||
|
||||
// 拼接所有交易(无 padding,与 viem concatHex 一致)
|
||||
val concatenatedTransactions = encodedTransactions.reduce { acc, bytes -> acc + bytes }
|
||||
val totalDataLength = concatenatedTransactions.size
|
||||
|
||||
// multiSend(bytes) 的 ABI 编码:offset(32) + length(32) + data(按 32 字节对齐 padding)
|
||||
val paddedLength = ((totalDataLength + 31) / 32) * 32
|
||||
val paddedData = concatenatedTransactions + ByteArray(paddedLength - totalDataLength)
|
||||
|
||||
val encodedOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
val encodedLength = EthereumUtils.encodeUint256(BigInteger.valueOf(totalDataLength.toLong()))
|
||||
val encodedData = paddedData.joinToString("") { "%02x".format(it) }
|
||||
|
||||
val callData = "0x" + multiSendSelector.removePrefix("0x") + encodedOffset + encodedLength + encodedData
|
||||
|
||||
return SafeTransaction(
|
||||
to = safeMultisendAddress,
|
||||
operation = 1, // DelegateCall
|
||||
data = callData,
|
||||
value = "0"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行代理交易(Safe 或 Magic PROXY)
|
||||
* 参考 TypeScript: RelayClient.execute()
|
||||
@@ -294,9 +474,10 @@ class RelayClientService(
|
||||
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
|
||||
val fromAddress = credentials.address
|
||||
|
||||
val relayPayloadResponse = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY)
|
||||
val relayPayloadResponse = withBuilderRelayerRateLimitRetry { relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY) }
|
||||
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
|
||||
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
|
||||
}
|
||||
@@ -360,9 +541,10 @@ class RelayClientService(
|
||||
metadata = "Redeem positions via Builder Relayer PROXY"
|
||||
)
|
||||
|
||||
val response = relayerApi.submitTransaction(request)
|
||||
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
@@ -524,15 +706,27 @@ class RelayClientService(
|
||||
// safeTx.data 已经是带 0x 前缀的完整调用数据
|
||||
val redeemCallData = safeTx.data
|
||||
|
||||
// 获取 Proxy 的 nonce(通过 Builder Relayer API)
|
||||
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE)
|
||||
// 获取 Proxy 的 nonce(通过 Builder Relayer API,遇 429 限流时重试)
|
||||
val nonceResponse = withBuilderRelayerRateLimitRetry { relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE) }
|
||||
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
|
||||
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("获取 nonce 失败: ${nonceResponse.code()} - $errorBody"))
|
||||
}
|
||||
val proxyNonce = BigInteger(nonceResponse.body()!!.nonce)
|
||||
|
||||
// 调试 GS026:记录 nonce 与交易参数,便于与 relayer/链上对比
|
||||
logger.debug(
|
||||
"Safe exec 签名参数: nonce={}, to={}, value={}, dataLen={}, operation={}, proxyWallet={}",
|
||||
proxyNonce,
|
||||
safeTx.to,
|
||||
safeTx.value,
|
||||
redeemCallData.removePrefix("0x").length / 2,
|
||||
safeTx.operation,
|
||||
proxyAddress
|
||||
)
|
||||
|
||||
// 构建 Safe 交易哈希并签名
|
||||
// 注意:encodeSafeTx 需要 data 带 0x 前缀
|
||||
val safeTxGas = BigInteger.ZERO
|
||||
@@ -564,6 +758,12 @@ class RelayClientService(
|
||||
messageHash = safeTxHash
|
||||
)
|
||||
|
||||
// 调试 GS026:记录 EIP-712 structHash 与最终签名的 hash(可与 Safe.getTransactionHash 对比)
|
||||
logger.debug(
|
||||
"Safe exec 哈希: structHash=0x{}, hashToSign 将基于 prefix+structHash 的 keccak256",
|
||||
safeTxStructuredHash.joinToString("") { "%02x".format(it) }
|
||||
)
|
||||
|
||||
// 注意:ethers.js 的 signMessage 会添加 EIP-191 前缀
|
||||
// 格式:\x19Ethereum Signed Message:\n<length><message>
|
||||
// 我们需要模拟这个行为以匹配 TypeScript 实现
|
||||
@@ -578,17 +778,17 @@ class RelayClientService(
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
logger.debug(
|
||||
"Safe exec hashToSign=0x{} (personal_sign 后签名的 32 字节)",
|
||||
hashWithPrefix.joinToString("") { "%02x".format(it) }
|
||||
)
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val safeSignature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
|
||||
// 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
val packedSignature = splitAndPackSig(safeSignature)
|
||||
|
||||
// 调试日志(地址已遮蔽)
|
||||
logger.debug("=== Builder Relayer 签名调试 ===")
|
||||
logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce")
|
||||
logger.debug("Signature Length: ${packedSignature.length}")
|
||||
|
||||
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts)
|
||||
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
@@ -607,16 +807,19 @@ class RelayClientService(
|
||||
gasToken = gasToken,
|
||||
refundReceiver = refundReceiver
|
||||
),
|
||||
metadata = "Redeem positions via Builder Relayer"
|
||||
metadata = if (safeTx.operation == 1) {
|
||||
"MultiSend redeem positions via Builder Relayer"
|
||||
} else {
|
||||
"Redeem positions via Builder Relayer"
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}")
|
||||
|
||||
// 调用 Builder Relayer API(认证头通过拦截器添加)
|
||||
val response = relayerApi.submitTransaction(request)
|
||||
// 调用 Builder Relayer API(认证头通过拦截器添加,遇 429 限流时重试)
|
||||
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("Builder Relayer API 调用失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("Builder Relayer API 调用失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
@@ -629,6 +832,104 @@ class RelayClientService(
|
||||
return Result.success(txHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 部署 Safe 代理(SAFE-CREATE)
|
||||
* 参考: builder-relayer-client client.ts deploy()、builder/create.ts buildSafeCreateTransactionRequest
|
||||
*
|
||||
* @param privateKey EOA 私钥
|
||||
* @param proxyAddress 待部署的 Safe 代理地址(与 getProxyAddress 一致)
|
||||
* @param fromAddress EOA 地址(from)
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun deploySafeViaBuilderRelayer(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
fromAddress: String
|
||||
): Result<String> {
|
||||
return try {
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return Result.failure(IllegalStateException("Builder API Key 未配置,无法执行 Safe 部署"))
|
||||
}
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey!!,
|
||||
secret = builderSecret!!,
|
||||
passphrase = builderPassphrase!!
|
||||
)
|
||||
val zeroAddress = "0x0000000000000000000000000000000000000000"
|
||||
val paymentToken = zeroAddress
|
||||
val payment = "0"
|
||||
val paymentReceiver = zeroAddress
|
||||
val domainSeparator = Eip712Encoder.encodeSafeCreateDomain(
|
||||
name = PolymarketConstants.SAFE_FACTORY_EIP712_NAME,
|
||||
chainId = 137L,
|
||||
verifyingContract = safeProxyFactoryAddress
|
||||
)
|
||||
val createProxyHash = Eip712Encoder.encodeCreateProxyMessage(
|
||||
paymentToken = paymentToken,
|
||||
payment = BigInteger.ZERO,
|
||||
paymentReceiver = paymentReceiver
|
||||
)
|
||||
val digest = Eip712Encoder.hashStructuredData(domainSeparator, createProxyHash)
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(digest, ecKeyPair, false)
|
||||
// SAFE-CREATE 使用标准 EIP-712 签名格式(0x + r + s + v,v 为 27/28),与 signTypedData 一致
|
||||
val signatureHex = signatureToStandardHex(signature)
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = RELAYER_TYPE_SAFE_CREATE,
|
||||
from = fromAddress,
|
||||
to = safeProxyFactoryAddress,
|
||||
proxyWallet = proxyAddress,
|
||||
data = "0x",
|
||||
nonce = null,
|
||||
signature = signatureHex,
|
||||
signatureParams = BuilderRelayerApi.SignatureParams(
|
||||
paymentToken = paymentToken,
|
||||
payment = payment,
|
||||
paymentReceiver = paymentReceiver
|
||||
),
|
||||
metadata = null
|
||||
)
|
||||
val response = withBuilderRelayerRateLimitRetry { relayerApi.submitTransaction(request) }
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
updateQuotaBlockedFromErrorBody(errorBody)
|
||||
logger.error("Builder Relayer SAFE-CREATE 失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("部署 Safe 失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
val relayerResponse = response.body()!!
|
||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
logger.info("Safe 部署成功: proxy=$proxyAddress, txHash=$txHash")
|
||||
Result.success(txHash)
|
||||
} catch (e: Exception) {
|
||||
logger.error("部署 Safe 失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 SignatureData 转为标准 hex 签名(0x + r(64) + s(64) + v(2),v 为 27/28)
|
||||
* 用于 SAFE-CREATE,与 viem signTypedData 输出格式一致
|
||||
*/
|
||||
private fun signatureToStandardHex(signature: org.web3j.crypto.Sign.SignatureData): String {
|
||||
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
|
||||
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
|
||||
val vBytes = signature.v
|
||||
val v = if (vBytes != null && vBytes.isNotEmpty()) {
|
||||
vBytes[0].toInt() and 0xff
|
||||
} else {
|
||||
27
|
||||
}
|
||||
val vHex = String.format("%02x", v)
|
||||
return "0x$rHex$sHex$vHex"
|
||||
}
|
||||
|
||||
/**
|
||||
* 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig)
|
||||
* 将签名打包成 Gnosis Safe 接受的格式:encodePacked(["uint256", "uint256", "uint8"], [r, s, v])
|
||||
|
||||
+4
-17
@@ -25,19 +25,6 @@ class SystemConfigService(
|
||||
const val CONFIG_KEY_BUILDER_SECRET = "builder.secret"
|
||||
const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase"
|
||||
const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem"
|
||||
|
||||
/**
|
||||
* 遮蔽敏感信息,仅显示前4位和后4位
|
||||
* 例如:abcd1234...wxyz5678
|
||||
*/
|
||||
fun maskSensitiveValue(value: String?): String? {
|
||||
if (value == null) return null
|
||||
return when {
|
||||
value.length <= 8 -> "****" // 太短则完全遮蔽
|
||||
value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}"
|
||||
else -> "${value.take(4)}...${value.takeLast(4)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,10 +36,10 @@ class SystemConfigService(
|
||||
val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE)
|
||||
val autoRedeem = isAutoRedeemEnabled()
|
||||
|
||||
// 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置)
|
||||
// 获取完整显示值(用于前端展示与编辑)
|
||||
val builderApiKeyDisplay = builderApiKey?.let {
|
||||
try {
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -60,7 +47,7 @@ class SystemConfigService(
|
||||
|
||||
val builderSecretDisplay = builderSecret?.let {
|
||||
try {
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -68,7 +55,7 @@ class SystemConfigService(
|
||||
|
||||
val builderPassphraseDisplay = builderPassphrase?.let {
|
||||
try {
|
||||
maskSensitiveValue(cryptoUtils.decrypt(it))
|
||||
cryptoUtils.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -377,5 +377,62 @@ object Eip712Encoder {
|
||||
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* SafeCreate 用 EIP712 域(Polymarket Contract Proxy Factory)
|
||||
* Domain: EIP712Domain(string name, uint256 chainId, address verifyingContract)
|
||||
* 参考: builder-relayer-client/src/builder/create.ts createSafeCreateSignature
|
||||
*/
|
||||
fun encodeSafeCreateDomain(
|
||||
name: String,
|
||||
chainId: Long,
|
||||
verifyingContract: String
|
||||
): ByteArray {
|
||||
val domainTypeHash = encodeType(
|
||||
"EIP712Domain",
|
||||
listOf(
|
||||
"name" to "string",
|
||||
"chainId" to "uint256",
|
||||
"verifyingContract" to "address"
|
||||
)
|
||||
)
|
||||
val nameHash = encodeString(name)
|
||||
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
|
||||
val contractBytes = encodeAddress(verifyingContract)
|
||||
val encoded = ByteArray(32 + 32 + 32 + 32)
|
||||
System.arraycopy(domainTypeHash, 0, encoded, 0, 32)
|
||||
System.arraycopy(nameHash, 0, encoded, 32, 32)
|
||||
System.arraycopy(chainIdBytes, 0, encoded, 64, 32)
|
||||
System.arraycopy(contractBytes, 0, encoded, 96, 32)
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateProxy 消息哈希(SafeCreate 签名用)
|
||||
* CreateProxy(address paymentToken, uint256 payment, address paymentReceiver)
|
||||
*/
|
||||
fun encodeCreateProxyMessage(
|
||||
paymentToken: String,
|
||||
payment: BigInteger,
|
||||
paymentReceiver: String
|
||||
): ByteArray {
|
||||
val typeHash = encodeType(
|
||||
"CreateProxy",
|
||||
listOf(
|
||||
"paymentToken" to "address",
|
||||
"payment" to "uint256",
|
||||
"paymentReceiver" to "address"
|
||||
)
|
||||
)
|
||||
val tokenBytes = encodeAddress(paymentToken)
|
||||
val paymentBytes = encodeUint256(payment)
|
||||
val receiverBytes = encodeAddress(paymentReceiver)
|
||||
val encoded = ByteArray(32 + 32 + 32 + 32)
|
||||
System.arraycopy(typeHash, 0, encoded, 0, 32)
|
||||
System.arraycopy(tokenBytes, 0, encoded, 32, 32)
|
||||
System.arraycopy(paymentBytes, 0, encoded, 64, 32)
|
||||
System.arraycopy(receiverBytes, 0, encoded, 96, 32)
|
||||
return keccak256(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -400,8 +400,6 @@ class RpcUrlReplaceInterceptor(
|
||||
private val fixedBaseUrl: String,
|
||||
private val actualRpcUrl: String
|
||||
) : Interceptor {
|
||||
private val logger = LoggerFactory.getLogger(RpcUrlReplaceInterceptor::class.java)
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
@@ -415,8 +413,6 @@ class RpcUrlReplaceInterceptor(
|
||||
val newUrl = newUrlString.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("无效的 RPC URL: $newUrlString")
|
||||
|
||||
logger.debug("RPC URL 替换: $originalUrlString -> $newUrlString")
|
||||
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 尾盘策略价差字段重构:支持最小价差/最大价差方向,使用枚举数值存储
|
||||
-- 1. 重命名 min_spread_mode -> spread_mode,并转换为 TINYINT (0=NONE, 1=FIXED, 2=AUTO)
|
||||
-- 2. 重命名 min_spread_value -> spread_value
|
||||
-- 3. 新增 spread_direction 字段,使用 TINYINT (0=MIN, 1=MAX)
|
||||
|
||||
-- 步骤1: 重命名并迁移 spread_mode 数据
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
ADD COLUMN spread_mode_new TINYINT NOT NULL DEFAULT 0 COMMENT '价差模式: 0=NONE, 1=FIXED, 2=AUTO';
|
||||
|
||||
UPDATE crypto_tail_strategy
|
||||
SET spread_mode_new = CASE
|
||||
WHEN min_spread_mode = 'NONE' THEN 0
|
||||
WHEN min_spread_mode = 'FIXED' THEN 1
|
||||
WHEN min_spread_mode = 'AUTO' THEN 2
|
||||
ELSE 0
|
||||
END;
|
||||
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
DROP COLUMN min_spread_mode,
|
||||
CHANGE COLUMN spread_mode_new spread_mode TINYINT NOT NULL DEFAULT 0 COMMENT '价差模式: 0=NONE, 1=FIXED, 2=AUTO';
|
||||
|
||||
-- 步骤2: 重命名 spread_value
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
CHANGE COLUMN min_spread_value spread_value DECIMAL(20, 8) NULL COMMENT '价差数值(FIXED 时必填;AUTO 时可存计算值)';
|
||||
|
||||
-- 步骤3: 新增 spread_direction 字段
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
ADD COLUMN spread_direction TINYINT NOT NULL DEFAULT 0 COMMENT '价差方向: 0=MIN(价差>=配置值触发), 1=MAX(价差<=配置值触发)';
|
||||
@@ -0,0 +1,32 @@
|
||||
# 尾盘策略文档 (Crypto Tail Strategy)
|
||||
|
||||
本目录集中存放与 Polymarket 加密市场尾盘策略相关的文档。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
crypto-tail-strategy/
|
||||
├── README.md # 本说明
|
||||
├── crypto-tail-auto-spread-dynamic-coefficient.md # 自动价差动态系数(中英通用)
|
||||
├── zh/ # 中文文档
|
||||
│ ├── crypto-tail-strategy-user-guide.md # 用户配置指南
|
||||
│ ├── crypto-tail-strategy-ui-spec.md # UI 规格
|
||||
│ ├── crypto-tail-strategy-tasks.md # 任务与验收
|
||||
│ ├── crypto-tail-strategy-flow.md # 流程说明
|
||||
│ ├── crypto-tail-strategy-min-spread-flow.md # 最小/最大价差流程
|
||||
│ └── crypto-tail-strategy-market-data.md # 市场数据与周期
|
||||
└── en/ # 英文文档
|
||||
└── crypto-tail-strategy-user-guide.md # User configuration guide
|
||||
```
|
||||
|
||||
## 文档说明
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| **user-guide** (zh/en) | 面向用户的策略配置指南与 FAQ |
|
||||
| **ui-spec** (zh) | 前端列表、表单、时间窗口、触发记录等 UI 规格 |
|
||||
| **tasks** (zh) | 开发任务与验收项 |
|
||||
| **flow** (zh) | 策略整体流程 |
|
||||
| **min-spread-flow** (zh) | 价差过滤(最小/最大价差)流程 |
|
||||
| **market-data** (zh) | Gamma slug、周期、时间区间、价格判断等市场数据规则 |
|
||||
| **auto-spread-dynamic-coefficient** | 自动价差模式下动态系数计算说明 |
|
||||
@@ -0,0 +1,469 @@
|
||||
# Crypto Tail Strategy Configuration Guide
|
||||
|
||||
## Part 1: What is Crypto Tail Strategy?
|
||||
|
||||
Crypto Tail Strategy is an automated trading strategy designed specifically for Polymarket crypto markets' **5-minute** or **15-minute** "Up or Down" markets.
|
||||
|
||||
**Core Logic**: Within a specified time window, when the market price enters your set price range, the system will automatically buy at a fixed price (0.99) without manual operation.
|
||||
|
||||
**Use Cases**:
|
||||
- You want to capture price fluctuations at the end of market cycles
|
||||
- You want to automate trading execution and avoid manual monitoring
|
||||
- You have some judgment about market trends and want to set conditions for automatic triggering
|
||||
|
||||
---
|
||||
|
||||
## Part 2: How the Strategy Works
|
||||
|
||||
### 2.1 Basic Flow
|
||||
|
||||
```
|
||||
Cycle Start → Within Time Window → Price Enters Range → Auto Order
|
||||
```
|
||||
|
||||
1. **Cycle**: Each market runs on fixed cycles (5 minutes or 15 minutes)
|
||||
- 5-minute market: Every 5 minutes is a cycle (e.g., 10:00, 10:05, 10:10...)
|
||||
- 15-minute market: Every 15 minutes is a cycle (e.g., 10:00, 10:15, 10:30...)
|
||||
|
||||
2. **Time Window**: You can set a time period within the cycle
|
||||
- Example: 15-minute market, set window to "3 minutes ~ 12 minutes"
|
||||
- Meaning: Only triggers between the 3rd and 12th minute after cycle start
|
||||
|
||||
3. **Price Range**: Set the trigger price range
|
||||
- Example: Minimum price 0.50, Maximum price 0.80
|
||||
- Meaning: Only triggers when market price is between 0.50 ~ 0.80
|
||||
|
||||
4. **Auto Order**: After conditions are met, the system automatically buys at price 0.99
|
||||
|
||||
### 2.2 Important Limitations
|
||||
|
||||
- **Maximum one trigger per cycle**: Within the same cycle, even if conditions are met multiple times, only one order is placed
|
||||
- **Fixed order price**: All orders are submitted at price 0.99
|
||||
- **Requires separate wallet**: It's recommended to use a dedicated wallet for tail strategies to avoid conflicts with other operations (manual trading, copy trading, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Parameter Details
|
||||
|
||||
### 3.1 Basic Parameters
|
||||
|
||||
| Parameter | Description | Required | Example |
|
||||
|-----------|-------------|----------|---------|
|
||||
| **Account** | Select the wallet account for trading | ✅ | Account A |
|
||||
| **Strategy Name** | Name your strategy for easy identification | ❌ | "BTC 15min Tail Strategy" |
|
||||
| **Market** | Select the market to trade (5-minute or 15-minute) | ✅ | btc-updown-15m |
|
||||
|
||||
### 3.2 Cycle Settings
|
||||
|
||||
| Parameter | Description | Required | Example |
|
||||
|-----------|-------------|----------|---------|
|
||||
| **Cycle Length** | Automatically determined by selected market | ✅ | 15 minutes (900 seconds) |
|
||||
| **Time Window Start** | Minutes after cycle start to begin monitoring | ✅ | 3 min 0 sec |
|
||||
| **Time Window End** | Minutes after cycle start to stop monitoring | ✅ | 12 min 0 sec |
|
||||
|
||||
**Time Window Explanation**:
|
||||
- 5-minute market: Can choose any time period within 0 ~ 5 minutes
|
||||
- 15-minute market: Can choose any time period within 0 ~ 15 minutes
|
||||
- **Start time must ≤ End time**
|
||||
- Times outside the window won't trigger even if price conditions are met
|
||||
|
||||
**Example**:
|
||||
- 15-minute market, window "3 min 0 sec ~ 12 min 0 sec"
|
||||
- 0 ~ 3 minutes after cycle start: Not monitoring
|
||||
- 3 ~ 12 minutes after cycle start: Monitoring price, triggers when conditions met
|
||||
- 12 ~ 15 minutes after cycle start: Not monitoring
|
||||
|
||||
### 3.3 Price Range
|
||||
|
||||
| Parameter | Description | Required | Range | Example |
|
||||
|-----------|-------------|----------|-------|---------|
|
||||
| **Minimum Price (minPrice)** | Minimum trigger price | ✅ | 0 ~ 1 | 0.50 |
|
||||
| **Maximum Price (maxPrice)** | Maximum trigger price | ❌ | 0 ~ 1, default 1 | 0.80 |
|
||||
|
||||
**Price Range Explanation**:
|
||||
- Price range is a decimal between 0 ~ 1
|
||||
- Only triggers when market price is within [Minimum Price, Maximum Price]
|
||||
- If maximum price is not filled, defaults to 1.0 (triggers as long as price ≥ minimum price)
|
||||
|
||||
**Example**:
|
||||
- Minimum price 0.50, Maximum price 0.80
|
||||
- Price 0.45: Not triggered (below minimum)
|
||||
- Price 0.60: Triggered ✅ (within range)
|
||||
- Price 0.85: Not triggered (above maximum)
|
||||
|
||||
### 3.4 Investment Amount
|
||||
|
||||
| Parameter | Description | Required | Example |
|
||||
|-----------|-------------|----------|---------|
|
||||
| **Investment Mode** | Choose ratio or fixed amount | ✅ | Ratio / Fixed Amount |
|
||||
| **Ratio (%)** | Percentage of account balance to invest | Conditionally required | 10% (Account has 100 USDC, invest 10 USDC) |
|
||||
| **Fixed Amount (USDC)** | Fixed amount to invest each time | Conditionally required | 50 USDC |
|
||||
|
||||
**Investment Mode Explanation**:
|
||||
|
||||
**Mode 1: By Ratio (RATIO)**
|
||||
- Each trigger invests a percentage of current available balance
|
||||
- Example: Account has 100 USDC, set ratio to 10%
|
||||
- 1st trigger: Invest 10 USDC
|
||||
- 2nd trigger: If balance becomes 90 USDC, invest 9 USDC
|
||||
- **Advantages**: Automatically adapts to account balance changes
|
||||
- **Disadvantages**: Investment amount may vary each time
|
||||
|
||||
**Mode 2: Fixed Amount (FIXED)**
|
||||
- Each trigger invests a fixed specified amount
|
||||
- Example: Set fixed amount to 50 USDC
|
||||
- Every trigger invests 50 USDC
|
||||
- **Advantages**: Stable investment amount, easy to manage
|
||||
- **Disadvantages**: Need to ensure sufficient account balance
|
||||
|
||||
**Notes**:
|
||||
- Minimum order amount: At least 1 USDC
|
||||
- If account balance is insufficient, order will fail and record failure reason
|
||||
|
||||
### 3.5 Spread Filter (Advanced Feature)
|
||||
|
||||
The spread filter controls whether to trigger based on Binance BTC/USDC K-line volatility. It supports two directions: **Minimum spread** and **Maximum spread**.
|
||||
|
||||
| Parameter | Description | Required | Example |
|
||||
|-----------|-------------|----------|---------|
|
||||
| **Spread Mode** | Choose spread validation method | ✅ | None / Fixed / Auto |
|
||||
| **Spread Direction** | Min spread (trigger when ≥) or Max spread (trigger when ≤) | ✅ | Min spread / Max spread |
|
||||
| **Spread Value** | Fill when using Fixed mode (unit: USDC) | Conditionally required | 30 |
|
||||
|
||||
**Spread Direction**:
|
||||
|
||||
- **Min spread**: Triggers only when Binance K-line spread **≥** the set value
|
||||
- Use when you want to trade only when volatility is "large enough" (avoid entering when volatility is too small).
|
||||
- **Max spread**: Triggers only when Binance K-line spread **≤** the set value
|
||||
- Use when you want to trade only when volatility is "small enough" (avoid entering when volatility is too high).
|
||||
|
||||
**Three Spread Modes**:
|
||||
|
||||
**Mode 1: None (NONE)**
|
||||
- No spread validation
|
||||
- Triggers as long as time window and price range conditions are met
|
||||
- **Suitable for**: Not concerned about Binance price volatility, only watching Polymarket price
|
||||
|
||||
**Mode 2: Fixed (FIXED)**
|
||||
- Set a fixed spread value (unit: USDC)
|
||||
- **Min spread**: Triggers when K-line spread ≥ set value
|
||||
- Example: Set 30, spread ≥ 30 → triggered ✅, spread < 30 → not triggered
|
||||
- **Max spread**: Triggers when K-line spread ≤ set value
|
||||
- Example: Set 50, spread ≤ 50 → triggered ✅, spread > 50 → not triggered
|
||||
- **Suitable for**: You have a clear spread threshold in mind
|
||||
|
||||
**Mode 3: Auto (AUTO)**
|
||||
- System automatically calculates an effective spread from the last 20 K-lines
|
||||
- Calculation logic:
|
||||
1. Get recent 20 K-lines (matching strategy cycle)
|
||||
2. Filter by direction (Up direction only looks at rising K-lines, Down direction only looks at falling K-lines)
|
||||
3. Remove outliers (using IQR method)
|
||||
4. Calculate average spread × 0.8 as effective spread
|
||||
- **Min spread**: Triggers when K-line spread ≥ effective spread
|
||||
- **Max spread**: Triggers when K-line spread ≤ effective spread
|
||||
- **Suitable for**: Want automatic adjustment based on historical data without setting a specific value
|
||||
|
||||
**Spread Explanation**:
|
||||
- Spread = |close price - open price| (Binance BTC/USDC for that K-line)
|
||||
- Example: Open price 50000, close price 50030, spread = 30
|
||||
- Larger spread means greater price volatility in that cycle
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Configuration Examples
|
||||
|
||||
### Example 1: Simple Strategy (5-minute Market)
|
||||
|
||||
**Scenario**: In the last 2 minutes of a 5-minute market, if price is below 0.60, automatically buy 10 USDC
|
||||
|
||||
**Configuration**:
|
||||
```
|
||||
Account: Account A
|
||||
Strategy Name: BTC 5min Simple Strategy
|
||||
Market: btc-updown-5m
|
||||
Time Window: 3 min 0 sec ~ 5 min 0 sec
|
||||
Minimum Price: 0.00
|
||||
Maximum Price: 0.60
|
||||
Investment Mode: Fixed Amount
|
||||
Fixed Amount: 10 USDC
|
||||
Spread Mode: None
|
||||
Enabled: On
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- 0 ~ 3 minutes after cycle start: Not monitoring
|
||||
- 3 ~ 5 minutes after cycle start: If price ≤ 0.60, automatically buy 10 USDC
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Ratio Investment Strategy (15-minute Market)
|
||||
|
||||
**Scenario**: In the middle segment (5 ~ 10 minutes) of a 15-minute market, if price is between 0.40 ~ 0.70, invest 15% of account balance
|
||||
|
||||
**Configuration**:
|
||||
```
|
||||
Account: Account B
|
||||
Strategy Name: BTC 15min Ratio Strategy
|
||||
Market: btc-updown-15m
|
||||
Time Window: 5 min 0 sec ~ 10 min 0 sec
|
||||
Minimum Price: 0.40
|
||||
Maximum Price: 0.70
|
||||
Investment Mode: By Ratio
|
||||
Ratio: 15%
|
||||
Spread Mode: None
|
||||
Enabled: On
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- Assuming account balance is 100 USDC
|
||||
- 5 ~ 10 minutes after cycle start: If price is between 0.40 ~ 0.70, automatically buy about 15 USDC (100 × 15%)
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Strategy with Spread Filter (15-minute Market)
|
||||
|
||||
**Scenario**: In the latter segment (10 ~ 14 minutes) of a 15-minute market, if price is between 0.50 ~ 0.80 and Binance spread ≥ 50, invest 20 USDC
|
||||
|
||||
**Configuration**:
|
||||
```
|
||||
Account: Account C
|
||||
Strategy Name: BTC 15min Spread Strategy
|
||||
Market: btc-updown-15m
|
||||
Time Window: 10 min 0 sec ~ 14 min 0 sec
|
||||
Minimum Price: 0.50
|
||||
Maximum Price: 0.80
|
||||
Investment Mode: Fixed Amount
|
||||
Fixed Amount: 20 USDC
|
||||
Spread Mode: Fixed
|
||||
Spread Direction: Min spread
|
||||
Spread Value: 50
|
||||
Enabled: On
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- 10 ~ 14 minutes after cycle start: Only triggers when both conditions are met:
|
||||
1. Price is between 0.50 ~ 0.80 ✅
|
||||
2. Spread direction is "Min spread" and Binance spread ≥ 50 ✅
|
||||
- If spread is only 30, won't trigger even if price condition is met
|
||||
|
||||
---
|
||||
|
||||
### Example 4: Auto Spread Strategy (15-minute Market)
|
||||
|
||||
**Scenario**: In the early segment (2 ~ 8 minutes) of a 15-minute market, if price is between 0.30 ~ 0.90, invest 20% of account balance, spread calculated automatically by system
|
||||
|
||||
**Configuration**:
|
||||
```
|
||||
Account: Account D
|
||||
Strategy Name: BTC 15min Auto Spread Strategy
|
||||
Market: btc-updown-15m
|
||||
Time Window: 2 min 0 sec ~ 8 min 0 sec
|
||||
Minimum Price: 0.30
|
||||
Maximum Price: 0.90
|
||||
Investment Mode: By Ratio
|
||||
Ratio: 20%
|
||||
Spread Mode: Auto
|
||||
Spread Direction: Min spread
|
||||
Enabled: On
|
||||
```
|
||||
|
||||
**Explanation**:
|
||||
- System automatically calculates effective spread from the last 20 K-lines
|
||||
- 2 ~ 8 minutes after cycle start: Only triggers when both conditions are met:
|
||||
1. Price is between 0.30 ~ 0.90 ✅
|
||||
2. Spread direction is "Min spread" and Binance spread ≥ system-calculated effective spread ✅
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Frequently Asked Questions
|
||||
|
||||
### Q1: When will the strategy trigger?
|
||||
|
||||
**A**: All of the following conditions must be met simultaneously:
|
||||
1. ✅ Current time is within the time window
|
||||
2. ✅ Market price is within [Minimum Price, Maximum Price] range
|
||||
3. ✅ This cycle hasn't triggered yet (maximum one trigger per cycle)
|
||||
4. ✅ If spread filter is set, Binance spread and spread direction must both be satisfied
|
||||
|
||||
### Q2: Why didn't my strategy trigger?
|
||||
|
||||
**Possible reasons**:
|
||||
1. **Time window incorrect**: Current time is not within the set time window
|
||||
2. **Price not in range**: Market price is not within [Minimum Price, Maximum Price] range
|
||||
3. **Already triggered this cycle**: This cycle has already triggered once, won't trigger again
|
||||
4. **Spread not met**: If spread filter is set, Binance spread or spread direction requirement is not satisfied
|
||||
5. **Insufficient account balance**: Account balance is less than the set investment amount
|
||||
6. **Strategy not enabled**: Check if strategy's enabled status is "On"
|
||||
|
||||
### Q3: What does "maximum one trigger per cycle" mean?
|
||||
|
||||
**A**: Within each cycle (5 minutes or 15 minutes), even if conditions are met multiple times, only one order is placed.
|
||||
|
||||
**Example**:
|
||||
- 15-minute market, cycle starts at 10:00
|
||||
- At 10:05, price meets condition, triggers order ✅
|
||||
- At 10:08, price meets condition again, but won't place another order (already triggered this cycle)
|
||||
- At 10:15, new cycle starts, can trigger again
|
||||
|
||||
### Q4: What's the difference between fixed amount and ratio?
|
||||
|
||||
**Fixed Amount**:
|
||||
- Invests the same amount each trigger
|
||||
- Example: Set 50 USDC, every trigger is 50 USDC
|
||||
- Need to ensure sufficient account balance
|
||||
|
||||
**By Ratio**:
|
||||
- Invests a percentage of account balance each trigger
|
||||
- Example: Set 10%, when account has 100 USDC, invest 10 USDC, after balance becomes 90 USDC, next trigger invests 9 USDC
|
||||
- Automatically adapts to balance changes
|
||||
|
||||
### Q5: What's the use of the spread filter feature?
|
||||
|
||||
**A**: The spread filter decides whether to trigger based on Binance BTC/USDC K-line volatility. It supports two directions.
|
||||
|
||||
**Min spread** (trigger when spread **≥** set value):
|
||||
- Avoids triggering when volatility is too small
|
||||
- Example: Set 30, only triggers when spread ≥ 30
|
||||
|
||||
**Max spread** (trigger when spread **≤** set value):
|
||||
- Avoids triggering when volatility is too high (lower risk)
|
||||
- Example: Set 50, only triggers when spread ≤ 50
|
||||
|
||||
**Three mode selection suggestions**:
|
||||
- **None**: Not concerned about Binance price volatility, only watching Polymarket price
|
||||
- **Fixed**: You know the expected spread threshold (use with Min or Max spread direction)
|
||||
- **Auto**: Want effective spread calculated from historical data without setting a specific value
|
||||
|
||||
### Q6: Why is it recommended to use a separate wallet?
|
||||
|
||||
**A**: To avoid the following issues:
|
||||
1. **Balance changes**: If wallet is also used for manual trading, balance changes may affect strategy execution
|
||||
2. **Position conflicts**: Manual trading and strategy trading may conflict
|
||||
3. **Management confusion**: Difficult to distinguish which orders are from strategy vs manual
|
||||
|
||||
**Recommendation**: Create a dedicated wallet, only for tail strategies.
|
||||
|
||||
### Q7: Why is the order price fixed at 0.99?
|
||||
|
||||
**A**: This is a design feature of the strategy:
|
||||
- 0.99 is the highest price in the market (close to 1.0)
|
||||
- Buying at the highest price ensures orders execute quickly
|
||||
- Although buying price is higher, the strategy's core is capturing market volatility, not pursuing optimal price
|
||||
|
||||
### Q8: Does the strategy depend on auto-redeem functionality?
|
||||
|
||||
**A**: Yes, tail strategy depends on auto-redeem functionality.
|
||||
|
||||
**Reasons**:
|
||||
- Strategy orders create positions after execution
|
||||
- These positions need to be automatically redeemed after market settlement
|
||||
- If auto-redeem is not configured, positions may not be redeemed in time
|
||||
|
||||
**Configuration Requirements**:
|
||||
- Configure Builder API Key in "System Settings"
|
||||
- Enable auto-redeem functionality
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Important Notes
|
||||
|
||||
### 6.1 Account Requirements
|
||||
|
||||
- ✅ Account must have API Key, API Secret, API Passphrase configured
|
||||
- ✅ Account must have sufficient USDC balance
|
||||
- ✅ Recommended to use a dedicated wallet to avoid conflicts with other operations
|
||||
|
||||
### 6.2 Time Window Settings
|
||||
|
||||
- ⚠️ Start time must ≤ End time
|
||||
- ⚠️ Time window cannot exceed cycle length (5-minute market ≤ 5 minutes, 15-minute market ≤ 15 minutes)
|
||||
- ⚠️ Recommended to set reasonable time windows, avoid triggering at cycle start or end
|
||||
|
||||
### 6.3 Price Range Settings
|
||||
|
||||
- ⚠️ Minimum price must ≤ Maximum price
|
||||
- ⚠️ Price range is a decimal between 0 ~ 1
|
||||
- ⚠️ Recommended to set reasonable price ranges based on market conditions
|
||||
|
||||
### 6.4 Investment Amount Settings
|
||||
|
||||
- ⚠️ Minimum order amount: At least 1 USDC
|
||||
- ⚠️ Ensure sufficient account balance to avoid order failures
|
||||
- ⚠️ Ratio mode: Note the impact of account balance changes on investment amount
|
||||
|
||||
### 6.5 Spread Filter Settings
|
||||
|
||||
- ⚠️ Spread direction: Min spread means "trigger when ≥"; Max spread means "trigger when ≤". Choose according to your need.
|
||||
- ⚠️ Fixed mode: Need to fill a reasonable spread value (unit: USDC)
|
||||
- ⚠️ Auto mode: System calculates effective spread within the window, no manual value needed
|
||||
- ⚠️ Overly strict spread (min spread too high or max spread too low) may make the strategy rarely trigger
|
||||
|
||||
### 6.6 Other Notes
|
||||
|
||||
- ⚠️ Strategy is enabled by default after creation, can disable "Enabled Status" if need to pause
|
||||
- ⚠️ Maximum one trigger per cycle, set trigger conditions reasonably
|
||||
- ⚠️ Strategy depends on auto-redeem functionality, ensure Builder API Key is configured
|
||||
- ⚠️ Recommended to regularly check trigger records to understand strategy execution
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Strategy Management
|
||||
|
||||
### 7.1 View Strategy List
|
||||
|
||||
On the "Crypto Tail Strategy" page, you can view all strategies:
|
||||
- Strategy name
|
||||
- Market information
|
||||
- Time window
|
||||
- Price range
|
||||
- Investment mode
|
||||
- Enabled status
|
||||
- Last trigger time
|
||||
- Statistics like total profit, win rate
|
||||
|
||||
### 7.2 View Trigger Records
|
||||
|
||||
Click on a strategy to view detailed trigger records:
|
||||
- Trigger time
|
||||
- Market price
|
||||
- Investment amount
|
||||
- Order ID
|
||||
- Order status (success/fail)
|
||||
- Settlement information (profit/loss, win rate, etc.)
|
||||
|
||||
### 7.3 Edit Strategy
|
||||
|
||||
You can modify strategy parameters at any time:
|
||||
- Time window
|
||||
- Price range
|
||||
- Investment mode
|
||||
- Spread filter (mode, direction, value)
|
||||
- Enabled status
|
||||
|
||||
**Note**: Modified strategies take effect in the next cycle.
|
||||
|
||||
### 7.4 Delete Strategy
|
||||
|
||||
After deleting a strategy:
|
||||
- Strategy configuration is deleted
|
||||
- Historical trigger records are retained
|
||||
- Already placed orders are not affected
|
||||
|
||||
---
|
||||
|
||||
## Part 8: Summary
|
||||
|
||||
Crypto Tail Strategy is a powerful automated trading tool that can help you:
|
||||
|
||||
1. **Automated Trading**: No need for manual monitoring, system executes automatically
|
||||
2. **Precise Control**: Precisely control trigger conditions through time windows and price ranges
|
||||
3. **Flexible Configuration**: Supports both ratio and fixed amount investment modes
|
||||
4. **Risk Filtering**: Control volatility conditions through spread filter (min spread / max spread)
|
||||
|
||||
**Usage Recommendations**:
|
||||
- For first-time users, start with simple strategies (no spread filter)
|
||||
- After familiarizing, try adding spread filter features
|
||||
- Regularly check trigger records, adjust strategy parameters based on actual situation
|
||||
- Use a dedicated wallet to avoid conflicts with other operations
|
||||
|
||||
**Happy Trading!** 🚀
|
||||
@@ -0,0 +1,469 @@
|
||||
# 尾盘策略配置指南
|
||||
|
||||
## 一、什么是尾盘策略?
|
||||
|
||||
尾盘策略是一种自动化交易策略,专门用于 Polymarket 加密市场的 **5分钟** 或 **15分钟** "Up or Down" 市场。
|
||||
|
||||
**核心逻辑**:在指定时间窗口内,当市场价格进入您设定的价格区间时,系统会自动以固定价格(0.99)买入,无需手动操作。
|
||||
|
||||
**适用场景**:
|
||||
- 您希望捕捉市场在周期末段的价格波动
|
||||
- 您想自动化执行交易,避免手动盯盘
|
||||
- 您对市场走势有一定判断,希望设置条件自动触发
|
||||
|
||||
---
|
||||
|
||||
## 二、策略工作原理
|
||||
|
||||
### 2.1 基本流程
|
||||
|
||||
```
|
||||
周期开始 → 时间窗口内 → 价格进入区间 → 自动下单
|
||||
```
|
||||
|
||||
1. **周期**:每个市场按固定周期运行(5分钟或15分钟)
|
||||
- 5分钟市场:每5分钟为一个周期(如 10:00、10:05、10:10...)
|
||||
- 15分钟市场:每15分钟为一个周期(如 10:00、10:15、10:30...)
|
||||
|
||||
2. **时间窗口**:您可以在周期内设置一个时间段
|
||||
- 例如:15分钟市场,设置窗口为「3分钟~12分钟」
|
||||
- 表示:从周期开始后第3分钟到第12分钟之间才会触发
|
||||
|
||||
3. **价格区间**:设置触发价格范围
|
||||
- 例如:最低价 0.50,最高价 0.80
|
||||
- 表示:当市场价格在 0.50~0.80 之间时才会触发
|
||||
|
||||
4. **自动下单**:满足条件后,系统自动以 0.99 的价格买入
|
||||
|
||||
### 2.2 重要限制
|
||||
|
||||
- **每周期最多触发一次**:同一个周期内,即使多次满足条件,也只下单一次
|
||||
- **固定下单价格**:所有订单都以 0.99 的价格提交
|
||||
- **需要单独钱包**:建议使用专门的钱包运行尾盘策略,避免与其他操作(手动交易、跟单等)冲突
|
||||
|
||||
---
|
||||
|
||||
## 三、参数详细说明
|
||||
|
||||
### 3.1 基础参数
|
||||
|
||||
| 参数 | 说明 | 必填 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **账户** | 选择用于交易的钱包账户 | ✅ | 账户A |
|
||||
| **策略名称** | 给策略起个名字,方便识别 | ❌ | "BTC 15分钟尾盘策略" |
|
||||
| **市场** | 选择要交易的市场(5分钟或15分钟) | ✅ | btc-updown-15m |
|
||||
|
||||
### 3.2 周期设置
|
||||
|
||||
| 参数 | 说明 | 必填 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **周期长度** | 由选择的市场自动确定 | ✅ | 15分钟(900秒) |
|
||||
| **时间窗口开始** | 从周期起点算起,多少分钟后开始监听 | ✅ | 3分0秒 |
|
||||
| **时间窗口结束** | 从周期起点算起,多少分钟后停止监听 | ✅ | 12分0秒 |
|
||||
|
||||
**时间窗口说明**:
|
||||
- 5分钟市场:可选 0~5 分钟内的任意时间段
|
||||
- 15分钟市场:可选 0~15 分钟内的任意时间段
|
||||
- **开始时间必须 ≤ 结束时间**
|
||||
- 窗口外的时间即使价格满足也不会触发
|
||||
|
||||
**示例**:
|
||||
- 15分钟市场,窗口「3分0秒 ~ 12分0秒」
|
||||
- 周期开始后 0~3 分钟:不监听
|
||||
- 周期开始后 3~12 分钟:监听价格,满足条件即触发
|
||||
- 周期开始后 12~15 分钟:不监听
|
||||
|
||||
### 3.3 价格区间
|
||||
|
||||
| 参数 | 说明 | 必填 | 取值范围 | 示例 |
|
||||
|------|------|------|----------|------|
|
||||
| **最低价 (minPrice)** | 触发的最低价格 | ✅ | 0~1 | 0.50 |
|
||||
| **最高价 (maxPrice)** | 触发的最高价格 | ❌ | 0~1,默认1 | 0.80 |
|
||||
|
||||
**价格区间说明**:
|
||||
- 价格范围是 0~1 之间的小数
|
||||
- 当市场价格在 [最低价, 最高价] 区间内时才会触发
|
||||
- 如果不填最高价,默认使用 1.0(即只要价格 ≥ 最低价就触发)
|
||||
|
||||
**示例**:
|
||||
- 最低价 0.50,最高价 0.80
|
||||
- 价格 0.45:不触发(低于最低价)
|
||||
- 价格 0.60:触发 ✅(在区间内)
|
||||
- 价格 0.85:不触发(高于最高价)
|
||||
|
||||
### 3.4 投入金额
|
||||
|
||||
| 参数 | 说明 | 必填 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **投入方式** | 选择按比例或固定金额 | ✅ | 按比例 / 固定金额 |
|
||||
| **比例 (%)** | 按账户余额的百分比投入 | 条件必填 | 10%(账户有100 USDC,投入10 USDC) |
|
||||
| **固定金额 (USDC)** | 每次固定投入的金额 | 条件必填 | 50 USDC |
|
||||
|
||||
**投入方式说明**:
|
||||
|
||||
**方式一:按比例 (RATIO)**
|
||||
- 每次触发时,按账户当前可用余额的百分比投入
|
||||
- 例如:账户有 100 USDC,设置比例 10%
|
||||
- 第1次触发:投入 10 USDC
|
||||
- 第2次触发:如果余额变为 90 USDC,投入 9 USDC
|
||||
- **优点**:自动适应账户余额变化
|
||||
- **缺点**:每次投入金额可能不同
|
||||
|
||||
**方式二:固定金额 (FIXED)**
|
||||
- 每次触发时,固定投入指定金额
|
||||
- 例如:设置固定金额 50 USDC
|
||||
- 每次触发都投入 50 USDC
|
||||
- **优点**:投入金额稳定,便于管理
|
||||
- **缺点**:需要确保账户余额充足
|
||||
|
||||
**注意事项**:
|
||||
- 最小下单金额:至少 1 USDC
|
||||
- 如果账户余额不足,下单会失败并记录失败原因
|
||||
|
||||
### 3.5 价差过滤(高级功能)
|
||||
|
||||
价差功能用于根据币安 BTC/USDC 的 K 线波动决定是否触发,支持「最小价差」与「最大价差」两种方向。
|
||||
|
||||
| 参数 | 说明 | 必填 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **价差模式** | 选择价差校验方式 | ✅ | 无 / 固定 / 自动 |
|
||||
| **价差方向** | 最小价差(≥ 触发)或 最大价差(≤ 触发) | ✅ | 最小价差 / 最大价差 |
|
||||
| **价差值** | 固定模式时填写(单位:USDC) | 条件必填 | 30 |
|
||||
|
||||
**价差方向说明**:
|
||||
|
||||
- **最小价差**:当币安 K 线价差 **≥** 设定值时才触发
|
||||
- 适合:只在波动「足够大」时交易(避免波动过小、不值得进场)
|
||||
- **最大价差**:当币安 K 线价差 **≤** 设定值时才触发
|
||||
- 适合:只在波动「足够小」时交易(避免波动过大、风险高)
|
||||
|
||||
**三种价差模式**:
|
||||
|
||||
**模式一:无 (NONE)**
|
||||
- 不进行价差校验
|
||||
- 只要时间窗口和价格区间满足就触发
|
||||
- **适合**:不关心币安价格波动,只看 Polymarket 价格
|
||||
|
||||
**模式二:固定 (FIXED)**
|
||||
- 设置一个固定的价差值(单位:USDC)
|
||||
- **最小价差**:当 K 线价差 ≥ 设定值时触发
|
||||
- 示例:设定 30,价差 ≥ 30 触发 ✅,价差 < 30 不触发
|
||||
- **最大价差**:当 K 线价差 ≤ 设定值时触发
|
||||
- 示例:设定 50,价差 ≤ 50 触发 ✅,价差 > 50 不触发
|
||||
- **适合**:您有明确的价差阈值
|
||||
|
||||
**模式三:自动 (AUTO)**
|
||||
- 系统根据历史 20 根 K 线自动计算基准价差
|
||||
- 计算逻辑:
|
||||
1. 获取最近 20 根 K 线(与策略周期一致)
|
||||
2. 按方向筛选(Up 方向只看上涨的 K 线,Down 方向只看下跌的 K 线)
|
||||
3. 剔除异常值(使用 IQR 方法)
|
||||
4. 计算平均价差 × 0.8 作为有效价差
|
||||
- **最小价差**:K 线价差 ≥ 有效价差时触发
|
||||
- **最大价差**:K 线价差 ≤ 有效价差时触发
|
||||
- **适合**:希望根据历史数据自动调整,无需手动设具体数值
|
||||
|
||||
**价差说明**:
|
||||
- 价差 = |收盘价 - 开盘价|(币安 BTC/USDC 当根 K 线)
|
||||
- 例如:开盘价 50000,收盘价 50030,价差 = 30
|
||||
- 价差越大,说明该周期内价格波动越大
|
||||
|
||||
---
|
||||
|
||||
## 四、配置示例
|
||||
|
||||
### 示例1:简单策略(5分钟市场)
|
||||
|
||||
**场景**:在 5 分钟市场的最后 2 分钟,如果价格低于 0.60,自动买入 10 USDC
|
||||
|
||||
**配置**:
|
||||
```
|
||||
账户:账户A
|
||||
策略名称:BTC 5分钟简单策略
|
||||
市场:btc-updown-5m
|
||||
时间窗口:3分0秒 ~ 5分0秒
|
||||
最低价:0.00
|
||||
最高价:0.60
|
||||
投入方式:固定金额
|
||||
固定金额:10 USDC
|
||||
价差模式:无
|
||||
启用状态:开启
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 周期开始后 0~3 分钟:不监听
|
||||
- 周期开始后 3~5 分钟:如果价格 ≤ 0.60,自动买入 10 USDC
|
||||
|
||||
---
|
||||
|
||||
### 示例2:比例投入策略(15分钟市场)
|
||||
|
||||
**场景**:在 15 分钟市场的中段(5~10分钟),如果价格在 0.40~0.70 之间,投入账户余额的 15%
|
||||
|
||||
**配置**:
|
||||
```
|
||||
账户:账户B
|
||||
策略名称:BTC 15分钟比例策略
|
||||
市场:btc-updown-15m
|
||||
时间窗口:5分0秒 ~ 10分0秒
|
||||
最低价:0.40
|
||||
最高价:0.70
|
||||
投入方式:按比例
|
||||
比例:15%
|
||||
价差模式:无
|
||||
启用状态:开启
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 假设账户余额 100 USDC
|
||||
- 周期开始后 5~10 分钟:如果价格在 0.40~0.70 之间,自动买入约 15 USDC(100 × 15%)
|
||||
|
||||
---
|
||||
|
||||
### 示例3:带价差过滤的策略(15分钟市场)
|
||||
|
||||
**场景**:在 15 分钟市场的后段(10~14分钟),如果价格在 0.50~0.80 之间,且币安价差 ≥ 50,投入 20 USDC
|
||||
|
||||
**配置**:
|
||||
```
|
||||
账户:账户C
|
||||
策略名称:BTC 15分钟价差策略
|
||||
市场:btc-updown-15m
|
||||
时间窗口:10分0秒 ~ 14分0秒
|
||||
最低价:0.50
|
||||
最高价:0.80
|
||||
投入方式:固定金额
|
||||
固定金额:20 USDC
|
||||
价差模式:固定
|
||||
价差方向:最小价差
|
||||
价差值:50
|
||||
启用状态:开启
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 周期开始后 10~14 分钟:同时满足以下条件才触发
|
||||
1. 价格在 0.50~0.80 之间 ✅
|
||||
2. 价差方向为「最小价差」且币安价差 ≥ 50 ✅
|
||||
- 如果价差只有 30,即使价格满足也不会触发
|
||||
|
||||
---
|
||||
|
||||
### 示例4:自动价差策略(15分钟市场)
|
||||
|
||||
**场景**:在 15 分钟市场的前段(2~8分钟),如果价格在 0.30~0.90 之间,投入账户余额的 20%,价差由系统自动计算
|
||||
|
||||
**配置**:
|
||||
```
|
||||
账户:账户D
|
||||
策略名称:BTC 15分钟自动价差策略
|
||||
市场:btc-updown-15m
|
||||
时间窗口:2分0秒 ~ 8分0秒
|
||||
最低价:0.30
|
||||
最高价:0.90
|
||||
投入方式:按比例
|
||||
比例:20%
|
||||
价差模式:自动
|
||||
价差方向:最小价差
|
||||
启用状态:开启
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 系统会根据历史 20 根 K 线自动计算有效价差
|
||||
- 周期开始后 2~8 分钟:同时满足以下条件才触发
|
||||
1. 价格在 0.30~0.90 之间 ✅
|
||||
2. 价差方向为「最小价差」且币安价差 ≥ 系统计算的有效价差 ✅
|
||||
|
||||
---
|
||||
|
||||
## 五、常见问题
|
||||
|
||||
### Q1:策略什么时候会触发?
|
||||
|
||||
**A**:需要同时满足以下条件:
|
||||
1. ✅ 当前时间在时间窗口内
|
||||
2. ✅ 市场价格在 [最低价, 最高价] 区间内
|
||||
3. ✅ 本周期尚未触发过(每周期最多触发一次)
|
||||
4. ✅ 如果设置了价差过滤,币安价差与价差方向需同时满足条件
|
||||
|
||||
### Q2:为什么我的策略没有触发?
|
||||
|
||||
**可能原因**:
|
||||
1. **时间窗口不对**:当前时间不在设定的时间窗口内
|
||||
2. **价格不在区间**:市场价格不在 [最低价, 最高价] 范围内
|
||||
3. **本周期已触发**:该周期已经触发过一次,不会再触发
|
||||
4. **价差不满足**:如果设置了价差过滤,币安价差或价差方向未满足要求
|
||||
5. **账户余额不足**:账户余额小于设定的投入金额
|
||||
6. **策略未启用**:检查策略的启用状态是否为"开启"
|
||||
|
||||
### Q3:每周期最多触发一次是什么意思?
|
||||
|
||||
**A**:每个周期(5分钟或15分钟)内,即使多次满足条件,也只下单一次。
|
||||
|
||||
**示例**:
|
||||
- 15分钟市场,周期从 10:00 开始
|
||||
- 10:05 时价格满足条件,触发下单 ✅
|
||||
- 10:08 时价格再次满足条件,但不会再次下单(本周期已触发)
|
||||
- 10:15 开始新周期,可以再次触发
|
||||
|
||||
### Q4:固定金额和按比例有什么区别?
|
||||
|
||||
**固定金额**:
|
||||
- 每次触发都投入相同金额
|
||||
- 例如:设置 50 USDC,每次都是 50 USDC
|
||||
- 需要确保账户余额充足
|
||||
|
||||
**按比例**:
|
||||
- 每次触发时按账户余额的百分比投入
|
||||
- 例如:设置 10%,账户有 100 USDC 时投入 10 USDC,余额变为 90 USDC 后下次投入 9 USDC
|
||||
- 自动适应余额变化
|
||||
|
||||
### Q5:价差过滤功能有什么用?
|
||||
|
||||
**A**:价差过滤根据币安 BTC/USDC 的 K 线波动决定是否触发,支持两种方向。
|
||||
|
||||
**最小价差**(价差 ≥ 设定值才触发):
|
||||
- 波动太小时不触发,避免在波动不足时进场
|
||||
- 例如:设定 30,只有价差 ≥ 30 才触发
|
||||
|
||||
**最大价差**(价差 ≤ 设定值才触发):
|
||||
- 波动太大时不触发,避免在波动过大、风险高时进场
|
||||
- 例如:设定 50,只有价差 ≤ 50 才触发
|
||||
|
||||
**三种模式选择建议**:
|
||||
- **无**:不关心币安价格波动,只看 Polymarket 价格
|
||||
- **固定**:您知道期望的价差阈值(配合最小/最大价差方向使用)
|
||||
- **自动**:希望根据历史数据自动计算有效价差,无需手动设具体数值
|
||||
|
||||
### Q6:为什么建议使用单独的钱包?
|
||||
|
||||
**A**:避免以下问题:
|
||||
1. **余额变化**:如果钱包同时用于手动交易,余额变化可能影响策略执行
|
||||
2. **仓位冲突**:手动交易和策略交易可能产生冲突
|
||||
3. **管理混乱**:难以区分哪些订单是策略产生的,哪些是手动产生的
|
||||
|
||||
**建议**:创建一个专门的钱包,只用于尾盘策略。
|
||||
|
||||
### Q7:下单价格为什么是固定的 0.99?
|
||||
|
||||
**A**:这是策略的设计特点:
|
||||
- 0.99 是市场中的最高价格(接近 1.0)
|
||||
- 以最高价买入可以确保订单快速成交
|
||||
- 虽然买入价格较高,但策略的核心是捕捉市场波动,而非追求最优价格
|
||||
|
||||
### Q8:策略需要依赖自动赎回功能吗?
|
||||
|
||||
**A**:是的,尾盘策略依赖自动赎回功能。
|
||||
|
||||
**原因**:
|
||||
- 策略下单后会形成仓位
|
||||
- 这些仓位需要在市场结算后自动赎回
|
||||
- 如果未配置自动赎回,仓位可能无法及时赎回
|
||||
|
||||
**配置要求**:
|
||||
- 在「系统设置」中配置 Builder API Key
|
||||
- 开启自动赎回功能
|
||||
|
||||
---
|
||||
|
||||
## 六、注意事项
|
||||
|
||||
### 6.1 账户要求
|
||||
|
||||
- ✅ 账户必须配置 API Key、API Secret、API Passphrase
|
||||
- ✅ 账户必须有足够的 USDC 余额
|
||||
- ✅ 建议使用专门的钱包,避免与其他操作冲突
|
||||
|
||||
### 6.2 时间窗口设置
|
||||
|
||||
- ⚠️ 开始时间必须 ≤ 结束时间
|
||||
- ⚠️ 时间窗口不能超出周期长度(5分钟市场 ≤ 5分钟,15分钟市场 ≤ 15分钟)
|
||||
- ⚠️ 建议设置合理的时间窗口,避免在周期开始或结束时触发
|
||||
|
||||
### 6.3 价格区间设置
|
||||
|
||||
- ⚠️ 最低价必须 ≤ 最高价
|
||||
- ⚠️ 价格范围是 0~1 之间的小数
|
||||
- ⚠️ 建议根据市场情况设置合理的价格区间
|
||||
|
||||
### 6.4 投入金额设置
|
||||
|
||||
- ⚠️ 最小下单金额:至少 1 USDC
|
||||
- ⚠️ 确保账户余额充足,避免下单失败
|
||||
- ⚠️ 按比例模式:注意账户余额变化对投入金额的影响
|
||||
|
||||
### 6.5 价差过滤设置
|
||||
|
||||
- ⚠️ 价差方向:最小价差为「≥ 触发」,最大价差为「≤ 触发」,请按需求选择
|
||||
- ⚠️ 固定模式:需要填写合理的价差值(单位:USDC)
|
||||
- ⚠️ 自动模式:系统会在周期内按窗口进度自动计算有效价差,无需手动设置
|
||||
- ⚠️ 价差设定过严(最小价差设得过大或最大价差设得过小)可能导致策略难以触发
|
||||
|
||||
### 6.6 其他注意事项
|
||||
|
||||
- ⚠️ 策略创建后默认启用,如需暂停可以关闭"启用状态"
|
||||
- ⚠️ 每周期最多触发一次,请合理设置触发条件
|
||||
- ⚠️ 策略依赖自动赎回功能,请确保已配置 Builder API Key
|
||||
- ⚠️ 建议定期查看触发记录,了解策略执行情况
|
||||
|
||||
---
|
||||
|
||||
## 七、策略管理
|
||||
|
||||
### 7.1 查看策略列表
|
||||
|
||||
在「尾盘策略」页面可以查看所有策略:
|
||||
- 策略名称
|
||||
- 市场信息
|
||||
- 时间窗口
|
||||
- 价格区间
|
||||
- 投入方式
|
||||
- 启用状态
|
||||
- 最后触发时间
|
||||
- 总收益、胜率等统计信息
|
||||
|
||||
### 7.2 查看触发记录
|
||||
|
||||
点击策略可以查看详细的触发记录:
|
||||
- 触发时间
|
||||
- 市场价格
|
||||
- 投入金额
|
||||
- 订单ID
|
||||
- 订单状态(成功/失败)
|
||||
- 结算信息(盈亏、胜率等)
|
||||
|
||||
### 7.3 编辑策略
|
||||
|
||||
可以随时修改策略参数:
|
||||
- 时间窗口
|
||||
- 价格区间
|
||||
- 投入方式
|
||||
- 价差过滤(模式、方向、数值)
|
||||
- 启用状态
|
||||
|
||||
**注意**:修改后的策略会在下一个周期生效。
|
||||
|
||||
### 7.4 删除策略
|
||||
|
||||
删除策略后:
|
||||
- 策略配置会被删除
|
||||
- 历史触发记录会保留
|
||||
- 已下单的订单不受影响
|
||||
|
||||
---
|
||||
|
||||
## 八、总结
|
||||
|
||||
尾盘策略是一个强大的自动化交易工具,可以帮助您:
|
||||
|
||||
1. **自动化交易**:无需手动盯盘,系统自动执行
|
||||
2. **精准控制**:通过时间窗口和价格区间精确控制触发条件
|
||||
3. **灵活配置**:支持比例和固定金额两种投入方式
|
||||
4. **风险过滤**:通过价差过滤(最小价差/最大价差)控制波动条件
|
||||
|
||||
**使用建议**:
|
||||
- 初次使用建议从简单策略开始(无价差过滤)
|
||||
- 熟悉后再尝试添加价差过滤功能
|
||||
- 定期查看触发记录,根据实际情况调整策略参数
|
||||
- 使用专门的钱包,避免与其他操作冲突
|
||||
|
||||
**祝您交易顺利!** 🚀
|
||||
@@ -0,0 +1,46 @@
|
||||
# Neg Risk 赎回与对应 JS/TS 代码说明
|
||||
|
||||
## Neg Risk 特殊逻辑
|
||||
|
||||
### 1. 赎回(Redeem)
|
||||
|
||||
- **普通市场**:仓位由 **USDC.e**(Bridged USDC)抵押,调用 CTF 的 `redeemPositions(collateralToken, parentCollectionId, conditionId, indexSets)` 时,`collateralToken` 为 USDC.e 地址(Polygon: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`)。
|
||||
- **Neg Risk 市场**:仓位由 **WrappedCollateral** 抵押(neg-risk-ctf-adapter 设计),同一笔赎回必须使用 WrappedCollateral 地址(Polygon: `0x3A3BD7bb9528E159577F7C2e685CC81A765002E2`),否则链上找不到对应仓位,会得到 payout 0。
|
||||
|
||||
本项目中:通过 Gamma API 的 `negRisk` / `negRiskOther` 判断市场类型,赎回时对 Neg Risk 市场传 `isNegRisk=true`,在 `createRedeemTx` 中选用 WrappedCollateral 作为 `collateralToken`。
|
||||
|
||||
### 2. 下单/签约(Order Signing)
|
||||
|
||||
- **普通市场**:使用标准 CTF Exchange 合约签约。
|
||||
- **Neg Risk 市场**:必须使用 **Neg Risk CTF Exchange** 合约签约,否则 CLOB 返回 invalid signature。
|
||||
|
||||
见 `OrderSigningService.getExchangeContract(negRisk)`、`CopyOrderTrackingService` 中按 `getNegRiskByConditionId` 选择 exchange。
|
||||
|
||||
---
|
||||
|
||||
## 对应的 JS/TS 代码位置
|
||||
|
||||
| 功能 | 仓库/来源 | 路径或说明 |
|
||||
|------|-----------|------------|
|
||||
| Relayer 执行、Safe 交易提交 | [Polymarket/builder-relayer-client](https://github.com/Polymarket/builder-relayer-client) | `src/client.ts`(`execute`)、`src/encode/safe.ts`(MultiSend `createSafeMultisendTransaction`) |
|
||||
| 链与合约配置 | builder-relayer-client | `src/config/index.ts`(Polygon/Amoy 的 SafeMultisend 等) |
|
||||
| 赎回 calldata 构建 | **官方仓库无** | 官方库只负责执行传入的 `Transaction[]`,不包含 `createRedeemTx` 或 redeem 工具函数 |
|
||||
| 社区赎回示例(单一 collateral) | [Gist: redeem-positions](https://gist.github.com/Waawzer/5cdff342767265c2637e21607d03f6eb) | 使用 `collateralToken` 调用 `redeemPositions`,**未区分 Neg Risk**(全部用同一 collateral,如 USDC) |
|
||||
| Neg Risk 合约与 WrappedCollateral | [Polymarket/neg-risk-ctf-adapter](https://github.com/Polymarket/neg-risk-ctf-adapter) | README、`addresses.json`(137 链上 negRiskWrappedCollateral 等地址) |
|
||||
| 市场是否 Neg Risk | Gamma API | 市场/事件的 `negRisk`、`negRiskOther` 字段,本项目中通过 `MarketService.getNegRiskByConditionId` 查询 |
|
||||
|
||||
---
|
||||
|
||||
## 赎回后自动解包 WCOL → USDC.e
|
||||
|
||||
Neg Risk 赎回到账为 **WCOL**,若不解包则余额显示为 WCOL 而非 USDC.e。本项目中由**轮询任务**统一解包,赎回流程内不再等待确认与解包:
|
||||
|
||||
- **WcolUnwrapJobService**:每 20 秒轮询一次(`@Scheduled(fixedRate = 20_000)`),遍历所有账户的代理地址,若 WCOL 余额 > 0 则调用 **BlockchainService.unwrapWcolForProxy** 解包为 USDC.e。同一时间仅允许单次执行;若上次执行未结束则本次忽略。
|
||||
- **AccountService.redeemPositions**:只负责赎回,不再在流程内等待交易确认或执行解包;解包由上述 Job 在后续轮询中处理(含重启后未解包、解包失败重试等)。
|
||||
- **BlockchainService**:提供 `getWcolBalance(proxyAddress)`、`unwrapWcolForProxy(...)`;**RelayClientService** 提供 `createUnwrapWcolTx(toAddress, amountWei)`。
|
||||
- **Safe 与 Magic 解包逻辑一致**:均使用同一 `createUnwrapWcolTx(proxyAddress, balance)` 与 `execute(privateKey, proxyAddress, unwrapTx, walletType)`;Safe 走 execTransaction(或 Builder Relayer SAFE),Magic 走 Builder Relayer PROXY(encodeProxyTransactionData),最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入该代理地址。
|
||||
|
||||
## 小结
|
||||
|
||||
- **Neg Risk 特殊逻辑**:赎回用 WrappedCollateral、下单用 Neg Risk Exchange;均由「是否为 Neg Risk 市场」分支处理。
|
||||
- **对应 JS 代码**:执行与 MultiSend 在 **builder-relayer-client**;赎回参数与 calldata 在**应用层**构建,官方无现成 redeem 工具;Neg Risk 的抵押品与合约见 **neg-risk-ctf-adapter** 与 **Gamma API**。
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { apiService } from '../services/api'
|
||||
import type { ProxyOption } from '../types'
|
||||
import AccountSetupGuideModal from './AccountSetupGuideModal'
|
||||
|
||||
type ImportType = 'privateKey' | 'mnemonic'
|
||||
|
||||
@@ -41,6 +42,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
|
||||
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
|
||||
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
|
||||
const [setupModalVisible, setSetupModalVisible] = useState<boolean>(false)
|
||||
const [setupStatus, setSetupStatus] = useState<any>(null)
|
||||
const [importedAccountId, setImportedAccountId] = useState<number | undefined>(undefined)
|
||||
|
||||
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
|
||||
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -254,11 +258,34 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
|
||||
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
|
||||
const accountsResponse = await apiService.accounts.list()
|
||||
let accountId: number | undefined = undefined
|
||||
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
|
||||
const newAccounts = accountsResponse.data.data.list || []
|
||||
const newAccount = newAccounts.find((acc: any) => acc.walletAddress === walletAddress)
|
||||
if (newAccount && onSuccess) {
|
||||
onSuccess(newAccount.id)
|
||||
if (newAccount) {
|
||||
accountId = newAccount.id
|
||||
setImportedAccountId(accountId)
|
||||
|
||||
// 检查账户设置状态
|
||||
let willShowSetupModal = false
|
||||
try {
|
||||
const setupResponse = await apiService.accounts.checkSetupStatus(newAccount.id)
|
||||
if (setupResponse.data.code === 0 && setupResponse.data.data) {
|
||||
const status = setupResponse.data.data
|
||||
setSetupStatus(status)
|
||||
const hasIncomplete = !status.proxyDeployed || !status.tradingEnabled || !status.tokensApproved
|
||||
if (hasIncomplete) {
|
||||
setSetupModalVisible(true)
|
||||
willShowSetupModal = true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查账户设置状态失败:', error)
|
||||
}
|
||||
// 未展示设置弹窗时才调用 onSuccess,避免父组件关闭导入弹窗导致设置弹窗被卸载
|
||||
if (!willShowSetupModal && onSuccess) {
|
||||
onSuccess(newAccount.id)
|
||||
}
|
||||
} else if (onSuccess) {
|
||||
onSuccess(0)
|
||||
}
|
||||
@@ -551,6 +578,37 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* 账户设置引导弹窗 */}
|
||||
<AccountSetupGuideModal
|
||||
visible={setupModalVisible}
|
||||
setupStatus={setupStatus}
|
||||
accountId={importedAccountId}
|
||||
onClose={() => {
|
||||
setSetupModalVisible(false)
|
||||
onSuccess?.(importedAccountId ?? 0)
|
||||
}}
|
||||
onComplete={async () => {
|
||||
// 刷新设置状态
|
||||
if (importedAccountId) {
|
||||
try {
|
||||
const setupResponse = await apiService.accounts.checkSetupStatus(importedAccountId)
|
||||
if (setupResponse.data.code === 0 && setupResponse.data.data) {
|
||||
setSetupStatus(setupResponse.data.data)
|
||||
const status = setupResponse.data.data
|
||||
// 如果所有步骤都完成了,关闭弹窗并通知父组件
|
||||
if (status.proxyDeployed && status.tradingEnabled && status.tokensApproved) {
|
||||
setSetupModalVisible(false)
|
||||
message.success(t('accountSetup.allCompleted.title'))
|
||||
onSuccess?.(importedAccountId ?? 0)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新设置状态失败:', error)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Modal, Alert, Space, Button, Typography } from 'antd'
|
||||
import { CheckCircleOutlined, ExclamationCircleOutlined, WalletOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import AccountSetupStatusBlock from './AccountSetupStatusBlock'
|
||||
import type { SetupStatus } from './AccountSetupStatusBlock'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface AccountSetupGuideModalProps {
|
||||
visible: boolean
|
||||
setupStatus: SetupStatus | null
|
||||
accountId?: number
|
||||
onClose: () => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
const AccountSetupGuideModal: React.FC<AccountSetupGuideModalProps> = ({
|
||||
visible,
|
||||
setupStatus: _initialStatus,
|
||||
accountId,
|
||||
onClose,
|
||||
onComplete
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const [allCompleted, setAllCompleted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) setAllCompleted(false)
|
||||
}, [visible, accountId])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<WalletOutlined style={{ fontSize: '20px', color: '#1890ff' }} />
|
||||
<span>{t('accountSetup.title')}</span>
|
||||
</Space>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
{allCompleted ? (
|
||||
<Button type="primary" onClick={onClose} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onClose} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('common.later')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width={isMobile ? '95%' : 680}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
destroyOnClose
|
||||
maskClosable={allCompleted}
|
||||
closable
|
||||
>
|
||||
<div style={{ padding: isMobile ? '16px 0' : '24px 0' }}>
|
||||
{allCompleted ? (
|
||||
<Alert
|
||||
message={t('accountSetup.allCompleted.title')}
|
||||
description={t('accountSetup.allCompleted.description')}
|
||||
type="success"
|
||||
icon={<CheckCircleOutlined />}
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
message={t('accountSetup.incomplete.title')}
|
||||
description={t('accountSetup.incomplete.description')}
|
||||
type="warning"
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{accountId != null && accountId > 0 ? (
|
||||
<AccountSetupStatusBlock
|
||||
accountId={accountId}
|
||||
embedded
|
||||
showApprovalDetails
|
||||
onAllCompleted={() => setAllCompleted(true)}
|
||||
onRefresh={onComplete}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary">{t('accountSetup.error.description')}</Text>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 24, padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('accountSetup.help')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountSetupGuideModal
|
||||
@@ -0,0 +1,305 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Card, Steps, Button, Space, Tag, Spin, Typography, message } from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
WalletOutlined,
|
||||
KeyOutlined,
|
||||
SafetyOutlined,
|
||||
LinkOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
|
||||
const { Paragraph, Text } = Typography
|
||||
|
||||
export interface SetupStatus {
|
||||
proxyDeployed: boolean
|
||||
tradingEnabled: boolean
|
||||
tokensApproved: boolean
|
||||
approvalDetails?: Record<string, string>
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface AccountSetupStatusBlockProps {
|
||||
accountId: number
|
||||
onRefresh?: () => void
|
||||
onAllCompleted?: () => void
|
||||
size?: 'small' | 'default'
|
||||
showApprovalDetails?: boolean
|
||||
/** 嵌入模式:不渲染 Card,仅渲染步骤与授权详情(供弹窗等复用) */
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
/** 步骤 key 与步骤编号对应 */
|
||||
const STEP_KEYS = ['step1', 'step2', 'step3'] as const
|
||||
const stepKeyToNumber = (key: string): number =>
|
||||
STEP_KEYS.indexOf(key as typeof STEP_KEYS[number]) + 1
|
||||
|
||||
const AccountSetupStatusBlock: React.FC<AccountSetupStatusBlockProps> = ({
|
||||
accountId,
|
||||
onRefresh,
|
||||
onAllCompleted,
|
||||
size = 'default',
|
||||
showApprovalDetails = true,
|
||||
embedded = false
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (accountId <= 0) return
|
||||
try {
|
||||
const response = await apiService.accounts.checkSetupStatus(accountId)
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setSetupStatus(response.data.data)
|
||||
} else {
|
||||
setSetupStatus(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取账户设置状态失败:', error)
|
||||
setSetupStatus(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchStatus()
|
||||
}, [accountId])
|
||||
|
||||
// 每 5 秒轮询最新状态(首次加载完成后且存在未完成步骤时轮询,全部完成后停止)
|
||||
useEffect(() => {
|
||||
if (accountId <= 0 || setupStatus == null) return
|
||||
const allCompleted =
|
||||
setupStatus.proxyDeployed &&
|
||||
setupStatus.tradingEnabled &&
|
||||
setupStatus.tokensApproved
|
||||
if (allCompleted) return
|
||||
const timer = setInterval(() => {
|
||||
fetchStatus()
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [accountId, setupStatus?.proxyDeployed, setupStatus?.tradingEnabled, setupStatus?.tokensApproved])
|
||||
|
||||
// 全部完成时通知父组件(供弹窗等关闭或更新用)
|
||||
const allCompleted =
|
||||
setupStatus != null &&
|
||||
setupStatus.proxyDeployed &&
|
||||
setupStatus.tradingEnabled &&
|
||||
setupStatus.tokensApproved
|
||||
useEffect(() => {
|
||||
if (allCompleted) onAllCompleted?.()
|
||||
}, [allCompleted, onAllCompleted])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await fetchStatus()
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
const handleStepAction = async (key: string) => {
|
||||
const stepNum = stepKeyToNumber(key)
|
||||
if (stepNum < 1) return
|
||||
setActionLoading(key)
|
||||
try {
|
||||
const response = await apiService.accounts.executeSetupStep(accountId, stepNum)
|
||||
const res = response.data
|
||||
if (res.code !== 0) {
|
||||
message.error(res.msg || t('accountSetup.actionFailed'))
|
||||
return
|
||||
}
|
||||
const data = res.data
|
||||
if (data?.redirectUrl) {
|
||||
window.open(data.redirectUrl, '_blank')
|
||||
}
|
||||
if (data?.success !== false) {
|
||||
await fetchStatus()
|
||||
onRefresh?.()
|
||||
if (data?.transactionHash) {
|
||||
message.success(t('accountSetup.actionSuccess'))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(t('accountSetup.actionFailed'))
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !setupStatus) {
|
||||
const loadingContent = (
|
||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
)
|
||||
return embedded ? <div>{loadingContent}</div> : (
|
||||
<Card title={t('accountSetup.title')} size={size}>{loadingContent}</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setupStatus) {
|
||||
const errorContent = (
|
||||
<>
|
||||
<Text type="secondary">{t('accountSetup.error.description')}</Text>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRefresh}>
|
||||
{t('accountSetup.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return embedded ? <div>{errorContent}</div> : (
|
||||
<Card title={t('accountSetup.title')} size={size}>{errorContent}</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
key: 'step1',
|
||||
title: t('accountSetup.step1.title'),
|
||||
description: t('accountSetup.step1.description'),
|
||||
icon: <WalletOutlined />,
|
||||
completed: setupStatus.proxyDeployed,
|
||||
actionLabel: t('accountSetup.step1.action')
|
||||
},
|
||||
{
|
||||
key: 'step2',
|
||||
title: t('accountSetup.step2.title'),
|
||||
description: t('accountSetup.step2.description'),
|
||||
icon: <KeyOutlined />,
|
||||
completed: setupStatus.tradingEnabled,
|
||||
actionLabel: t('accountSetup.step2.action')
|
||||
},
|
||||
{
|
||||
key: 'step3',
|
||||
title: t('accountSetup.step3.title'),
|
||||
description: t('accountSetup.step3.description'),
|
||||
icon: <SafetyOutlined />,
|
||||
completed: setupStatus.tokensApproved,
|
||||
actionLabel: t('accountSetup.step3.action')
|
||||
}
|
||||
]
|
||||
|
||||
const stepsContent = (
|
||||
<>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={steps.findIndex(s => !s.completed)}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Steps.Step
|
||||
key={step.key}
|
||||
title={
|
||||
<Space>
|
||||
<span>{step.title}</span>
|
||||
{step.completed ? (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||
{t('accountSetup.completed')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="warning" icon={<CloseCircleOutlined />}>
|
||||
{t('accountSetup.pending')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Paragraph style={{ marginBottom: 8, fontSize: 14, color: '#666' }}>
|
||||
{step.description}
|
||||
</Paragraph>
|
||||
{!step.completed && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => handleStepAction(step.key)}
|
||||
loading={actionLoading === step.key}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{step.actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
icon={step.icon}
|
||||
status={step.completed ? 'finish' : 'process'}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
|
||||
{showApprovalDetails && setupStatus.approvalDetails && Object.keys(setupStatus.approvalDetails).length > 0 && (
|
||||
<div style={{ marginTop: 16, padding: '12px', background: '#fafafa', borderRadius: 4 }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>{t('accountSetup.approvalDetails.title')}</Text>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||
{Object.entries(setupStatus.approvalDetails).map(([contract, allowance]) => {
|
||||
const isUnlimited = allowance === 'unlimited'
|
||||
const isApproved = isUnlimited || parseFloat(allowance) > 0
|
||||
const displayText = isUnlimited
|
||||
? t('accountSetup.approvalDetails.unlimited')
|
||||
: isApproved
|
||||
? `${parseFloat(allowance).toFixed(2)} USDC`
|
||||
: t('accountSetup.approvalDetails.notApproved')
|
||||
return (
|
||||
<div
|
||||
key={contract}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: 13,
|
||||
minHeight: 24
|
||||
}}
|
||||
>
|
||||
<span>{t(`accountSetup.approvalDetails.${contract}`) || contract}</span>
|
||||
<span style={{ minWidth: 100, textAlign: 'right' }}>{displayText}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setupStatus.error && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="danger">{setupStatus.error}</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return <div style={{ position: 'relative' }}>{stepsContent}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('accountSetup.title')}
|
||||
size={size}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
>
|
||||
{t('accountSetup.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{stepsContent}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountSetupStatusBlock
|
||||
@@ -13,6 +13,7 @@
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"confirm": "Confirm",
|
||||
"later": "Later",
|
||||
"submit": "Submit",
|
||||
"reset": "Reset",
|
||||
"close": "Close",
|
||||
@@ -237,6 +238,51 @@
|
||||
"select": "Select this proxy address"
|
||||
}
|
||||
},
|
||||
"accountSetup": {
|
||||
"title": "Account Setup Check",
|
||||
"completed": "Completed",
|
||||
"pending": "Pending",
|
||||
"refresh": "Refresh Status",
|
||||
"allCompleted": {
|
||||
"title": "All Setup Completed",
|
||||
"description": "Your account is ready to use all features."
|
||||
},
|
||||
"incomplete": {
|
||||
"title": "Account Setup Incomplete",
|
||||
"description": "Please complete the following setup steps to ensure your account works properly."
|
||||
},
|
||||
"step1": {
|
||||
"title": "Deploy Proxy Wallet",
|
||||
"description": "Proxy wallet is required for trading on Polymarket. Safe accounts can deploy with one click; Magic accounts will be redirected to Polymarket.",
|
||||
"action": "Deploy Proxy Wallet"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Enable Trading",
|
||||
"description": "API credentials are required for trading. Click the button below to let the system automatically obtain and save the API Key.",
|
||||
"action": "Enable Trading (One-Click)"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Approve Tokens",
|
||||
"description": "You need to authorize the proxy wallet to use your USDC tokens. Click the button below to complete token approval automatically.",
|
||||
"action": "Approve Tokens (One-Click)"
|
||||
},
|
||||
"approvalDetails": {
|
||||
"title": "Token Approval Details",
|
||||
"CTF_CONTRACT": "CTF Contract",
|
||||
"CTF_EXCHANGE": "CTF Exchange",
|
||||
"NEG_RISK_EXCHANGE": "Neg Risk Exchange",
|
||||
"NEG_RISK_ADAPTER": "Neg Risk Adapter",
|
||||
"notApproved": "Not Approved",
|
||||
"unlimited": "Unlimited"
|
||||
},
|
||||
"error": {
|
||||
"title": "Check Failed",
|
||||
"description": "Unable to check account setup status, please try again later."
|
||||
},
|
||||
"actionSuccess": "Operation successful",
|
||||
"actionFailed": "Operation failed, please try again later",
|
||||
"help": "Tip: After completing the setup, click the \"Refresh Status\" button to update the check results. If all steps are completed, you can use account features normally."
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader Management",
|
||||
"leaderName": "Leader Name",
|
||||
@@ -1428,7 +1474,8 @@
|
||||
"delete": "Delete",
|
||||
"viewTriggers": "Orders",
|
||||
"deleteConfirm": "Delete this strategy?",
|
||||
"fetchFailed": "Failed to fetch list"
|
||||
"fetchFailed": "Failed to fetch list",
|
||||
"configGuide": "Configuration Guide"
|
||||
},
|
||||
"form": {
|
||||
"walletTip": "Use a dedicated wallet to avoid balance or order issues.",
|
||||
@@ -1451,13 +1498,17 @@
|
||||
"update": "Update",
|
||||
"timeWindowStartLEEnd": "Window start must not be greater than end",
|
||||
"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 20 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"
|
||||
"spreadMode": "Spread",
|
||||
"spreadModeTip": "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 20 klines (updated each period); Fixed: you enter a value (e.g. 30); None: no spread check, order when price is in range.",
|
||||
"spreadModeNone": "None",
|
||||
"spreadModeFixed": "Fixed",
|
||||
"spreadModeAuto": "Auto",
|
||||
"spreadValue": "Spread value (USDC)",
|
||||
"spreadValuePlaceholder": "e.g. 30",
|
||||
"spreadDirection": "Spread Direction",
|
||||
"spreadDirectionTip": "Min spread: trigger when spread ≥ configured value, buy price fixed at 0.99; Max spread: trigger when spread ≤ configured value, buy price = trigger price + 0.02 (suitable for low-price buying).",
|
||||
"spreadDirectionMin": "Min Spread",
|
||||
"spreadDirectionMax": "Max Spread"
|
||||
},
|
||||
"redeemRequiredModal": {
|
||||
"title": "Configure Auto Redeem First",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"confirm": "确定",
|
||||
"later": "稍后",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"viewDetail": "查看详情",
|
||||
@@ -236,6 +237,51 @@
|
||||
"select": "选择此代理地址"
|
||||
}
|
||||
},
|
||||
"accountSetup": {
|
||||
"title": "账户设置检查",
|
||||
"completed": "已完成",
|
||||
"pending": "待完成",
|
||||
"refresh": "刷新状态",
|
||||
"allCompleted": {
|
||||
"title": "所有设置已完成",
|
||||
"description": "您的账户已准备就绪,可以开始使用所有功能。"
|
||||
},
|
||||
"incomplete": {
|
||||
"title": "账户设置未完成",
|
||||
"description": "请完成以下设置步骤,以确保账户可以正常使用。"
|
||||
},
|
||||
"step1": {
|
||||
"title": "部署代理钱包",
|
||||
"description": "代理钱包是您在 Polymarket 上进行交易的必要组件。Safe 账户可点击下方按钮由系统一键部署;Magic 账户将跳转至 Polymarket 完成。",
|
||||
"action": "部署代理钱包"
|
||||
},
|
||||
"step2": {
|
||||
"title": "启用交易",
|
||||
"description": "需要配置 API 凭证才能进行交易。点击下方按钮由系统自动获取并保存 API Key。",
|
||||
"action": "一键启用交易"
|
||||
},
|
||||
"step3": {
|
||||
"title": "批准代币",
|
||||
"description": "需要授权代理钱包使用您的 USDC 代币。点击下方按钮由系统自动完成代币授权。",
|
||||
"action": "一键批准代币"
|
||||
},
|
||||
"approvalDetails": {
|
||||
"title": "代币授权详情",
|
||||
"CTF_CONTRACT": "CTF 合约",
|
||||
"CTF_EXCHANGE": "CTF 交易所",
|
||||
"NEG_RISK_EXCHANGE": "负风险交易所",
|
||||
"NEG_RISK_ADAPTER": "负风险适配器",
|
||||
"notApproved": "未授权",
|
||||
"unlimited": "无限"
|
||||
},
|
||||
"error": {
|
||||
"title": "检查失败",
|
||||
"description": "无法检查账户设置状态,请稍后重试。"
|
||||
},
|
||||
"actionSuccess": "操作成功",
|
||||
"actionFailed": "操作失败,请稍后重试",
|
||||
"help": "提示:完成设置后,点击「刷新状态」按钮更新检查结果。如果所有步骤都已完成,您可以正常使用账户功能。"
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
"leaderName": "Leader 名称",
|
||||
@@ -1427,7 +1473,8 @@
|
||||
"delete": "删除",
|
||||
"viewTriggers": "订单",
|
||||
"deleteConfirm": "确定删除该策略?",
|
||||
"fetchFailed": "获取列表失败"
|
||||
"fetchFailed": "获取列表失败",
|
||||
"configGuide": "配置指南"
|
||||
},
|
||||
"form": {
|
||||
"walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。",
|
||||
@@ -1450,13 +1497,17 @@
|
||||
"update": "更新",
|
||||
"timeWindowStartLEEnd": "时间区间开始不能大于结束",
|
||||
"timeWindowExceed": "时间区间不能超过周期长度",
|
||||
"minSpreadMode": "最小价差",
|
||||
"minSpreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30),仅当价差 ≥ 该值时才下单;无:不校验价差,满足价格区间即下单。",
|
||||
"minSpreadModeNone": "无",
|
||||
"minSpreadModeFixed": "固定",
|
||||
"minSpreadModeAuto": "自动",
|
||||
"minSpreadValue": "最小价差数值 (USDC)",
|
||||
"minSpreadValuePlaceholder": "如 30"
|
||||
"spreadMode": "价差",
|
||||
"spreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30);无:不校验价差,满足价格区间即下单。",
|
||||
"spreadModeNone": "无",
|
||||
"spreadModeFixed": "固定",
|
||||
"spreadModeAuto": "自动",
|
||||
"spreadValue": "价差数值 (USDC)",
|
||||
"spreadValuePlaceholder": "如 30",
|
||||
"spreadDirection": "价差方向",
|
||||
"spreadDirectionTip": "最小价差:价差 ≥ 配置值时触发,买入价固定 0.99;最大价差:价差 ≤ 配置值时触发,买入价 = 触发价 + 0.02(适合低价买入)。",
|
||||
"spreadDirectionMin": "最小价差",
|
||||
"spreadDirectionMax": "最大价差"
|
||||
},
|
||||
"redeemRequiredModal": {
|
||||
"title": "请先配置自动赎回",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"success": "成功",
|
||||
"failed": "失敗",
|
||||
"confirm": "確認",
|
||||
"later": "稍後",
|
||||
"submit": "提交",
|
||||
"reset": "重置",
|
||||
"close": "關閉",
|
||||
@@ -237,6 +238,51 @@
|
||||
"select": "選擇此代理地址"
|
||||
}
|
||||
},
|
||||
"accountSetup": {
|
||||
"title": "帳戶設置檢查",
|
||||
"completed": "已完成",
|
||||
"pending": "待完成",
|
||||
"refresh": "刷新狀態",
|
||||
"allCompleted": {
|
||||
"title": "所有設置已完成",
|
||||
"description": "您的帳戶已準備就緒,可以開始使用所有功能。"
|
||||
},
|
||||
"incomplete": {
|
||||
"title": "帳戶設置未完成",
|
||||
"description": "請完成以下設置步驟,以確保帳戶可以正常使用。"
|
||||
},
|
||||
"step1": {
|
||||
"title": "部署代理錢包",
|
||||
"description": "代理錢包是您在 Polymarket 上進行交易的必要組件。Safe 帳戶可點擊下方按鈕由系統一鍵部署;Magic 帳戶將跳轉至 Polymarket 完成。",
|
||||
"action": "部署代理錢包"
|
||||
},
|
||||
"step2": {
|
||||
"title": "啟用交易",
|
||||
"description": "需要配置 API 憑證才能進行交易。點擊下方按鈕由系統自動獲取並保存 API Key。",
|
||||
"action": "一鍵啟用交易"
|
||||
},
|
||||
"step3": {
|
||||
"title": "批准代幣",
|
||||
"description": "需要授權代理錢包使用您的 USDC 代幣。點擊下方按鈕由系統自動完成代幣授權。",
|
||||
"action": "一鍵批准代幣"
|
||||
},
|
||||
"approvalDetails": {
|
||||
"title": "代幣授權詳情",
|
||||
"CTF_CONTRACT": "CTF 合約",
|
||||
"CTF_EXCHANGE": "CTF 交易所",
|
||||
"NEG_RISK_EXCHANGE": "負風險交易所",
|
||||
"NEG_RISK_ADAPTER": "負風險適配器",
|
||||
"notApproved": "未授權",
|
||||
"unlimited": "無限"
|
||||
},
|
||||
"error": {
|
||||
"title": "檢查失敗",
|
||||
"description": "無法檢查帳戶設置狀態,請稍後重試。"
|
||||
},
|
||||
"actionSuccess": "操作成功",
|
||||
"actionFailed": "操作失敗,請稍後重試",
|
||||
"help": "提示:完成設置後,點擊「刷新狀態」按鈕更新檢查結果。如果所有步驟都已完成,您可以正常使用帳戶功能。"
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
"leaderName": "Leader 名稱",
|
||||
@@ -1428,7 +1474,8 @@
|
||||
"delete": "刪除",
|
||||
"viewTriggers": "訂單",
|
||||
"deleteConfirm": "確定刪除該策略?",
|
||||
"fetchFailed": "獲取列表失敗"
|
||||
"fetchFailed": "獲取列表失敗",
|
||||
"configGuide": "配置指南"
|
||||
},
|
||||
"form": {
|
||||
"walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。",
|
||||
@@ -1451,13 +1498,17 @@
|
||||
"update": "更新",
|
||||
"timeWindowStartLEEnd": "時間區間開始不能大於結束",
|
||||
"timeWindowExceed": "時間區間不能超過週期長度",
|
||||
"minSpreadMode": "最小價差",
|
||||
"minSpreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30),僅當價差 ≥ 該值時才下單;無:不校驗價差,滿足價格區間即下單。",
|
||||
"minSpreadModeNone": "無",
|
||||
"minSpreadModeFixed": "固定",
|
||||
"minSpreadModeAuto": "自動",
|
||||
"minSpreadValue": "最小價差數值 (USDC)",
|
||||
"minSpreadValuePlaceholder": "如 30"
|
||||
"spreadMode": "價差",
|
||||
"spreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30);無:不校驗價差,滿足價格區間即下單。",
|
||||
"spreadModeNone": "無",
|
||||
"spreadModeFixed": "固定",
|
||||
"spreadModeAuto": "自動",
|
||||
"spreadValue": "價差數值 (USDC)",
|
||||
"spreadValuePlaceholder": "如 30",
|
||||
"spreadDirection": "價差方向",
|
||||
"spreadDirectionTip": "最小價差:價差 ≥ 配置值時觸發,買入價固定 0.99;最大價差:價差 ≤ 配置值時觸發,買入價 = 觸發價 + 0.02(適合低價買入)。",
|
||||
"spreadDirectionMin": "最小價差",
|
||||
"spreadDirectionMax": "最大價差"
|
||||
},
|
||||
"redeemRequiredModal": {
|
||||
"title": "請先配置自動贖回",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAccountStore } from '../store/accountStore'
|
||||
import type { Account } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { formatUSDC } from '../utils'
|
||||
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
@@ -150,10 +151,7 @@ const AccountDetail: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditModalVisible(true)
|
||||
editForm.setFieldsValue({
|
||||
accountName: account.accountName || '',
|
||||
apiKey: '', // 不显示实际值,留空表示不修改
|
||||
apiSecret: '', // 不显示实际值,留空表示不修改
|
||||
apiPassphrase: '' // 不显示实际值,留空表示不修改
|
||||
accountName: account.accountName || ''
|
||||
})
|
||||
}}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
@@ -214,46 +212,23 @@ const AccountDetail: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Card
|
||||
title={t('account.apiCredentials')}
|
||||
style={{
|
||||
|
||||
{accountId && (
|
||||
<div style={{
|
||||
marginTop: isMobile ? '12px' : '16px',
|
||||
margin: isMobile ? '0 -8px' : '0',
|
||||
borderRadius: isMobile ? '0' : undefined
|
||||
}}
|
||||
>
|
||||
<Descriptions
|
||||
column={isMobile ? 1 : 2}
|
||||
bordered
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ fontSize: isMobile ? '14px' : undefined }}
|
||||
>
|
||||
<Descriptions.Item label={t('account.apiKey')}>
|
||||
<Tag color={account.apiKeyConfigured ? 'success' : 'default'}>
|
||||
{account.apiKeyConfigured ? t('account.configured') : t('account.notConfigured')}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('account.apiSecret')}>
|
||||
<Tag color={account.apiSecretConfigured ? 'success' : 'default'}>
|
||||
{account.apiSecretConfigured ? t('account.configured') : t('account.notConfigured')}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('account.apiPassphrase')}>
|
||||
<Tag color={account.apiPassphraseConfigured ? 'success' : 'default'}>
|
||||
{account.apiPassphraseConfigured ? t('account.configured') : t('account.notConfigured')}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('account.apiCredentials')}>
|
||||
{account.apiKeyConfigured && account.apiSecretConfigured && account.apiPassphraseConfigured ? (
|
||||
<Tag color="success">{t('account.fullConfig')}</Tag>
|
||||
) : (
|
||||
<Tag color="warning">{t('account.partialConfig')}</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
margin: isMobile ? '0 -8px' : '0'
|
||||
}}>
|
||||
<AccountSetupStatusBlock
|
||||
accountId={Number(accountId)}
|
||||
onRefresh={() => { loadAccountDetail(); loadBalance() }}
|
||||
size={isMobile ? 'small' : 'default'}
|
||||
showApprovalDetails={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: isMobile ? '12px 0' : '16px 0' }} />
|
||||
|
||||
{(account.totalOrders !== undefined || account.totalPnl !== undefined ||
|
||||
account.activeOrders !== undefined ||
|
||||
account.completedOrders !== undefined || account.positionCount !== undefined) ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Account } from '../types'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { formatUSDC } from '../utils'
|
||||
import AccountImportForm from '../components/AccountImportForm'
|
||||
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
@@ -204,10 +205,7 @@ const AccountList: React.FC = () => {
|
||||
setEditAccount(accountDetail)
|
||||
|
||||
editForm.setFieldsValue({
|
||||
accountName: accountDetail.accountName || '',
|
||||
apiKey: '', // 不显示实际值,留空表示不修改
|
||||
apiSecret: '', // 不显示实际值,留空表示不修改
|
||||
apiPassphrase: '' // 不显示实际值,留空表示不修改
|
||||
accountName: accountDetail.accountName || ''
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('打开编辑失败:', error)
|
||||
@@ -720,35 +718,14 @@ const AccountList: React.FC = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Descriptions
|
||||
column={isMobile ? 1 : 2}
|
||||
bordered
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
title={t('accountList.apiCredentials')}
|
||||
>
|
||||
<Descriptions.Item label={t('accountList.apiKey')}>
|
||||
<Tag color={detailAccount.apiKeyConfigured ? 'success' : 'default'}>
|
||||
{detailAccount.apiKeyConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('accountList.apiSecret')}>
|
||||
<Tag color={detailAccount.apiSecretConfigured ? 'success' : 'default'}>
|
||||
{detailAccount.apiSecretConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('accountList.apiPassphrase')}>
|
||||
<Tag color={detailAccount.apiPassphraseConfigured ? 'success' : 'default'}>
|
||||
{detailAccount.apiPassphraseConfigured ? t('accountList.configured') : t('accountList.notConfiguredStatus')}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('accountList.configStatus')}>
|
||||
{detailAccount.apiKeyConfigured && detailAccount.apiSecretConfigured && detailAccount.apiPassphraseConfigured ? (
|
||||
<Tag color="success">{t('accountList.fullConfig')}</Tag>
|
||||
) : (
|
||||
<Tag color="warning">{t('accountList.partialConfig')}</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<AccountSetupStatusBlock
|
||||
accountId={detailAccount.id}
|
||||
onRefresh={handleRefreshDetailBalance}
|
||||
size={isMobile ? 'small' : 'default'}
|
||||
showApprovalDetails={true}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{(detailAccount.totalOrders !== undefined || detailAccount.totalPnl !== undefined ||
|
||||
detailAccount.activeOrders !== undefined ||
|
||||
|
||||
@@ -26,16 +26,17 @@ import {
|
||||
} from 'antd'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { apiService } from '../services/api'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types'
|
||||
import { formatUSDC, formatNumber } from '../utils'
|
||||
import { getVersionInfo } from '../utils/version'
|
||||
|
||||
const CryptoTailStrategyList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { accounts, fetchAccounts } = useAccountStore()
|
||||
@@ -148,7 +149,8 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
enabled: true,
|
||||
amountMode: 'RATIO',
|
||||
maxPrice: '1',
|
||||
minSpreadMode: 'AUTO',
|
||||
spreadMode: 'AUTO',
|
||||
spreadDirection: 'MIN',
|
||||
windowStartMinutes: 0,
|
||||
windowStartSeconds: 0
|
||||
})
|
||||
@@ -169,8 +171,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
maxPrice: record.maxPrice,
|
||||
amountMode: record.amountMode,
|
||||
amountValue: record.amountValue,
|
||||
minSpreadMode: record.minSpreadMode ?? 'AUTO',
|
||||
minSpreadValue: record.minSpreadValue ?? undefined,
|
||||
spreadMode: record.spreadMode ?? 'AUTO',
|
||||
spreadValue: record.spreadValue ?? undefined,
|
||||
spreadDirection: record.spreadDirection ?? 'MIN',
|
||||
enabled: record.enabled
|
||||
})
|
||||
setFormModalOpen(true)
|
||||
@@ -203,8 +206,9 @@ 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),
|
||||
spreadMode: (v.spreadMode as string) || 'AUTO',
|
||||
spreadValue: v.spreadMode === 'FIXED' && v.spreadValue != null ? String(v.spreadValue) : (v.spreadMode === 'AUTO' && v.spreadValue != null ? String(v.spreadValue) : undefined),
|
||||
spreadDirection: v.spreadDirection as string || 'MIN',
|
||||
enabled: v.enabled !== false
|
||||
}
|
||||
if (editingId) {
|
||||
@@ -217,8 +221,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
maxPrice: payload.maxPrice,
|
||||
amountMode: payload.amountMode,
|
||||
amountValue: payload.amountValue,
|
||||
minSpreadMode: payload.minSpreadMode,
|
||||
minSpreadValue: payload.minSpreadValue,
|
||||
spreadMode: payload.spreadMode,
|
||||
spreadValue: payload.spreadValue,
|
||||
spreadDirection: payload.spreadDirection,
|
||||
enabled: payload.enabled
|
||||
})
|
||||
if (res.data.code === 0) {
|
||||
@@ -231,7 +236,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
} else {
|
||||
const res = await apiService.cryptoTailStrategy.create({
|
||||
...payload,
|
||||
minSpreadValue: payload.minSpreadMode === 'FIXED' ? payload.minSpreadValue : undefined
|
||||
spreadValue: payload.spreadMode === 'FIXED' ? payload.spreadValue : undefined
|
||||
})
|
||||
if (res.data.code === 0) {
|
||||
message.success(t('common.success'))
|
||||
@@ -531,10 +536,26 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
})
|
||||
}, [formModalOpen, editingId, selectedMarket, intervalSeconds])
|
||||
|
||||
const getGuideUrl = () => {
|
||||
const { githubRepoUrl } = getVersionInfo()
|
||||
const lang = i18n.language === 'zh-CN' || i18n.language === 'zh-TW' ? 'zh' : 'en'
|
||||
return `${githubRepoUrl}/blob/main/docs/crypto-tail-strategy/${lang}/crypto-tail-strategy-user-guide.md`
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 12 : 24 }}>
|
||||
<h1 style={{ marginBottom: 16, fontSize: isMobile ? 20 : 24 }}>{t('cryptoTailStrategy.list.title')}</h1>
|
||||
{binanceUnhealthy.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<h1 style={{ margin: 0, fontSize: isMobile ? 20 : 24 }}>{t('cryptoTailStrategy.list.title')}</h1>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => window.open(getGuideUrl(), '_blank')}
|
||||
style={{ padding: 0, height: 'auto', fontSize: isMobile ? 14 : 16 }}
|
||||
>
|
||||
{t('cryptoTailStrategy.list.configGuide')}
|
||||
</Button>
|
||||
</div>
|
||||
{binanceUnhealthy.length > 0 && list.some((s) => s.enabled) && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
@@ -723,7 +744,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', minSpreadMode: 'AUTO', enabled: true }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ amountMode: 'RATIO', maxPrice: '1', spreadMode: 'AUTO', spreadDirection: 'MIN', enabled: true }}>
|
||||
<Form.Item name="accountId" label={t('cryptoTailStrategy.form.selectAccount')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder={t('cryptoTailStrategy.form.selectAccount')}
|
||||
@@ -817,37 +838,37 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="minSpreadMode"
|
||||
name="spreadMode"
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>{t('cryptoTailStrategy.form.minSpreadMode')}</span>
|
||||
<Tooltip title={t('cryptoTailStrategy.form.minSpreadModeTip')}>
|
||||
<span>{t('cryptoTailStrategy.form.spreadMode')}</span>
|
||||
<Tooltip title={t('cryptoTailStrategy.form.spreadModeTip')}>
|
||||
<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 value="AUTO">{t('cryptoTailStrategy.form.spreadModeAuto')}</Radio>
|
||||
<Radio value="FIXED">{t('cryptoTailStrategy.form.spreadModeFixed')}</Radio>
|
||||
<Radio value="NONE">{t('cryptoTailStrategy.form.spreadModeNone')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => prev.minSpreadMode !== curr.minSpreadMode}
|
||||
shouldUpdate={(prev, curr) => prev.spreadMode !== curr.spreadMode}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('minSpreadMode') === 'FIXED' ? (
|
||||
getFieldValue('spreadMode') === 'FIXED' ? (
|
||||
<Form.Item
|
||||
name="minSpreadValue"
|
||||
label={t('cryptoTailStrategy.form.minSpreadValue')}
|
||||
name="spreadValue"
|
||||
label={t('cryptoTailStrategy.form.spreadValue')}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder={t('cryptoTailStrategy.form.minSpreadValuePlaceholder')}
|
||||
placeholder={t('cryptoTailStrategy.form.spreadValuePlaceholder')}
|
||||
style={{ width: '100%' }}
|
||||
stringMode
|
||||
/>
|
||||
@@ -855,6 +876,22 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="spreadDirection"
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>{t('cryptoTailStrategy.form.spreadDirection')}</span>
|
||||
<Tooltip title={t('cryptoTailStrategy.form.spreadDirectionTip')}>
|
||||
<InfoCircleOutlined style={{ color: '#999', cursor: 'help', fontSize: 14 }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="MIN">{t('cryptoTailStrategy.form.spreadDirectionMin')}</Radio>
|
||||
<Radio value="MAX">{t('cryptoTailStrategy.form.spreadDirectionMax')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" valuePropName="checked">
|
||||
<Switch checkedChildren={t('common.enabled')} unCheckedChildren={t('common.disabled')} />
|
||||
</Form.Item>
|
||||
@@ -923,9 +960,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
align: 'center',
|
||||
render: (i: number) =>
|
||||
i === 0 ? (
|
||||
<Tag icon={<ArrowUpOutlined />} color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
||||
<Tag color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
||||
) : (
|
||||
<Tag icon={<ArrowDownOutlined />} color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
||||
<Tag color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -998,9 +1035,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
align: 'center',
|
||||
render: (i: number) =>
|
||||
i === 0 ? (
|
||||
<Tag icon={<ArrowUpOutlined />} color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
||||
<Tag color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
||||
) : (
|
||||
<Tag icon={<ArrowDownOutlined />} color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
||||
<Tag color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -215,13 +215,28 @@ export const apiService = {
|
||||
/**
|
||||
* 导入账户
|
||||
*/
|
||||
import: (data: any) =>
|
||||
import: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/accounts/import', data),
|
||||
|
||||
/**
|
||||
* 检查账户设置状态
|
||||
*/
|
||||
checkSetupStatus: (accountId: number) =>
|
||||
apiClient.post<ApiResponse<any>>('/accounts/check-setup-status', { accountId }),
|
||||
|
||||
/**
|
||||
* 执行设置步骤(步骤1 返回跳转 URL,步骤2/3 由后端执行)
|
||||
*/
|
||||
executeSetupStep: (accountId: number, step: number) =>
|
||||
apiClient.post<ApiResponse<{ success: boolean; redirectUrl?: string; transactionHash?: string }>>(
|
||||
'/accounts/execute-setup-step',
|
||||
{ accountId, step }
|
||||
),
|
||||
|
||||
/**
|
||||
* 更新账户
|
||||
*/
|
||||
update: (data: any) =>
|
||||
update: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/accounts/update', data),
|
||||
|
||||
/**
|
||||
@@ -447,8 +462,9 @@ export const apiService = {
|
||||
maxPrice?: string
|
||||
amountMode: string
|
||||
amountValue: string
|
||||
minSpreadMode?: string
|
||||
minSpreadValue?: string | null
|
||||
spreadMode?: string
|
||||
spreadValue?: string | null
|
||||
spreadDirection?: string
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data),
|
||||
@@ -461,8 +477,9 @@ export const apiService = {
|
||||
maxPrice?: string
|
||||
amountMode?: string
|
||||
amountValue?: string
|
||||
minSpreadMode?: string
|
||||
minSpreadValue?: string | null
|
||||
spreadMode?: string
|
||||
spreadValue?: string | null
|
||||
spreadDirection?: string
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
|
||||
|
||||
@@ -932,9 +932,9 @@ export interface SystemConfig {
|
||||
builderApiKeyConfigured: boolean
|
||||
builderSecretConfigured: boolean
|
||||
builderPassphraseConfigured: boolean
|
||||
builderApiKeyDisplay?: string // Builder API Key 显示值(部分显示)
|
||||
builderSecretDisplay?: string // Builder Secret 显示值(部分显示)
|
||||
builderPassphraseDisplay?: string // Builder Passphrase 显示值(部分显示)
|
||||
builderApiKeyDisplay?: string // Builder API Key 显示值(完整)
|
||||
builderSecretDisplay?: string // Builder Secret 显示值(完整)
|
||||
builderPassphraseDisplay?: string // Builder Passphrase 显示值(完整)
|
||||
autoRedeemEnabled: boolean // 自动赎回(系统级别配置,默认开启)
|
||||
}
|
||||
|
||||
@@ -1051,10 +1051,12 @@ export interface CryptoTailStrategyDto {
|
||||
maxPrice: string
|
||||
amountMode: string
|
||||
amountValue: string
|
||||
/** 最小价差模式: NONE, FIXED, AUTO */
|
||||
minSpreadMode?: string
|
||||
/** 最小价差数值(FIXED 时必填;AUTO 时可为计算值) */
|
||||
minSpreadValue?: string | null
|
||||
/** 价差模式: NONE, FIXED, AUTO */
|
||||
spreadMode?: string
|
||||
/** 价差数值 */
|
||||
spreadValue?: string | null
|
||||
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
|
||||
spreadDirection?: string
|
||||
enabled: boolean
|
||||
lastTriggerAt?: number
|
||||
/** 已实现总收益 USDC */
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
"description": "Utility scripts for Polyhermes",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"unwrap-wcol": "node unwrap-wcol.js",
|
||||
"get-order-detail": "node get-order-detail.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",
|
||||
"ethers": "^5.7.1",
|
||||
"@polymarket/clob-client": "^5.2.1",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 将 Wrapped Collateral (WCOL) 解包为 USDC.e
|
||||
*
|
||||
* Polygon 上 Polymarket 使用 USDC.e(Bridged USDC),解包后到账的为 USDC.e,在钱包/区块浏览器中显示为 USDC.e。
|
||||
*
|
||||
* 合约: Polymarket Neg Risk WrappedCollateral
|
||||
* Polygon: 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
|
||||
* 方法: unwrap(address _to, uint256 _amount)
|
||||
*
|
||||
* 使用方式:
|
||||
* 1) 从 Safe 解包(Gasless,需 Builder 凭证,主钱包无需 POL):
|
||||
* PRIVATE_KEY=0x... SAFE_ADDRESS=0xB4c3... BUILDERS_API_KEY=... BUILDERS_SECRET=... BUILDERS_PASSPHRASE=... node scripts/unwrap-wcol.js
|
||||
* 2) 从 Safe 解包(自付 gas,主钱包需少量 POL):
|
||||
* PRIVATE_KEY=0x... SAFE_ADDRESS=0xB4c3... node scripts/unwrap-wcol.js
|
||||
* 3) 从 EOA 解包(WCOL 在主钱包上):
|
||||
* PRIVATE_KEY=0x... node scripts/unwrap-wcol.js
|
||||
*
|
||||
* 环境变量:
|
||||
* PRIVATE_KEY 主钱包私钥(必需)
|
||||
* SAFE_ADDRESS 若设置则从 Safe 执行 unwrap
|
||||
* UNWRAP_TO_MAIN_WALLET=1 从 Safe 解包时,USDC.e 转到主钱包;不设则转到 Safe
|
||||
* BUILDERS_API_KEY / BUILDERS_SECRET / BUILDERS_PASSPHRASE 三者齐备时走 Builder Relayer(Gasless,主钱包无需 POL)
|
||||
* RPC_URL Polygon RPC(可选)
|
||||
* RELAYER_URL Builder Relayer 地址(可选,默认 https://relayer-v2.polymarket.com)
|
||||
*/
|
||||
|
||||
import { ethers } from "ethers";
|
||||
import crypto from "crypto";
|
||||
|
||||
const CHAIN_ID = 137;
|
||||
const RPC_URL = process.env.RPC_URL || "https://polygon-rpc.com";
|
||||
const RELAYER_URL = (process.env.RELAYER_URL || "https://relayer-v2.polymarket.com").replace(/\/$/, "");
|
||||
const WCOL_ADDRESS = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2";
|
||||
|
||||
const WCOL_ABI = [
|
||||
"function balanceOf(address account) view returns (uint256)",
|
||||
"function decimals() view returns (uint8)",
|
||||
"function unwrap(address _to, uint256 _amount)",
|
||||
];
|
||||
|
||||
// Safe 合约:nonce()
|
||||
const SAFE_ABI = ["function nonce() view returns (uint256)"];
|
||||
|
||||
function normalizePrivateKey(key) {
|
||||
if (!key || !key.trim()) return null;
|
||||
const k = key.trim();
|
||||
return k.startsWith("0x") ? k : "0x" + k;
|
||||
}
|
||||
|
||||
function getPrivateKey() {
|
||||
const key = normalizePrivateKey(process.env.PRIVATE_KEY);
|
||||
if (!key) {
|
||||
console.error("请设置环境变量 PRIVATE_KEY(主钱包私钥)");
|
||||
process.exit(1);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
// EIP-712: domain type hash for Safe (chainId + verifyingContract)
|
||||
function getSafeDomainTypeHash() {
|
||||
const typeStr = "EIP712Domain(uint256 chainId,address verifyingContract)";
|
||||
return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(typeStr));
|
||||
}
|
||||
|
||||
function encodeSafeDomain(chainId, verifyingContract) {
|
||||
const typeHash = getSafeDomainTypeHash();
|
||||
const chainIdHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(chainId).toHexString(), 32);
|
||||
const contractHex = ethers.utils.hexZeroPad(verifyingContract.toLowerCase(), 32);
|
||||
return ethers.utils.keccak256(ethers.utils.concat([typeHash, chainIdHex, contractHex]));
|
||||
}
|
||||
|
||||
// SafeTx type hash
|
||||
function getSafeTxTypeHash() {
|
||||
const typeStr =
|
||||
"SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)";
|
||||
return ethers.utils.keccak256(ethers.utils.toUtf8Bytes(typeStr));
|
||||
}
|
||||
|
||||
function encodeSafeTxMessage(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce) {
|
||||
const typeHash = getSafeTxTypeHash();
|
||||
const toHex = ethers.utils.hexZeroPad(to.toLowerCase(), 32);
|
||||
const valueHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(value).toHexString(), 32);
|
||||
const dataHash = data && data !== "0x" ? ethers.utils.keccak256(data) : ethers.constants.HashZero;
|
||||
const opHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(operation).toHexString(), 32);
|
||||
const safeTxGasHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(safeTxGas).toHexString(), 32);
|
||||
const baseGasHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(baseGas).toHexString(), 32);
|
||||
const gasPriceHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(gasPrice).toHexString(), 32);
|
||||
const gasTokenHex = ethers.utils.hexZeroPad(gasToken.toLowerCase(), 32);
|
||||
const refundHex = ethers.utils.hexZeroPad(refundReceiver.toLowerCase(), 32);
|
||||
const nonceHex = ethers.utils.hexZeroPad(ethers.BigNumber.from(nonce).toHexString(), 32);
|
||||
return ethers.utils.keccak256(
|
||||
ethers.utils.concat([
|
||||
typeHash,
|
||||
toHex,
|
||||
valueHex,
|
||||
dataHash,
|
||||
opHex,
|
||||
safeTxGasHex,
|
||||
baseGasHex,
|
||||
gasPriceHex,
|
||||
gasTokenHex,
|
||||
refundHex,
|
||||
nonceHex,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function hashStructuredData(domainSeparator, messageHash) {
|
||||
const prefix = "0x1901";
|
||||
return ethers.utils.keccak256(ethers.utils.concat([prefix, domainSeparator, messageHash]));
|
||||
}
|
||||
|
||||
// Gnosis Safe 签名:先对 structHash 做 personal_sign 风格(\x19Ethereum Signed Message:\n32 + hash),再对结果做 keccak256 后签名
|
||||
async function signSafeTx(wallet, structHash) {
|
||||
const hashBytes = ethers.utils.arrayify(structHash);
|
||||
const messagePrefix = "\x19Ethereum Signed Message:\n32";
|
||||
const prefixed = ethers.utils.concat([
|
||||
ethers.utils.toUtf8Bytes(messagePrefix),
|
||||
hashBytes,
|
||||
]);
|
||||
const hashToSign = ethers.utils.keccak256(prefixed);
|
||||
const sig = await wallet._signingKey().signDigest(ethers.utils.arrayify(hashToSign));
|
||||
let v = sig.v;
|
||||
if (v < 27) v += 27;
|
||||
const r = ethers.utils.hexZeroPad(ethers.BigNumber.from(sig.r).toHexString(), 32).slice(2);
|
||||
const s = ethers.utils.hexZeroPad(ethers.BigNumber.from(sig.s).toHexString(), 32).slice(2);
|
||||
const vByte = v.toString(16).padStart(2, "0");
|
||||
return "0x" + r + s + vByte;
|
||||
}
|
||||
|
||||
// Builder Relayer 要求 Safe 签名使用调整后的 v(与后端 splitAndPackSig 一致),否则链上恢复签名者会失败
|
||||
function packSafeSignatureForRelayer(signatureHex) {
|
||||
const raw = signatureHex.startsWith("0x") ? signatureHex.slice(2) : signatureHex;
|
||||
if (raw.length !== 130) throw new Error("签名长度应为 65 字节 (130 个十六进制字符)");
|
||||
const r = raw.slice(0, 64);
|
||||
const s = raw.slice(64, 128);
|
||||
const vHex = raw.slice(128, 130);
|
||||
let v = parseInt(vHex, 16);
|
||||
const adjustedV = v === 0 || v === 1 ? v + 31 : v === 27 || v === 28 ? v + 4 : v;
|
||||
return "0x" + r + s + adjustedV.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
// 构建 execTransaction(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, signatures)
|
||||
function buildExecTransactionCalldata(to, data, operation, signatureHex) {
|
||||
const iface = new ethers.utils.Interface([
|
||||
"function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)",
|
||||
]);
|
||||
const value = 0;
|
||||
const safeTxGas = 0;
|
||||
const baseGas = 0;
|
||||
const gasPrice = 0;
|
||||
const gasToken = ethers.constants.AddressZero;
|
||||
const refundReceiver = ethers.constants.AddressZero;
|
||||
const sigBytes = ethers.utils.arrayify(signatureHex);
|
||||
return iface.encodeFunctionData("execTransaction", [
|
||||
to,
|
||||
value,
|
||||
data,
|
||||
operation,
|
||||
safeTxGas,
|
||||
baseGas,
|
||||
gasPrice,
|
||||
gasToken,
|
||||
refundReceiver,
|
||||
sigBytes,
|
||||
]);
|
||||
}
|
||||
|
||||
function isBuildersConfigured() {
|
||||
const k = process.env.BUILDERS_API_KEY?.trim();
|
||||
const s = process.env.BUILDERS_SECRET?.trim();
|
||||
const p = process.env.BUILDERS_PASSPHRASE?.trim();
|
||||
return !!(k && s && p);
|
||||
}
|
||||
|
||||
function buildBuilderSignature(signString, secret) {
|
||||
let decodedSecret;
|
||||
try {
|
||||
decodedSecret = Buffer.from(secret, "base64");
|
||||
} catch {
|
||||
try {
|
||||
decodedSecret = Buffer.from(secret.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
||||
} catch {
|
||||
decodedSecret = Buffer.from(secret, "utf8");
|
||||
}
|
||||
}
|
||||
const hmac = crypto.createHmac("sha256", decodedSecret);
|
||||
hmac.update(signString, "utf8");
|
||||
const base64 = hmac.digest("base64");
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function getBuilderHeaders(method, path, body, apiKey, secret, passphrase) {
|
||||
const timestamp = Date.now().toString();
|
||||
const bodyStr = body ?? "";
|
||||
const signString = timestamp + method + path + bodyStr;
|
||||
const signature = buildBuilderSignature(signString, secret);
|
||||
return {
|
||||
POLY_BUILDER_SIGNATURE: signature,
|
||||
POLY_BUILDER_TIMESTAMP: timestamp,
|
||||
POLY_BUILDER_API_KEY: apiKey,
|
||||
POLY_BUILDER_PASSPHRASE: passphrase,
|
||||
};
|
||||
}
|
||||
|
||||
async function relayerGetNonce(fromAddress) {
|
||||
const pathForSign = "/nonce";
|
||||
const url = RELAYER_URL + "/nonce?address=" + encodeURIComponent(fromAddress) + "&type=SAFE";
|
||||
const headers = getBuilderHeaders(
|
||||
"GET",
|
||||
pathForSign,
|
||||
"",
|
||||
process.env.BUILDERS_API_KEY.trim(),
|
||||
process.env.BUILDERS_SECRET.trim(),
|
||||
process.env.BUILDERS_PASSPHRASE.trim()
|
||||
);
|
||||
const res = await fetch(url, { method: "GET", headers });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error("Relayer getNonce 失败: " + res.status + " " + text);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.nonce;
|
||||
}
|
||||
|
||||
async function relayerSubmit(requestBody) {
|
||||
const pathForSign = "/submit";
|
||||
const body = JSON.stringify(requestBody);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...getBuilderHeaders(
|
||||
"POST",
|
||||
pathForSign,
|
||||
body,
|
||||
process.env.BUILDERS_API_KEY.trim(),
|
||||
process.env.BUILDERS_SECRET.trim(),
|
||||
process.env.BUILDERS_PASSPHRASE.trim()
|
||||
),
|
||||
};
|
||||
const res = await fetch(RELAYER_URL + pathForSign, { method: "POST", headers, body });
|
||||
const rawText = await res.text();
|
||||
if (!res.ok) throw new Error("Relayer submit 失败: " + res.status + " " + rawText);
|
||||
const data = JSON.parse(rawText);
|
||||
console.log("Relayer submit 原始响应:", JSON.stringify(data, null, 2));
|
||||
return data;
|
||||
}
|
||||
|
||||
async function relayerGetTransaction(transactionId) {
|
||||
const pathForSign = "/transaction";
|
||||
const url = RELAYER_URL + "/transaction?id=" + encodeURIComponent(transactionId);
|
||||
const headers = getBuilderHeaders(
|
||||
"GET",
|
||||
pathForSign,
|
||||
"",
|
||||
process.env.BUILDERS_API_KEY.trim(),
|
||||
process.env.BUILDERS_SECRET.trim(),
|
||||
process.env.BUILDERS_PASSPHRASE.trim()
|
||||
);
|
||||
const res = await fetch(url, { method: "GET", headers });
|
||||
const rawText = await res.text();
|
||||
if (!res.ok) throw new Error("Relayer getTransaction 失败: " + res.status + " " + rawText);
|
||||
const data = JSON.parse(rawText);
|
||||
return { parsed: Array.isArray(data) ? data[0] : data, raw: data };
|
||||
}
|
||||
|
||||
function isSuccessState(state) {
|
||||
if (!state) return false;
|
||||
const s = String(state).toUpperCase();
|
||||
return s === "STATE_CONFIRMED" || s === "STATE_MINED" || s === "STATE_EXECUTED";
|
||||
}
|
||||
|
||||
function isFailedState(state) {
|
||||
if (!state) return false;
|
||||
const s = String(state).toUpperCase();
|
||||
return s === "STATE_FAILED" || s === "STATE_INVALID";
|
||||
}
|
||||
|
||||
// 链上交易 hash 应为 0x + 64 个十六进制字符
|
||||
function isChainTxHash(s) {
|
||||
if (!s || typeof s !== "string") return false;
|
||||
const t = s.trim();
|
||||
return /^0x[0-9a-fA-F]{64}$/.test(t);
|
||||
}
|
||||
|
||||
function getChainTxHashFromTx(tx) {
|
||||
if (!tx) return null;
|
||||
const candidates = [
|
||||
tx.transactionHash,
|
||||
tx.hash,
|
||||
tx.txHash,
|
||||
tx.blockchainHash,
|
||||
tx.chainTxHash,
|
||||
tx.executionHash,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (isChainTxHash(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function pollUntilMined(transactionId, maxAttempts = 30, intervalMs = 3000) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const { parsed: tx, raw: rawData } = await relayerGetTransaction(transactionId);
|
||||
const state = tx?.state;
|
||||
if (isSuccessState(state)) {
|
||||
console.log("Relayer getTransaction 原始响应 (最终状态):", JSON.stringify(rawData, null, 2));
|
||||
const chainHash = getChainTxHashFromTx(tx);
|
||||
if (chainHash) return chainHash;
|
||||
return null;
|
||||
}
|
||||
if (isFailedState(state)) {
|
||||
console.log("Relayer getTransaction 原始响应 (失败状态):", JSON.stringify(rawData, null, 2));
|
||||
const detail = tx.transactionHash ? ` txHash=${tx.transactionHash}` : "";
|
||||
const errMsg = tx.errorMessage ?? tx.error ?? tx.revertReason ?? "";
|
||||
const full = errMsg ? ` ${errMsg}` : detail;
|
||||
throw new Error("Relayer 交易失败: state=" + state + full);
|
||||
}
|
||||
if (i === 0) console.log("等待 Relayer 确认交易,每", intervalMs / 1000, "秒查询一次...");
|
||||
console.log(` [${i + 1}/${maxAttempts}] state=${state ?? "未知"}`);
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new Error("等待交易确认超时");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const privateKey = getPrivateKey();
|
||||
const safeAddress = process.env.SAFE_ADDRESS?.trim();
|
||||
|
||||
const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
|
||||
const wallet = new ethers.Wallet(privateKey, provider);
|
||||
const wcol = new ethers.Contract(WCOL_ADDRESS, WCOL_ABI, provider);
|
||||
|
||||
const ownerAddress = wallet.address;
|
||||
const targetBalanceAddress = safeAddress || ownerAddress;
|
||||
const balanceRaw = await wcol.balanceOf(targetBalanceAddress);
|
||||
const decimals = await wcol.decimals();
|
||||
const balanceFormatted = ethers.utils.formatUnits(balanceRaw, decimals);
|
||||
|
||||
if (balanceRaw.isZero()) {
|
||||
console.log(`地址 ${targetBalanceAddress} 的 WCOL 余额为 0,无需解包。`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`WCOL 余额: ${balanceFormatted} (${targetBalanceAddress})`);
|
||||
|
||||
if (safeAddress) {
|
||||
const iface = new ethers.utils.Interface(WCOL_ABI);
|
||||
// 从 Safe 解包时,USDC 默认转到 Safe;设环境变量 UNWRAP_TO_MAIN_WALLET=1 则转到主钱包
|
||||
const unwrapTo = process.env.UNWRAP_TO_MAIN_WALLET === "1" ? ownerAddress : safeAddress;
|
||||
const unwrapData = iface.encodeFunctionData("unwrap", [unwrapTo, balanceRaw]);
|
||||
|
||||
const safeContract = new ethers.Contract(safeAddress, SAFE_ABI, provider);
|
||||
|
||||
const useRelayer = isBuildersConfigured();
|
||||
let nonce;
|
||||
if (useRelayer) {
|
||||
const relayerNonce = await relayerGetNonce(safeAddress);
|
||||
nonce = await safeContract.nonce();
|
||||
console.log("Nonce: Relayer 返回=" + relayerNonce.toString() + ", Safe 链上=" + nonce.toString() + "(Gasless 一律用链上 nonce 签名)");
|
||||
} else {
|
||||
nonce = await safeContract.nonce();
|
||||
}
|
||||
|
||||
const domainSeparator = encodeSafeDomain(CHAIN_ID, safeAddress);
|
||||
const zero = "0";
|
||||
const gasToken = ethers.constants.AddressZero;
|
||||
const messageHash = encodeSafeTxMessage(
|
||||
WCOL_ADDRESS,
|
||||
zero,
|
||||
unwrapData,
|
||||
0,
|
||||
zero,
|
||||
zero,
|
||||
zero,
|
||||
gasToken,
|
||||
gasToken,
|
||||
nonce
|
||||
);
|
||||
const structHash = hashStructuredData(domainSeparator, messageHash);
|
||||
const signatureHex = await signSafeTx(wallet, structHash);
|
||||
|
||||
if (useRelayer) {
|
||||
console.log("使用 Builder Relayer(Gasless),主钱包无需 POL");
|
||||
console.log("若链上仍报 GS026,多为 Relayer 上链前 Safe 又执行了其他交易导致 nonce 变化,可改用自付 gas(不设 BUILDERS_*)或确保此时无其他 Safe 交易。");
|
||||
const relayerSignature = packSafeSignatureForRelayer(signatureHex);
|
||||
const requestBody = {
|
||||
type: "SAFE",
|
||||
from: ownerAddress,
|
||||
to: WCOL_ADDRESS,
|
||||
proxyWallet: safeAddress,
|
||||
data: unwrapData,
|
||||
nonce: nonce.toString(),
|
||||
signature: relayerSignature,
|
||||
signatureParams: {
|
||||
gasPrice: "0",
|
||||
operation: "0",
|
||||
safeTxnGas: "0",
|
||||
baseGas: "0",
|
||||
gasToken: ethers.constants.AddressZero,
|
||||
refundReceiver: ethers.constants.AddressZero,
|
||||
},
|
||||
metadata: "unwrap WCOL to USDC.e",
|
||||
};
|
||||
const result = await relayerSubmit(requestBody);
|
||||
const txId = result.transactionID ?? result.transactionId ?? result.id ?? result.transaction_id;
|
||||
if (!txId) {
|
||||
console.log("Relayer 已提交,响应:", JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log("Relayer 已提交,transactionId:", txId);
|
||||
const txHash = await pollUntilMined(txId);
|
||||
if (txHash) {
|
||||
console.log("交易已确认 (Polygon):", txHash);
|
||||
} else {
|
||||
console.log("Relayer 已标记为成功,但未返回链上 tx hash。可在 https://polygonscan.com 用 Safe 地址查看最新交易:", safeAddress);
|
||||
console.log("若需排查,可设置 DEBUG=1 查看 Relayer 返回的完整数据");
|
||||
}
|
||||
console.log("已解包", balanceFormatted, "WCOL → USDC.e,到账地址:", unwrapTo);
|
||||
return;
|
||||
}
|
||||
|
||||
const execCalldata = buildExecTransactionCalldata(WCOL_ADDRESS, unwrapData, 0, signatureHex);
|
||||
console.log("正在由 Safe 执行 unwrap,主钱包发送交易(需少量 POL 付 gas)...");
|
||||
const tx = await wallet.sendTransaction({
|
||||
to: safeAddress,
|
||||
data: execCalldata,
|
||||
value: 0,
|
||||
gasLimit: 500000,
|
||||
});
|
||||
console.log("Tx hash:", tx.hash);
|
||||
const receipt = await tx.wait();
|
||||
console.log("已确认,block:", receipt.blockNumber);
|
||||
console.log("已解包", balanceFormatted, "WCOL → USDC.e,到账地址:", unwrapTo);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("正在发送 unwrap 交易,USDC.e 将转到:", ownerAddress);
|
||||
const tx = await wcol.connect(wallet).unwrap(ownerAddress, balanceRaw);
|
||||
console.log("Tx hash:", tx.hash);
|
||||
const receipt = await tx.wait();
|
||||
console.log("已确认,block:", receipt.blockNumber);
|
||||
console.log("已解包", balanceFormatted, "WCOL → USDC.e");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user