Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f50939625 | |||
| a64d1edbf4 | |||
| a62f1bd6e8 | |||
| 63ce4107c6 | |||
| a136d68e8c | |||
| 61a5077d4d | |||
| bd529d9041 | |||
| 5cdcc487d4 | |||
| ca2b1acbb9 | |||
| 77b681a40e | |||
| c6f9e5db61 | |||
| 2ec4ae3f98 | |||
| 5074bb4f28 | |||
| 9fef4bea59 | |||
| b50e43c239 | |||
| 7ec9311df2 | |||
| 1f6cf1ecaf | |||
| 9413507997 | |||
| bf0e52fc01 | |||
| c520eb7777 | |||
| d6d4b1200e | |||
| a1f3cfd3ff | |||
| 261dbdc23c | |||
| 4c0d0afceb | |||
| b79caceae6 | |||
| ed749fb606 | |||
| f200ba7f7c | |||
| 0efd4a5fc3 | |||
| 2238370088 | |||
| f1ec0a330b | |||
| 72de65d670 | |||
| 3405a1cda3 | |||
| fc6fa8b419 | |||
| cf2c8a611c | |||
| bd323fca35 | |||
| a067a20a02 |
+1
-1
@@ -19,7 +19,7 @@ backend/gradle-app.setting
|
||||
backend/.gradle
|
||||
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
|
||||
# backend/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
polyhub/
|
||||
# Kotlin
|
||||
*.kt.bak
|
||||
*.class
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 币安现货公开 API(K 线等)
|
||||
* Base URL: https://api.binance.com
|
||||
* 文档: https://developers.binance.com/docs/binance-spot-api-docs/rest-api
|
||||
*/
|
||||
interface BinanceApi {
|
||||
|
||||
/**
|
||||
* K 线数据
|
||||
* 返回每根 K 线: [openTime, open, high, low, close, volume, closeTime, ...]
|
||||
*/
|
||||
@GET("/api/v3/klines")
|
||||
fun getKlines(
|
||||
@Query("symbol") symbol: String,
|
||||
@Query("interval") interval: String,
|
||||
@Query("limit") limit: Int = 30,
|
||||
@Query("startTime") startTime: Long? = null,
|
||||
@Query("endTime") endTime: Long? = null
|
||||
): Call<List<List<Any>>>
|
||||
}
|
||||
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
|
||||
@Query("address") address: String,
|
||||
@Query("type") type: String
|
||||
): Response<NoncePayload>
|
||||
|
||||
/**
|
||||
* 获取 Relay Payload(PROXY 类型执行时使用)
|
||||
* GET /relay-payload?address={address}&type=PROXY
|
||||
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
|
||||
*/
|
||||
@GET("/relay-payload")
|
||||
suspend fun getRelayPayload(
|
||||
@Query("address") address: String,
|
||||
@Query("type") type: String
|
||||
): Response<RelayPayload>
|
||||
|
||||
/**
|
||||
* 获取交易状态
|
||||
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
|
||||
/**
|
||||
* 签名参数
|
||||
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
|
||||
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
|
||||
*/
|
||||
data class SignatureParams(
|
||||
@SerializedName("gasPrice")
|
||||
@@ -114,7 +126,19 @@ interface BuilderRelayerApi {
|
||||
val gasToken: String? = null,
|
||||
|
||||
@SerializedName("refundReceiver")
|
||||
val refundReceiver: String? = null
|
||||
val refundReceiver: String? = null,
|
||||
|
||||
@SerializedName("relayerFee")
|
||||
val relayerFee: String? = null,
|
||||
|
||||
@SerializedName("gasLimit")
|
||||
val gasLimit: String? = null,
|
||||
|
||||
@SerializedName("relayHub")
|
||||
val relayHub: String? = null,
|
||||
|
||||
@SerializedName("relay")
|
||||
val relay: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -142,6 +166,17 @@ interface BuilderRelayerApi {
|
||||
@SerializedName("nonce")
|
||||
val nonce: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Relay Payload(PROXY 执行时获取 relay 地址与 nonce)
|
||||
* 参考: builder-relayer-client types RelayPayload
|
||||
*/
|
||||
data class RelayPayload(
|
||||
@SerializedName("address")
|
||||
val address: String,
|
||||
@SerializedName("nonce")
|
||||
val nonce: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Relayer 交易详情
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
@@ -164,10 +165,10 @@ interface PolymarketClobApi {
|
||||
|
||||
/**
|
||||
* 获取服务器时间
|
||||
* 端点: /time
|
||||
* 端点: /time 返回纯数字(Unix 时间戳),非 JSON
|
||||
*/
|
||||
@GET("/time")
|
||||
suspend fun getServerTime(): Response<ServerTimeResponse>
|
||||
suspend fun getServerTime(): Response<ResponseBody>
|
||||
}
|
||||
|
||||
// 请求和响应数据类
|
||||
@@ -334,7 +335,8 @@ data class TradeResponse(
|
||||
val timestamp: String, // ISO 8601 格式字符串或时间戳
|
||||
val user: String?,
|
||||
val outcomeIndex: Int? = null, // 结果索引(0=YES, 1=NO)
|
||||
val outcome: String? = null // 结果名称(如 "Up", "Down")
|
||||
val outcome: String? = null, // 结果名称(如 "Up", "Down")
|
||||
val tokenId: String? = null // CLOB tokenId(链上解析时从 ERC1155 取得,与 Gamma clobTokenIds 一致,用于下单)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -363,13 +365,6 @@ data class ApiKeyResponse(
|
||||
val passphrase: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 服务器时间响应
|
||||
*/
|
||||
data class ServerTimeResponse(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 费率响应
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
@@ -26,23 +27,56 @@ interface PolymarketGammaApi {
|
||||
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||
@Query("include_tag") includeTag: Boolean? = null
|
||||
): Response<List<MarketResponse>>
|
||||
|
||||
/**
|
||||
* 根据 slug 获取事件(用于 5/15 分钟加密市场)
|
||||
* GET /events/slug/{slug},如 btc-updown-5m-1771007400
|
||||
* 返回事件含 markets(conditionId、endDate、clobTokenIds 等)
|
||||
*/
|
||||
@GET("/events/slug/{slug}")
|
||||
suspend fun getEventBySlug(@Path("slug") slug: String): Response<GammaEventBySlugResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gamma 按 slug 返回的事件结构
|
||||
*/
|
||||
data class GammaEventBySlugResponse(
|
||||
val id: String? = null,
|
||||
val slug: String? = null,
|
||||
val title: String? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val markets: List<GammaEventMarketItem>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件下的市场项(5/15 分钟市场为二元,通常两个 outcome)
|
||||
*/
|
||||
data class GammaEventMarketItem(
|
||||
val conditionId: String? = null,
|
||||
val question: String? = null,
|
||||
val endDate: String? = null,
|
||||
val startDate: String? = null,
|
||||
val clobTokenIds: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件响应(从 MarketResponse.events 解析)
|
||||
* Gamma API Event 含 negRisk,用于判断是否使用 Neg Risk Exchange 签约
|
||||
*/
|
||||
data class EventResponse(
|
||||
val id: String? = null,
|
||||
val ticker: String? = null,
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val slug: String? = null,
|
||||
val title: String? = null,
|
||||
val category: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val closed: Boolean? = null,
|
||||
val archived: Boolean? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val createdAt: String? = null
|
||||
val createdAt: String? = null,
|
||||
val negRisk: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -74,6 +108,8 @@ data class MarketResponse(
|
||||
val events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug)
|
||||
// 以下字段可能存在于响应中,但不在标准文档中
|
||||
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
|
||||
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
|
||||
val clob_token_ids: String? = null, // 下划线格式(兼容不同 API 版本)
|
||||
val negRisk: Boolean? = null, // 事件级 neg risk(部分 API 直接返回在 market)
|
||||
val negRiskOther: Boolean? = null // Market 级 neg risk 标记
|
||||
)
|
||||
|
||||
|
||||
+54
-7
@@ -23,6 +23,50 @@ class AccountController(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountController::class.java)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项(用于导入前选择代理类型)
|
||||
*/
|
||||
@PostMapping("/check-proxy-options")
|
||||
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
|
||||
return try {
|
||||
if (request.walletAddress.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
|
||||
}
|
||||
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
|
||||
}
|
||||
|
||||
val result = runBlocking { accountService.checkProxyOptions(request) }
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过私钥导入账户
|
||||
*/
|
||||
@@ -45,14 +89,17 @@ class AccountController(
|
||||
onFailure = { e ->
|
||||
logger.error("导入账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package com.wrbug.polymarketbot.controller.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDeleteRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDto
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/crypto-tail-strategy")
|
||||
class CryptoTailStrategyController(
|
||||
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyController::class.java)
|
||||
|
||||
@PostMapping("/list")
|
||||
fun list(@RequestBody request: CryptoTailStrategyListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyListResponse>> {
|
||||
return try {
|
||||
val result = cryptoTailStrategyService.list(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询尾盘策略列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询尾盘策略列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
fun create(@RequestBody request: CryptoTailStrategyCreateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||
return try {
|
||||
val result = cryptoTailStrategyService.create(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("创建尾盘策略失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/update")
|
||||
fun update(@RequestBody request: CryptoTailStrategyUpdateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.update(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("更新尾盘策略失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
fun delete(@RequestBody request: CryptoTailStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val strategyId = request.strategyId
|
||||
if (strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.delete(strategyId)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
||||
onFailure = { e ->
|
||||
logger.error("删除尾盘策略失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/triggers")
|
||||
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.getTriggerRecords(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/market-options")
|
||||
fun getMarketOptions(): ResponseEntity<ApiResponse<List<CryptoTailMarketOptionDto>>> {
|
||||
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)
|
||||
)
|
||||
ResponseEntity.ok(ApiResponse.success(options))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取市场选项异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动最小价差预览:按「当前周期」计算一次并返回,仅用于前端展示参考。
|
||||
* 实际触发时按每个周期在需要时计算,不依赖此接口。
|
||||
*/
|
||||
@PostMapping("/auto-min-spread")
|
||||
fun getAutoMinSpread(@RequestBody request: java.util.Map<String, Any>): ResponseEntity<ApiResponse<CryptoTailAutoMinSpreadResponse>> {
|
||||
return try {
|
||||
val intervalSeconds = (request["intervalSeconds"] as? Number)?.toInt() ?: 300
|
||||
if (intervalSeconds != 300 && intervalSeconds != 900) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
|
||||
}
|
||||
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
|
||||
?: (System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
||||
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
|
||||
val body = CryptoTailAutoMinSpreadResponse(
|
||||
minSpreadUp = pair.first.toPlainString(),
|
||||
minSpreadDown = pair.second.toPlainString()
|
||||
)
|
||||
ResponseEntity.ok(ApiResponse.success(body))
|
||||
} catch (e: Exception) {
|
||||
logger.error("计算自动最小价差异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,37 @@ data class AccountImportRequest(
|
||||
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项请求
|
||||
*/
|
||||
data class CheckProxyOptionsRequest(
|
||||
val walletAddress: String, // EOA 地址(必需)
|
||||
val privateKey: String? = null, // 私钥(加密,私钥导入时提供)
|
||||
val mnemonic: String? = null // 助记词(加密,助记词导入时提供)
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理地址选项信息
|
||||
*/
|
||||
data class ProxyOptionDto(
|
||||
val walletType: String, // "magic" 或 "safe"
|
||||
val proxyAddress: String, // 代理地址
|
||||
val descriptionKey: String, // 说明文案的多语言 key(如 "accountImport.proxyOption.magic.description")
|
||||
val availableBalance: String, // 可用余额
|
||||
val positionBalance: String, // 仓位余额
|
||||
val totalBalance: String, // 总余额
|
||||
val positionCount: Int, // 持仓数量
|
||||
val hasAssets: Boolean, // 是否有资产(余额>0 或持仓>0)
|
||||
val error: String? = null // 获取失败时的错误信息(可选)
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项响应
|
||||
*/
|
||||
data class CheckProxyOptionsResponse(
|
||||
val options: List<ProxyOptionDto> // 代理地址选项列表(私钥导入返回2个,助记词返回1个)
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,8 @@ data class BacktestCreateRequest(
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
|
||||
)
|
||||
|
||||
@@ -166,7 +168,9 @@ data class BacktestConfigDto(
|
||||
val supportSell: Boolean,
|
||||
val keywordFilterMode: String?,
|
||||
val keywords: List<String>?,
|
||||
val maxPositionValue: String?
|
||||
val maxPositionValue: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 尾盘策略创建请求
|
||||
* 金额与价格使用 String,后端转为 BigDecimal
|
||||
*/
|
||||
data class CryptoTailStrategyCreateRequest(
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val intervalSeconds: Int = 300,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略更新请求
|
||||
*/
|
||||
data class CryptoTailStrategyUpdateRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val windowStartSeconds: Int? = null,
|
||||
val windowEndSeconds: Int? = null,
|
||||
val minPrice: String? = null,
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String? = null,
|
||||
val amountValue: String? = null,
|
||||
val minSpreadMode: String? = null,
|
||||
val minSpreadValue: String? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略列表请求
|
||||
*/
|
||||
data class CryptoTailStrategyListRequest(
|
||||
val accountId: Long? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略 DTO(列表与详情)
|
||||
*/
|
||||
data class CryptoTailStrategyDto(
|
||||
val id: Long = 0L,
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val marketTitle: String? = null,
|
||||
val intervalSeconds: Int = 0,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String = "1",
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
val lastTriggerAt: Long? = null,
|
||||
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
|
||||
val totalRealizedPnl: String? = null,
|
||||
/** 已结算笔数(用于胜率分母) */
|
||||
val settledCount: Long = 0L,
|
||||
/** 已结算中赢的笔数(用于胜率分子) */
|
||||
val winCount: Long = 0L,
|
||||
/** 胜率 0~1(已结算时 = winCount/settledCount,无结算为 null) */
|
||||
val winRate: String? = null,
|
||||
val createdAt: Long = 0L,
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略列表响应
|
||||
*/
|
||||
data class CryptoTailStrategyListResponse(
|
||||
val list: List<CryptoTailStrategyDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略删除请求
|
||||
*/
|
||||
data class CryptoTailStrategyDeleteRequest(
|
||||
val strategyId: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录列表请求
|
||||
* @param startDate 开始日期(当天 00:00:00.000 的时间戳毫秒),为 null 表示不限制
|
||||
* @param endDate 结束日期(当天 23:59:59.999 的时间戳毫秒),为 null 表示不限制
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 20,
|
||||
val status: String? = null,
|
||||
val startDate: Long? = null,
|
||||
val endDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录 DTO
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerDto(
|
||||
val id: Long = 0L,
|
||||
val strategyId: Long = 0L,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val marketTitle: String? = null,
|
||||
val outcomeIndex: Int = 0,
|
||||
val triggerPrice: String = "0",
|
||||
val amountUsdc: String = "0",
|
||||
val orderId: String? = null,
|
||||
val status: String = "success",
|
||||
val failReason: String? = null,
|
||||
/** 是否已结算 */
|
||||
val resolved: Boolean = false,
|
||||
/** 已实现盈亏 USDC(结算后有值) */
|
||||
val realizedPnl: String? = null,
|
||||
/** 市场赢家 outcome 索引(结算后有值) */
|
||||
val winnerOutcomeIndex: Int? = null,
|
||||
val settledAt: Long? = null,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录分页响应
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListResponse(
|
||||
val list: List<CryptoTailStrategyTriggerDto> = emptyList(),
|
||||
val total: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 自动最小价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||
*/
|
||||
data class CryptoTailAutoMinSpreadResponse(
|
||||
val minSpreadUp: String = "0",
|
||||
val minSpreadDown: String = "0"
|
||||
)
|
||||
|
||||
/**
|
||||
* 5/15 分钟市场项(供前端选择市场)
|
||||
*/
|
||||
data class CryptoTailMarketOptionDto(
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
val intervalSeconds: Int = 0,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val endDate: String? = null
|
||||
)
|
||||
@@ -16,11 +16,11 @@ data class Account(
|
||||
@Column(name = "private_key", nullable = false, length = 500)
|
||||
val privateKey: String, // 私钥(AES 加密存储)
|
||||
|
||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
||||
@Column(name = "wallet_address", nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
|
||||
|
||||
@Column(name = "proxy_address", nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
||||
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
|
||||
|
||||
@Column(name = "api_key", length = 500)
|
||||
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
||||
|
||||
@@ -76,6 +76,12 @@ data class BacktestTask(
|
||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
|
||||
@Column(name = "min_price", precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
|
||||
@Column(name = "max_price", precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
|
||||
// 统计字段
|
||||
@Column(name = "avg_holding_time")
|
||||
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 加密市场尾盘策略实体
|
||||
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "crypto_tail_strategy")
|
||||
data class CryptoTailStrategy(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "account_id", nullable = false)
|
||||
val accountId: Long = 0L,
|
||||
|
||||
@Column(name = "name", length = 255)
|
||||
val name: String? = null,
|
||||
|
||||
@Column(name = "market_slug_prefix", nullable = false, length = 64)
|
||||
val marketSlugPrefix: String = "",
|
||||
|
||||
@Column(name = "interval_seconds", nullable = false)
|
||||
val intervalSeconds: Int = 300,
|
||||
|
||||
@Column(name = "window_start_seconds", nullable = false)
|
||||
val windowStartSeconds: Int = 0,
|
||||
|
||||
@Column(name = "window_end_seconds", nullable = false)
|
||||
val windowEndSeconds: Int = 0,
|
||||
|
||||
@Column(name = "min_price", nullable = false, precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "max_price", nullable = false, precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "amount_mode", nullable = false, length = 10)
|
||||
val amountMode: String = "RATIO",
|
||||
|
||||
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
|
||||
val amountValue: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "min_spread_mode", nullable = false, length = 16)
|
||||
val minSpreadMode: String = "NONE",
|
||||
|
||||
@Column(name = "min_spread_value", precision = 20, scale = 8)
|
||||
val minSpreadValue: BigDecimal? = null,
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
val enabled: Boolean = true,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 尾盘策略触发记录
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "crypto_tail_strategy_trigger")
|
||||
data class CryptoTailStrategyTrigger(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "strategy_id", nullable = false)
|
||||
val strategyId: Long = 0L,
|
||||
|
||||
@Column(name = "period_start_unix", nullable = false)
|
||||
val periodStartUnix: Long = 0L,
|
||||
|
||||
@Column(name = "market_title", length = 500)
|
||||
val marketTitle: String? = null,
|
||||
|
||||
@Column(name = "outcome_index", nullable = false)
|
||||
val outcomeIndex: Int = 0,
|
||||
|
||||
@Column(name = "trigger_price", nullable = false, precision = 20, scale = 8)
|
||||
val triggerPrice: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "amount_usdc", nullable = false, precision = 20, scale = 8)
|
||||
val amountUsdc: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "order_id", length = 128)
|
||||
val orderId: String? = null,
|
||||
|
||||
@Column(name = "condition_id", length = 66)
|
||||
val conditionId: String? = null,
|
||||
|
||||
@Column(name = "resolved", nullable = false)
|
||||
val resolved: Boolean = false,
|
||||
|
||||
@Column(name = "winner_outcome_index")
|
||||
val winnerOutcomeIndex: Int? = null,
|
||||
|
||||
@Column(name = "realized_pnl", precision = 20, scale = 8)
|
||||
val realizedPnl: BigDecimal? = null,
|
||||
|
||||
@Column(name = "settled_at")
|
||||
val settledAt: Long? = null,
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
val status: String = "success",
|
||||
|
||||
@Column(name = "fail_reason", length = 500)
|
||||
val failReason: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "notification_sent", nullable = false)
|
||||
var notificationSent: Boolean = false
|
||||
)
|
||||
@@ -158,6 +158,13 @@ enum class ErrorCode(
|
||||
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
||||
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
||||
|
||||
// 尾盘策略 (4710-4729)
|
||||
CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "尾盘策略不存在", "error.crypto_tail_strategy_not_found"),
|
||||
CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"),
|
||||
CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED(4712, "时间区间不能超过周期长度", "error.crypto_tail_strategy_window_exceed"),
|
||||
CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID(4713, "周期仅支持 300 或 900 秒", "error.crypto_tail_strategy_interval_invalid"),
|
||||
CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID(4714, "投入方式仅支持 RATIO 或 FIXED", "error.crypto_tail_strategy_amount_mode_invalid"),
|
||||
|
||||
// 统计相关 (4801-4899)
|
||||
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
|
||||
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"),
|
||||
@@ -250,7 +257,14 @@ enum class ErrorCode(
|
||||
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
|
||||
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
|
||||
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
|
||||
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed");
|
||||
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed"),
|
||||
|
||||
// 尾盘策略服务 (5620-5629)
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建尾盘策略失败", "error.server.crypto_tail_strategy_create_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新尾盘策略失败", "error.server.crypto_tail_strategy_update_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除尾盘策略失败", "error.server.crypto_tail_strategy_delete_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询尾盘策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* 钱包类型枚举
|
||||
*/
|
||||
enum class WalletType(val value: String, val description: String) {
|
||||
/**
|
||||
* Magic 钱包(邮箱/OAuth 登录)
|
||||
* 使用 PROXY 代理合约,通过 Builder Relayer 执行 Gasless 交易
|
||||
*/
|
||||
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
|
||||
|
||||
/**
|
||||
* Safe 钱包(MetaMask 等 Web3 钱包)
|
||||
* 使用 Gnosis Safe 代理合约,支持 Builder Relayer Gasless 或手动交易
|
||||
*/
|
||||
SAFE("safe", "Safe(Web3钱包)");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 从字符串值解析钱包类型(不区分大小写)
|
||||
*/
|
||||
fun fromString(value: String?): WalletType {
|
||||
if (value.isNullOrBlank()) {
|
||||
return SAFE // 默认返回 SAFE
|
||||
}
|
||||
return values().find { it.value.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("未知的钱包类型: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地从字符串值解析钱包类型(不区分大小写),解析失败返回默认值
|
||||
*/
|
||||
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
|
||||
if (value.isNullOrBlank()) {
|
||||
return default
|
||||
}
|
||||
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字符串是否为有效的钱包类型
|
||||
*/
|
||||
fun isValid(value: String?): Boolean {
|
||||
if (value.isNullOrBlank()) {
|
||||
return false
|
||||
}
|
||||
return values().any { it.value.equals(value, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.wrbug.polymarketbot.event
|
||||
|
||||
import org.springframework.context.ApplicationEvent
|
||||
|
||||
/**
|
||||
* 尾盘策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
|
||||
*/
|
||||
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
||||
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
||||
* 检查钱包地址是否存在
|
||||
*/
|
||||
fun existsByWalletAddress(walletAddress: String): Boolean
|
||||
|
||||
/**
|
||||
* 检查代理地址是否存在
|
||||
*/
|
||||
fun existsByProxyAddress(proxyAddress: String): Boolean
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CryptoTailStrategyRepository : JpaRepository<CryptoTailStrategy, Long> {
|
||||
|
||||
fun findAllByAccountId(accountId: Long): List<CryptoTailStrategy>
|
||||
fun findAllByEnabledTrue(): List<CryptoTailStrategy>
|
||||
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<CryptoTailStrategy>
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.math.BigDecimal
|
||||
|
||||
interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategyTrigger, Long> {
|
||||
|
||||
fun findByStrategyIdAndPeriodStartUnix(strategyId: Long, periodStartUnix: Long): CryptoTailStrategyTrigger?
|
||||
fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
|
||||
|
||||
fun findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun countByStrategyIdAndCreatedAtBetween(strategyId: Long, startInclusive: Long, endInclusive: Long): Long
|
||||
fun countByStrategyIdAndStatusAndCreatedAtBetween(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long): Long
|
||||
|
||||
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
|
||||
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
||||
|
||||
/** 根据订单 ID 查询尾盘触发记录 */
|
||||
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
|
||||
|
||||
/** 轮询发 TG:status=success、orderId 非空、未发过通知,按创建时间正序 */
|
||||
fun findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc(status: String): List<CryptoTailStrategyTrigger>
|
||||
|
||||
/** 策略已结算订单的总已实现盈亏(用于收益统计) */
|
||||
@Query("SELECT COALESCE(SUM(t.realizedPnl), 0) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
||||
fun sumRealizedPnlByStrategyId(@Param("strategyId") strategyId: Long): BigDecimal?
|
||||
|
||||
/** 策略已结算订单笔数(用于胜率分母) */
|
||||
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
||||
fun countResolvedByStrategyId(@Param("strategyId") strategyId: Long): Long
|
||||
|
||||
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index) */
|
||||
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true AND t.outcomeIndex = t.winnerOutcomeIndex")
|
||||
fun countWinsByStrategyId(@Param("strategyId") strategyId: Long): Long
|
||||
}
|
||||
+223
-28
@@ -3,10 +3,12 @@ package com.wrbug.polymarketbot.service.accounts
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.getEventSlug
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
@@ -66,11 +68,6 @@ class AccountService(
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 检查地址是否已存在
|
||||
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
|
||||
}
|
||||
|
||||
// 3. 验证私钥和地址的对应关系
|
||||
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
||||
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
||||
@@ -104,7 +101,8 @@ class AccountService(
|
||||
// 5. 获取代理地址(必须成功,否则导入失败)
|
||||
// 根据用户选择的钱包类型计算代理地址
|
||||
val proxyAddress = runBlocking {
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, walletTypeEnum)
|
||||
if (proxyResult.isSuccess) {
|
||||
val address = proxyResult.getOrNull()
|
||||
if (address != null) {
|
||||
@@ -120,25 +118,31 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
|
||||
if (accountRepository.existsByProxyAddress(proxyAddress)) {
|
||||
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
|
||||
}
|
||||
|
||||
// 7. 加密敏感信息
|
||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
|
||||
|
||||
// 8. 生成账户名称(如果未提供,使用钱包地址后四位)
|
||||
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
||||
val accountName = if (request.accountName.isNullOrBlank()) {
|
||||
val walletAddress = request.walletAddress.trim()
|
||||
// 取地址后四位(去掉 0x 前缀后取后四位)
|
||||
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
|
||||
walletAddress.substring(2)
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||
val typeLabel = walletTypeEnum.name.uppercase()
|
||||
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
|
||||
proxyAddress.substring(2)
|
||||
} else {
|
||||
walletAddress
|
||||
proxyAddress
|
||||
}
|
||||
if (addressWithoutPrefix.length >= 4) {
|
||||
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
|
||||
val suffix = if (proxyWithoutPrefix.length >= 4) {
|
||||
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
|
||||
} else {
|
||||
addressWithoutPrefix.uppercase()
|
||||
proxyWithoutPrefix.uppercase()
|
||||
}
|
||||
"$typeLabel-$suffix"
|
||||
} else {
|
||||
request.accountName.trim()
|
||||
}
|
||||
@@ -171,6 +175,192 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理地址选项(用于账户导入前选择代理类型)
|
||||
* 私钥导入:返回 Magic 和 Safe 两个选项
|
||||
* 助记词导入:仅返回 Safe 选项
|
||||
*/
|
||||
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
|
||||
return try {
|
||||
// 1. 验证钱包地址格式
|
||||
if (!isValidWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 验证至少提供了私钥或助记词之一
|
||||
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
|
||||
}
|
||||
|
||||
val options = mutableListOf<ProxyOptionDto>()
|
||||
|
||||
// 3. 判断导入类型
|
||||
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
|
||||
|
||||
if (isPrivateKeyImport) {
|
||||
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
|
||||
coroutineScope {
|
||||
val magicDeferred = async {
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
ProxyOptionDto(
|
||||
walletType = WalletType.MAGIC.value,
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
ProxyOptionDto(
|
||||
walletType = "magic",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Magic 代理地址失败"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
|
||||
ProxyOptionDto(
|
||||
walletType = "magic",
|
||||
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val safeDeferred = async {
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
ProxyOptionDto(
|
||||
walletType = WalletType.SAFE.value,
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Safe 代理地址失败"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val magicOption = magicDeferred.await()
|
||||
val safeOption = safeDeferred.await()
|
||||
// Safe 在前,Magic 在后
|
||||
options.add(safeOption)
|
||||
options.add(magicOption)
|
||||
}
|
||||
} else {
|
||||
// 助记词导入:仅获取 Safe 代理地址及资产
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Safe 代理地址失败"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(CheckProxyOptionsResponse(options = options))
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查代理地址选项失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@@ -827,7 +1017,7 @@ class AccountService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -836,7 +1026,7 @@ class AccountService(
|
||||
side = "SELL",
|
||||
price = sellPrice,
|
||||
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
||||
signatureType = 2, // Browser Wallet(与正确订单数据一致)
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = expiration
|
||||
@@ -1189,13 +1379,6 @@ class AccountService(
|
||||
*/
|
||||
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
|
||||
return try {
|
||||
// 检查 Builder API Key 是否已配置
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
|
||||
)
|
||||
}
|
||||
|
||||
if (request.positions.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
|
||||
}
|
||||
@@ -1217,7 +1400,17 @@ class AccountService(
|
||||
accounts[accountId] = account
|
||||
}
|
||||
|
||||
// 4. 验证并收集要赎回的仓位信息(按账户分组)
|
||||
// 4. 若涉及 Magic 账户,必须已配置 Builder API Key(提前判断,避免执行到深层再报错)
|
||||
val hasMagicAccount = accounts.values.any {
|
||||
WalletType.fromStringOrDefault(it.walletType, WalletType.SAFE) == WalletType.MAGIC
|
||||
}
|
||||
if (hasMagicAccount && !relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Builder API Key 未配置,无法执行 Magic 账户赎回(Gasless)。请前往系统设置页面配置 Builder API Key。")
|
||||
)
|
||||
}
|
||||
|
||||
// 5. 验证并收集要赎回的仓位信息(按账户分组)
|
||||
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
|
||||
val accountRedeemedInfo =
|
||||
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
|
||||
@@ -1260,7 +1453,7 @@ class AccountService(
|
||||
accountRedeemedInfo[accountId] = accountInfo
|
||||
}
|
||||
|
||||
// 5. 对每个账户执行赎回
|
||||
// 6. 对每个账户执行赎回(Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
|
||||
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
|
||||
var totalRedeemedValue = BigDecimal.ZERO
|
||||
|
||||
@@ -1280,11 +1473,13 @@ class AccountService(
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 调用区块链服务赎回仓位
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
val redeemResult = blockchainService.redeemPositions(
|
||||
privateKey = decryptedPrivateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
conditionId = marketId,
|
||||
indexSets = indexSets
|
||||
indexSets = indexSets,
|
||||
walletType = walletTypeEnum
|
||||
)
|
||||
|
||||
redeemResult.fold(
|
||||
@@ -1315,7 +1510,7 @@ class AccountService(
|
||||
)
|
||||
}
|
||||
|
||||
// 6. 发送赎回推送通知(异步,不阻塞)
|
||||
// 7. 发送赎回推送通知(异步,不阻塞)
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取当前语言设置
|
||||
|
||||
+1
-5
@@ -380,14 +380,10 @@ class PositionCheckService(
|
||||
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
||||
|
||||
for ((accountId, positions) in positionsByAccount) {
|
||||
// 查找该账户下所有启用的跟单配置
|
||||
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如尾盘策略账户也会执行赎回)
|
||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||
.filter { it.enabled }
|
||||
|
||||
if (copyTradings.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 过滤掉已经处理过的仓位(去重,避免重复赎回)
|
||||
val now = System.currentTimeMillis()
|
||||
val positionsToRedeem = positions.filter { position ->
|
||||
|
||||
+2
@@ -73,6 +73,8 @@ class BacktestExecutionService(
|
||||
minOrderDepth = null, // 回测无实时订单簿数据
|
||||
maxSpread = null, // 回测无实时价差数据
|
||||
maxPositionValue = task.maxPositionValue,
|
||||
minPrice = task.minPrice, // 最低价格
|
||||
maxPrice = task.maxPrice, // 最高价格
|
||||
keywordFilterMode = task.keywordFilterMode,
|
||||
keywords = task.keywords,
|
||||
configName = null,
|
||||
|
||||
@@ -82,7 +82,9 @@ class BacktestService(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal()
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(task)
|
||||
@@ -192,7 +194,9 @@ class BacktestService(
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
maxPositionValue = task.maxPositionValue?.toPlainString()
|
||||
maxPositionValue = task.maxPositionValue?.toPlainString(),
|
||||
minPrice = task.minPrice?.toPlainString(),
|
||||
maxPrice = task.maxPrice?.toPlainString()
|
||||
)
|
||||
|
||||
val statistics = BacktestStatisticsDto(
|
||||
@@ -379,7 +383,9 @@ class BacktestService(
|
||||
supportSell = source.supportSell,
|
||||
keywordFilterMode = source.keywordFilterMode,
|
||||
keywords = source.keywords,
|
||||
maxPositionValue = source.maxPositionValue
|
||||
maxPositionValue = source.maxPositionValue,
|
||||
minPrice = source.minPrice,
|
||||
maxPrice = source.maxPrice
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(newTask)
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.wrbug.polymarketbot.service.binance
|
||||
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (interval, period)。
|
||||
* 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。
|
||||
*/
|
||||
@Service
|
||||
class BinanceKlineAutoSpreadService(
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
|
||||
|
||||
private val symbol = "BTCUSDC"
|
||||
private val historyLimit = 20
|
||||
private val minSamplesAfterIqr = 3
|
||||
|
||||
/** (intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
||||
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||
|
||||
private fun cacheKey(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
|
||||
|
||||
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
|
||||
fun getAutoMinSpreadBase(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
||||
val key = cacheKey(intervalSeconds, periodStartUnix)
|
||||
val (up, down) = cache[key] ?: run {
|
||||
computeAndCache(intervalSeconds, periodStartUnix) ?: return null
|
||||
}
|
||||
return if (outcomeIndex == 0) up else down
|
||||
}
|
||||
|
||||
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
|
||||
fun computeAndCache(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
|
||||
val endTimeMs = periodStartUnix * 1000L
|
||||
val klines = fetchKlines(intervalStr, historyLimit, endTime = endTimeMs) ?: return null
|
||||
val spreadsUp = mutableListOf<BigDecimal>()
|
||||
val spreadsDown = mutableListOf<BigDecimal>()
|
||||
for (k in klines) {
|
||||
if (k.size < 5) continue
|
||||
val openP = k.getOrNull(1)?.toString()?.toSafeBigDecimal() ?: continue
|
||||
val closeP = k.getOrNull(4)?.toString()?.toSafeBigDecimal() ?: continue
|
||||
if (closeP > openP) spreadsUp.add(closeP.subtract(openP))
|
||||
if (closeP < openP) spreadsDown.add(openP.subtract(closeP))
|
||||
}
|
||||
val baseUp = averageAfterIqr(spreadsUp).setScale(8, RoundingMode.HALF_UP)
|
||||
val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP)
|
||||
cache[cacheKey(intervalSeconds, periodStartUnix)] = baseUp to baseDown
|
||||
logger.info(
|
||||
"尾盘自动价差已计算并缓存(100%基准): 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>>? {
|
||||
return try {
|
||||
val api = retrofitFactory.createBinanceApi()
|
||||
val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime)
|
||||
val response = call.execute()
|
||||
if (response.isSuccessful && response.body() != null) response.body() else null
|
||||
} catch (e: Exception) {
|
||||
logger.warn("拉取币安 K 线失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IQR 剔除异常值后求平均;若剔除后样本数 < minSamplesAfterIqr 则不剔除,用全量求平均。
|
||||
*/
|
||||
private fun averageAfterIqr(list: List<BigDecimal>): BigDecimal {
|
||||
if (list.isEmpty()) return BigDecimal.ZERO
|
||||
val sorted = list.sorted()
|
||||
val n = sorted.size
|
||||
val q1Idx = (n * 0.25).toInt().coerceIn(0, n - 1)
|
||||
val q3Idx = (n * 0.75).toInt().coerceIn(0, n - 1)
|
||||
val q1 = sorted[q1Idx]
|
||||
val q3 = sorted[q3Idx]
|
||||
val iqr = q3.subtract(q1)
|
||||
val lower = q1.subtract(iqr.multiply(BigDecimal("1.5")))
|
||||
val upper = q3.add(iqr.multiply(BigDecimal("1.5")))
|
||||
val filtered = sorted.filter { it >= lower && it <= upper }
|
||||
val use = if (filtered.size < minSamplesAfterIqr) sorted else filtered
|
||||
return use.fold(BigDecimal.ZERO) { a, b -> a.add(b) }.divide(BigDecimal(use.size), 18, RoundingMode.HALF_UP)
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package com.wrbug.polymarketbot.service.binance
|
||||
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* 币安 K 线 WebSocket:订阅 BTCUSDC 5m/15m,维护当前周期 (open, close),供尾盘策略价差校验使用。
|
||||
*/
|
||||
@Service
|
||||
class BinanceKlineService {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BinanceKlineService::class.java)
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val wsBase = "wss://stream.binance.com:9443"
|
||||
private val client = createClient().build()
|
||||
|
||||
/** (intervalSeconds, periodStartUnix) -> (open, close) */
|
||||
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||
private var ws5m: WebSocket? = null
|
||||
private var ws15m: WebSocket? = null
|
||||
private var reconnectJob: Job? = null
|
||||
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()
|
||||
)
|
||||
|
||||
private fun connectAll() {
|
||||
if (ws5m != null && ws15m != null) return
|
||||
connectStream("btcusdc@kline_5m") { intervalSec, tMs, openP, closeP ->
|
||||
val periodSec = tMs / 1000
|
||||
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
|
||||
}.also { ws5m = it }
|
||||
connectStream("btcusdc@kline_15m") { intervalSec, tMs, openP, closeP ->
|
||||
val periodSec = tMs / 1000
|
||||
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
|
||||
}.also { ws15m = it }
|
||||
}
|
||||
|
||||
private fun connectStream(
|
||||
streamName: String,
|
||||
onKline: (intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
|
||||
): WebSocket {
|
||||
val url = "$wsBase/ws/$streamName"
|
||||
val intervalSeconds = when {
|
||||
streamName.contains("kline_5m") -> 300
|
||||
streamName.contains("kline_15m") -> 900
|
||||
else -> 300
|
||||
}
|
||||
val request = Request.Builder().url(url).build()
|
||||
val 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)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
parseKlineMessage(text, intervalSeconds)?.let { (tMs, o, c) ->
|
||||
onKline(intervalSeconds, tMs, o, c)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
connectedFlag?.set(false)
|
||||
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
connectedFlag?.set(false)
|
||||
if (code != 1000) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
connectedFlag?.set(false)
|
||||
}
|
||||
})
|
||||
logger.info("币安 K 线 WS 已连接: $streamName")
|
||||
return ws
|
||||
}
|
||||
|
||||
private fun parseKlineMessage(text: String, intervalSeconds: Int): Triple<Long, BigDecimal, BigDecimal>? {
|
||||
return try {
|
||||
val json = com.google.gson.JsonParser.parseString(text).asJsonObject
|
||||
if (json.get("e")?.asString != "kline") return null
|
||||
val k = json.getAsJsonObject("k") ?: return null
|
||||
val tMs = k.get("t")?.asLong ?: return null
|
||||
val o = k.get("o")?.asString?.toSafeBigDecimal() ?: return null
|
||||
val c = k.get("c")?.asString?.toSafeBigDecimal() ?: return null
|
||||
Triple(tMs, o, c)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("解析币安 K 线消息失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
delay(3_000)
|
||||
reconnectJob = null
|
||||
ws5m?.close(1000, "reconnect")
|
||||
ws15m?.close(1000, "reconnect")
|
||||
ws5m = null
|
||||
ws15m = null
|
||||
connected5m.set(false)
|
||||
connected15m.set(false)
|
||||
logger.info("币安 K 线 WS 尝试重连")
|
||||
connectAll()
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
reconnectJob?.cancel()
|
||||
ws5m?.close(1000, "shutdown")
|
||||
ws15m?.close(1000, "shutdown")
|
||||
ws5m = null
|
||||
ws15m = null
|
||||
}
|
||||
}
|
||||
+14
-17
@@ -10,6 +10,7 @@ import com.wrbug.polymarketbot.api.ValueResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.dto.PositionDto
|
||||
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -93,13 +94,13 @@ class BlockchainService(
|
||||
* 2. Safe Proxy(MetaMask 钱包用户)- 通过合约调用获取地址
|
||||
*
|
||||
* @param walletAddress 用户的钱包地址(EOA)
|
||||
* @param walletType 钱包类型:"magic"(默认)或 "safe"
|
||||
* @param walletType 钱包类型:MAGIC(默认)或 SAFE
|
||||
* @return 代理钱包地址
|
||||
*/
|
||||
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
|
||||
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
|
||||
return try {
|
||||
when (walletType.lowercase()) {
|
||||
"safe" -> {
|
||||
when (walletType) {
|
||||
WalletType.SAFE -> {
|
||||
// Safe Proxy(MetaMask 用户)
|
||||
val safeProxyResult = getSafeProxyAddress(walletAddress)
|
||||
if (safeProxyResult.isSuccess) {
|
||||
@@ -110,7 +111,7 @@ class BlockchainService(
|
||||
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
WalletType.MAGIC -> {
|
||||
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
|
||||
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
|
||||
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
|
||||
@@ -580,39 +581,35 @@ class BlockchainService(
|
||||
|
||||
/**
|
||||
* 赎回仓位
|
||||
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
|
||||
*
|
||||
* 使用 RelayClientService 实现,完全参考 TypeScript 项目的实现方式
|
||||
*
|
||||
* Safe 账户通过代理 execTransaction 调用,Magic 账户通过 Builder Relayer PROXY(Gasless)执行
|
||||
*
|
||||
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0,[2] 表示 outcome 1)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex)
|
||||
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun redeemPositions(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
conditionId: String,
|
||||
indexSets: List<BigInteger>
|
||||
indexSets: List<BigInteger>,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 验证参数
|
||||
if (indexSets.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
|
||||
}
|
||||
|
||||
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
|
||||
}
|
||||
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 使用 RelayClientService 创建赎回交易并执行
|
||||
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
|
||||
relayClientService.execute(privateKey, proxyAddress, redeemTx)
|
||||
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
|
||||
} catch (e: Exception) {
|
||||
logger.error("赎回仓位失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.entity.Market
|
||||
import com.wrbug.polymarketbot.repository.MarketRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.getEventSlug
|
||||
import com.wrbug.polymarketbot.util.parseStringArray
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -207,6 +208,36 @@ class MarketService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 tokenId 从 Gamma 解析市场信息(conditionId、outcomeIndex)
|
||||
* 用于链上解析时 Gamma 失败、仅带 tokenId 的交易在 processBuyTrade 中补查市场
|
||||
*/
|
||||
suspend fun getMarketInfoByTokenId(tokenId: String): MarketInfoByTokenId? {
|
||||
if (tokenId.isBlank()) return null
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.listMarkets(
|
||||
conditionIds = null,
|
||||
clobTokenIds = listOf(tokenId),
|
||||
includeTag = null
|
||||
)
|
||||
if (!response.isSuccessful || response.body().isNullOrEmpty()) return null
|
||||
val market = response.body()!!.first()
|
||||
val conditionId = market.conditionId ?: return null
|
||||
val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids
|
||||
val clobTokenIds = (clobTokenIdsRaw ?: "").parseStringArray()
|
||||
val outcomeIndex = clobTokenIds.indexOfFirst { it.equals(tokenId, ignoreCase = true) }.takeIf { it >= 0 }
|
||||
?: return null
|
||||
val outcomes = market.outcomes.parseStringArray()
|
||||
val outcome = if (outcomeIndex < outcomes.size) outcomes[outcomeIndex] else null
|
||||
saveMarketFromResponse(conditionId, market)
|
||||
MarketInfoByTokenId(conditionId = conditionId, outcomeIndex = outcomeIndex, outcome = outcome)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("按 tokenId 查询市场失败: tokenId=$tokenId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于测试或手动刷新)
|
||||
*/
|
||||
@@ -230,5 +261,33 @@ class MarketService(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 conditionId 查询该市场是否为 Neg Risk(需使用 Neg Risk Exchange 签约)
|
||||
* 用于跟单下单时选择正确的 exchange 合约,避免 invalid signature
|
||||
*/
|
||||
suspend fun getNegRiskByConditionId(conditionId: String): Boolean? {
|
||||
if (conditionId.isBlank()) return null
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.listMarkets(conditionIds = listOf(conditionId))
|
||||
if (!response.isSuccessful || response.body().isNullOrEmpty()) return null
|
||||
val marketResponse = response.body()!!.first()
|
||||
val fromEvent = marketResponse.events?.firstOrNull()?.negRisk
|
||||
val fromMarket = marketResponse.negRisk ?: marketResponse.negRiskOther
|
||||
fromEvent ?: fromMarket
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询市场 negRisk 失败: conditionId=$conditionId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 tokenId 查询 Gamma 得到的市场信息(用于补全 trade.market / outcomeIndex)
|
||||
*/
|
||||
data class MarketInfoByTokenId(
|
||||
val conditionId: String,
|
||||
val outcomeIndex: Int,
|
||||
val outcome: String? = null
|
||||
)
|
||||
|
||||
+20
-28
@@ -98,6 +98,24 @@ class PolymarketApiKeyService(
|
||||
creds.passphrase.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 CLOB /time 获取服务器时间戳,失败时返回 null(调用方使用本地时间)
|
||||
*/
|
||||
private suspend fun fetchServerTimeOrNull(): Long? {
|
||||
return try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful) {
|
||||
timeResponse.body()?.string()?.trim()?.toLongOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 API Key
|
||||
*/
|
||||
@@ -107,20 +125,7 @@ class PolymarketApiKeyService(
|
||||
chainId: Long
|
||||
): Result<ApiKeyCreds> {
|
||||
return try {
|
||||
// 获取服务器时间(可选,用于更准确的时间戳)
|
||||
val serverTime = try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful && timeResponse.body() != null) {
|
||||
timeResponse.body()!!.timestamp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
|
||||
val serverTime = fetchServerTimeOrNull()
|
||||
// 创建带 L1 认证的 API 客户端
|
||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||
|
||||
@@ -158,20 +163,7 @@ class PolymarketApiKeyService(
|
||||
chainId: Long
|
||||
): Result<ApiKeyCreds> {
|
||||
return try {
|
||||
// 获取服务器时间(可选)
|
||||
val serverTime = try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful && timeResponse.body() != null) {
|
||||
timeResponse.body()!!.timestamp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
|
||||
val serverTime = fetchServerTimeOrNull()
|
||||
// 创建带 L1 认证的 API 客户端
|
||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||
|
||||
|
||||
+4
-3
@@ -220,10 +220,10 @@ object OnChainWsUtils {
|
||||
return null
|
||||
}
|
||||
|
||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
|
||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId);失败时仍保留链上 tokenId 供后续按 tokenId 补查市场
|
||||
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
|
||||
|
||||
// 创建 TradeResponse
|
||||
// 创建 TradeResponse:tokenId 始终写入链上解析得到的 asset(与 CLOB 一致),便于 Gamma 失败时在 processBuyTrade 中按 tokenId 再查
|
||||
return TradeResponse(
|
||||
id = txHash,
|
||||
market = marketInfo?.conditionId ?: "",
|
||||
@@ -233,7 +233,8 @@ object OnChainWsUtils {
|
||||
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
||||
user = walletAddress,
|
||||
outcomeIndex = marketInfo?.outcomeIndex,
|
||||
outcome = marketInfo?.outcome
|
||||
outcome = marketInfo?.outcome,
|
||||
tokenId = asset.toString()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -463,6 +463,7 @@ class PolymarketActivityWsService(
|
||||
// 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID
|
||||
val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}"
|
||||
|
||||
// asset 即 CLOB 的 tokenId,必须写入 TradeResponse,跟单下单时用此 tokenId 请求订单簿/下单,否则会用 conditionId+outcomeIndex 链上重算,可能得到与 CLOB 不一致的 tokenId
|
||||
TradeResponse(
|
||||
id = tradeId,
|
||||
market = conditionId,
|
||||
@@ -472,7 +473,8 @@ class PolymarketActivityWsService(
|
||||
timestamp = timestamp,
|
||||
user = null, // Activity WS 中不需要
|
||||
outcomeIndex = outcomeIndex,
|
||||
outcome = outcome
|
||||
outcome = outcome,
|
||||
tokenId = asset
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("解析 Activity Trade 失败: ${e.message}", e)
|
||||
|
||||
+57
-32
@@ -8,6 +8,7 @@ import org.web3j.crypto.Credentials
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* 订单签名服务
|
||||
@@ -19,11 +20,31 @@ import java.math.RoundingMode
|
||||
*/
|
||||
@Service
|
||||
class OrderSigningService {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
|
||||
|
||||
// Polygon 主网合约地址
|
||||
|
||||
/**
|
||||
* 根据是否为 Neg Risk 市场返回签约用 exchange 合约地址
|
||||
* @param negRisk true 时使用 Neg Risk CTF Exchange,否则使用标准 CTF Exchange
|
||||
*/
|
||||
fun getExchangeContract(negRisk: Boolean): String {
|
||||
return if (negRisk) NEG_RISK_EXCHANGE_CONTRACT else EXCHANGE_CONTRACT
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据钱包类型返回 CLOB 订单签名类型
|
||||
* @param walletType Magic=邮箱/社交登录, Safe=Web3 钱包
|
||||
* @return 1=POLY_PROXY(Magic), 2=POLY_GNOSIS_SAFE(Safe), 默认 2
|
||||
*/
|
||||
fun getSignatureTypeForWalletType(walletType: String?): Int {
|
||||
val walletTypeEnum = com.wrbug.polymarketbot.enums.WalletType.fromStringOrDefault(walletType, com.wrbug.polymarketbot.enums.WalletType.SAFE)
|
||||
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
|
||||
}
|
||||
|
||||
// Polygon 主网合约地址(标准 CTF Exchange)
|
||||
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
|
||||
// Neg Risk CTF Exchange(neg risk 市场需用此合约签约,否则服务端返回 invalid signature)
|
||||
private val NEG_RISK_EXCHANGE_CONTRACT = "0xC5d563A36AE78145C45a50134d48A1215220f80a"
|
||||
private val CHAIN_ID = 137L
|
||||
|
||||
// USDC 有 6 位小数
|
||||
@@ -147,6 +168,7 @@ class OrderSigningService {
|
||||
* @param nonce nonce(默认 "0")
|
||||
* @param feeRateBps 费率基点(默认 "0")
|
||||
* @param expiration 过期时间戳(秒,0 表示永不过期)
|
||||
* @param exchangeContract 签约用 exchange 合约地址;null 时用标准 CTF Exchange,neg risk 市场需传 Neg Risk Exchange
|
||||
* @return 签名的订单对象
|
||||
*/
|
||||
fun createAndSignOrder(
|
||||
@@ -159,7 +181,8 @@ class OrderSigningService {
|
||||
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
|
||||
nonce: String = "0",
|
||||
feeRateBps: String = "0",
|
||||
expiration: String = "0"
|
||||
expiration: String = "0",
|
||||
exchangeContract: String? = null
|
||||
): SignedOrderObject {
|
||||
try {
|
||||
// 1. 从私钥获取签名地址
|
||||
@@ -192,10 +215,11 @@ class OrderSigningService {
|
||||
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
|
||||
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
|
||||
|
||||
// 6. 构建订单数据并签名
|
||||
// 6. 构建订单数据并签名(neg risk 市场需用 NEG_RISK_EXCHANGE_CONTRACT)
|
||||
val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT
|
||||
val signature = signOrder(
|
||||
privateKey = privateKey,
|
||||
exchangeContract = EXCHANGE_CONTRACT,
|
||||
exchangeContract = contract,
|
||||
chainId = CHAIN_ID,
|
||||
salt = salt,
|
||||
maker = makerAddressLower,
|
||||
@@ -257,20 +281,20 @@ class OrderSigningService {
|
||||
signatureType: Int
|
||||
): String {
|
||||
try {
|
||||
// 1. 从私钥创建 BigInteger
|
||||
// 1. 私钥与密钥对
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
|
||||
// 2. 编码域分隔符
|
||||
val credentials = Credentials.create(privateKeyBigInt.toString(16))
|
||||
val ecKeyPair = credentials.ecKeyPair
|
||||
|
||||
// 2. 编码域分隔符(verifyingContract 显式小写,与 EIP-712 约定一致)
|
||||
val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain(
|
||||
chainId = chainId,
|
||||
verifyingContract = exchangeContract
|
||||
verifyingContract = exchangeContract.lowercase()
|
||||
)
|
||||
|
||||
|
||||
// 3. 编码订单消息哈希
|
||||
// signatureType 参数:1 = POLY_PROXY (代理钱包), 2 = POLY_GNOSIS_SAFE, 0 = EOA
|
||||
// 使用传入的 signatureType 参数,而不是硬编码
|
||||
// signatureType:1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA
|
||||
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
|
||||
salt = salt,
|
||||
maker = maker,
|
||||
@@ -283,29 +307,25 @@ class OrderSigningService {
|
||||
nonce = nonce,
|
||||
feeRateBps = feeRateBps,
|
||||
side = side,
|
||||
signatureType = signatureType // 使用传入的参数
|
||||
signatureType = signatureType
|
||||
)
|
||||
|
||||
// 4. 计算完整的结构化数据哈希
|
||||
|
||||
// 4. 计算完整 EIP-712 结构化数据哈希
|
||||
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
|
||||
domainSeparator = domainSeparator,
|
||||
messageHash = orderHash
|
||||
)
|
||||
|
||||
// 5. 使用私钥签名
|
||||
|
||||
// 5. 使用私钥签名(needToHash=false,对 32 字节 hash 直接签名)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
|
||||
|
||||
// 6. 组合签名(r + s + v)
|
||||
|
||||
// 6. 组合 r + s + v
|
||||
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 as ByteArray
|
||||
val vInt = if (vBytes.isNotEmpty()) {
|
||||
vBytes[0].toInt() and 0xff
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val vHex = String.format("%02x", vInt)
|
||||
|
||||
val vBytes = signature.v
|
||||
val vInt = if (vBytes.isNotEmpty()) vBytes[0].toInt() and 0xff else 0
|
||||
val vHex = "%02x".format(vInt)
|
||||
|
||||
return "0x$rHex$sHex$vHex"
|
||||
} catch (e: Exception) {
|
||||
logger.error("订单签名失败", e)
|
||||
@@ -313,12 +333,17 @@ class OrderSigningService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 并发安全:确保同一毫秒内多次调用生成唯一 salt,避免 FIXED 模式预签双单等场景的 salt 碰撞 */
|
||||
private val saltSequence = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 生成 salt(使用时间戳,毫秒)
|
||||
* 与 TypeScript SDK 保持一致,使用时间戳作为 salt
|
||||
* 生成 salt(时间戳 + 自增序列,保证并发下唯一)
|
||||
* 兼容 Polymarket:salt 为 Long,时间戳主位 + 序列次位,与 TypeScript SDK 语义兼容
|
||||
*/
|
||||
private fun generateSalt(): Long {
|
||||
return System.currentTimeMillis()
|
||||
val now = System.currentTimeMillis()
|
||||
val seq = saltSequence.incrementAndGet() and 0x3FF
|
||||
return now * 1000 + seq
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+97
-42
@@ -258,19 +258,36 @@ open class CopyOrderTrackingService(
|
||||
continue
|
||||
}
|
||||
|
||||
// 直接使用outcomeIndex获取tokenId(支持多元市场)
|
||||
if (trade.outcomeIndex == null) {
|
||||
logger.warn("交易缺少outcomeIndex,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}")
|
||||
continue
|
||||
// 获取 tokenId:优先使用链上解析得到的 tokenId(与 Gamma clobTokenIds 一致),否则用 conditionId+outcomeIndex 链上重算
|
||||
val tokenId = if (!trade.tokenId.isNullOrBlank()) {
|
||||
trade.tokenId
|
||||
} else {
|
||||
if (trade.outcomeIndex == null) {
|
||||
logger.warn("交易缺少outcomeIndex且无tokenId,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}")
|
||||
continue
|
||||
}
|
||||
val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
continue
|
||||
}
|
||||
tokenIdResult.getOrNull() ?: continue
|
||||
}
|
||||
|
||||
// 获取tokenId(直接使用outcomeIndex,不转换为YES/NO)
|
||||
val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
// 当链上解析时 Gamma 失败导致 market/outcomeIndex 为空时,按 tokenId 补查市场信息
|
||||
var effectiveMarketId = trade.market
|
||||
var effectiveOutcomeIndex = trade.outcomeIndex
|
||||
if (effectiveMarketId.isBlank() && !trade.tokenId.isNullOrBlank()) {
|
||||
val infoByToken = marketService.getMarketInfoByTokenId(trade.tokenId)
|
||||
if (infoByToken != null) {
|
||||
effectiveMarketId = infoByToken.conditionId
|
||||
effectiveOutcomeIndex = infoByToken.outcomeIndex
|
||||
}
|
||||
}
|
||||
if (effectiveMarketId.isBlank()) {
|
||||
logger.warn("无法确定市场(conditionId),跳过: tradeId=${trade.id}, tokenId=${trade.tokenId}")
|
||||
continue
|
||||
}
|
||||
val tokenId = tokenIdResult.getOrNull() ?: continue
|
||||
|
||||
// 先计算跟单金额(用于仓位检查)
|
||||
// 注意:这里先计算金额,即使后续被过滤也会记录
|
||||
@@ -293,7 +310,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
if (needMarketInfo) {
|
||||
try {
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val market = marketService.getMarket(effectiveMarketId)
|
||||
marketTitle = market?.title
|
||||
marketEndDate = market?.endDate
|
||||
} catch (e: Exception) {
|
||||
@@ -312,10 +329,10 @@ open class CopyOrderTrackingService(
|
||||
tokenId,
|
||||
tradePrice = tradePrice,
|
||||
copyOrderAmount = copyOrderAmount,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketTitle = marketTitle,
|
||||
marketEndDate = marketEndDate,
|
||||
outcomeIndex = trade.outcomeIndex
|
||||
outcomeIndex = effectiveOutcomeIndex
|
||||
)
|
||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||
if (!filterResult.isPassed) {
|
||||
@@ -325,8 +342,8 @@ open class CopyOrderTrackingService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val marketTitle = market?.title ?: trade.market
|
||||
val market = marketService.getMarket(effectiveMarketId)
|
||||
val marketTitle = market?.title ?: effectiveMarketId
|
||||
val marketSlug = market?.slug // 显示用的 slug
|
||||
|
||||
// 从过滤结果中提取 filterType
|
||||
@@ -346,11 +363,11 @@ open class CopyOrderTrackingService(
|
||||
accountId = copyTrading.accountId,
|
||||
leaderId = copyTrading.leaderId,
|
||||
leaderTradeId = trade.id,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketTitle = marketTitle,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcomeIndex = trade.outcomeIndex,
|
||||
outcomeIndex = effectiveOutcomeIndex,
|
||||
outcome = trade.outcome,
|
||||
price = trade.price.toSafeBigDecimal(),
|
||||
size = trade.size.toSafeBigDecimal(),
|
||||
@@ -376,7 +393,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
telegramNotificationService?.sendOrderFilteredNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = trade.outcome,
|
||||
@@ -556,6 +573,11 @@ open class CopyOrderTrackingService(
|
||||
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps")
|
||||
|
||||
// Neg Risk 市场需用 Neg Risk Exchange 签约,否则服务端返回 invalid signature
|
||||
val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true
|
||||
val exchangeContract = orderSigningService.getExchangeContract(negRisk)
|
||||
if (negRisk) logger.debug("市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=$effectiveMarketId")
|
||||
|
||||
// 调用API创建订单(带重试机制)
|
||||
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
|
||||
@@ -563,6 +585,8 @@ open class CopyOrderTrackingService(
|
||||
clobApi = clobApi,
|
||||
privateKey = decryptedPrivateKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
walletAddress = account.walletAddress,
|
||||
exchangeContract = exchangeContract,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = buyPrice.toString(),
|
||||
@@ -570,7 +594,8 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id,
|
||||
feeRateBps = feeRateBps
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
// 处理订单创建失败
|
||||
@@ -584,8 +609,8 @@ open class CopyOrderTrackingService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val marketTitle = market?.title ?: trade.market
|
||||
val market = marketService.getMarket(effectiveMarketId)
|
||||
val marketTitle = market?.title ?: effectiveMarketId
|
||||
val marketSlug = market?.eventSlug // 跳转用的 slug
|
||||
|
||||
// 获取当前语言设置(从 LocaleContextHolder)
|
||||
@@ -597,7 +622,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
telegramNotificationService?.sendOrderFailureNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = null, // 失败时可能没有 outcome
|
||||
@@ -631,9 +656,9 @@ open class CopyOrderTrackingService(
|
||||
copyTradingId = copyTrading.id,
|
||||
accountId = copyTrading.accountId,
|
||||
leaderId = copyTrading.leaderId,
|
||||
marketId = trade.market,
|
||||
side = trade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据)
|
||||
outcomeIndex = trade.outcomeIndex, // 新增字段
|
||||
marketId = effectiveMarketId,
|
||||
side = effectiveOutcomeIndex?.toString() ?: "", // 使用outcomeIndex作为side(兼容旧数据)
|
||||
outcomeIndex = effectiveOutcomeIndex, // 新增字段
|
||||
buyOrderId = realOrderId, // 使用真实订单ID
|
||||
leaderBuyTradeId = trade.id,
|
||||
leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例)
|
||||
@@ -903,13 +928,21 @@ open class CopyOrderTrackingService(
|
||||
finalNeedMatch = BigDecimal.ONE
|
||||
}
|
||||
|
||||
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
return
|
||||
// 4. 获取 tokenId:优先使用链上解析得到的 tokenId,否则用 conditionId+outcomeIndex 链上重算
|
||||
val tokenId = if (!leaderSellTrade.tokenId.isNullOrBlank()) {
|
||||
leaderSellTrade.tokenId
|
||||
} else {
|
||||
if (leaderSellTrade.outcomeIndex == null) {
|
||||
logger.error("卖出交易缺少outcomeIndex且无tokenId: market=${leaderSellTrade.market}")
|
||||
return
|
||||
}
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
return
|
||||
}
|
||||
tokenIdResult.getOrNull() ?: return
|
||||
}
|
||||
val tokenId = tokenIdResult.getOrNull() ?: return
|
||||
|
||||
// 5. 计算卖出价格(优先使用订单簿 bestBid,失败则使用 Leader 价格,固定按90%计算)
|
||||
// 注意:需要先计算卖出价格,因为后续创建 matchDetails 需要使用实际卖出价格
|
||||
@@ -994,7 +1027,12 @@ open class CopyOrderTrackingService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 9. 创建并签名卖出订单
|
||||
// 9. Neg Risk 市场需用 Neg Risk Exchange 签约
|
||||
val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true
|
||||
val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell)
|
||||
if (negRiskSell) logger.debug("卖出市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=${leaderSellTrade.market}")
|
||||
|
||||
// 10. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -1003,17 +1041,18 @@ open class CopyOrderTrackingService(
|
||||
side = "SELL",
|
||||
price = sellPrice.toString(),
|
||||
size = totalMatched.toString(),
|
||||
signatureType = 2, // Browser Wallet
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
expiration = "0",
|
||||
exchangeContract = exchangeContractSell
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建并签名卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}", e)
|
||||
return
|
||||
}
|
||||
|
||||
// 10. 构建订单请求
|
||||
// 11. 构建订单请求
|
||||
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||
val orderRequest = NewOrderRequest(
|
||||
@@ -1023,7 +1062,7 @@ open class CopyOrderTrackingService(
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
// 11. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey,
|
||||
apiSecret,
|
||||
@@ -1031,12 +1070,13 @@ open class CopyOrderTrackingService(
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
// 12. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||
|
||||
// 13. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||
val createOrderResult = createOrderWithRetry(
|
||||
clobApi = clobApi,
|
||||
privateKey = decryptedPrivateKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
walletAddress = account.walletAddress,
|
||||
exchangeContract = exchangeContractSell,
|
||||
tokenId = tokenId,
|
||||
side = "SELL",
|
||||
price = sellPrice.toString(),
|
||||
@@ -1044,7 +1084,8 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id,
|
||||
tradeId = leaderSellTrade.id,
|
||||
feeRateBps = feeRateBps
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
if (createOrderResult.isFailure) {
|
||||
@@ -1128,7 +1169,9 @@ open class CopyOrderTrackingService(
|
||||
*
|
||||
* @param clobApi CLOB API 客户端
|
||||
* @param privateKey 私钥(用于签名)
|
||||
* @param makerAddress 代理钱包地址
|
||||
* @param makerAddress 代理钱包地址(funder)
|
||||
* @param walletAddress 账户 EOA 地址(须与私钥推导的 signer 一致,用于校验及 POLY_ADDRESS)
|
||||
* @param exchangeContract 签约用 exchange 合约(Neg Risk 市场需用 Neg Risk Exchange)
|
||||
* @param tokenId Token ID
|
||||
* @param side 订单方向(BUY/SELL)
|
||||
* @param price 价格
|
||||
@@ -1137,12 +1180,15 @@ open class CopyOrderTrackingService(
|
||||
* @param copyTradingId 跟单配置ID(用于日志)
|
||||
* @param tradeId Leader 交易ID(用于日志)
|
||||
* @param feeRateBps 费率基点(从API动态获取)
|
||||
* @param signatureType 签名类型(1=Magic, 2=Safe)
|
||||
* @return 成功返回订单ID,失败返回异常
|
||||
*/
|
||||
private suspend fun createOrderWithRetry(
|
||||
clobApi: PolymarketClobApi,
|
||||
privateKey: String,
|
||||
makerAddress: String,
|
||||
walletAddress: String,
|
||||
exchangeContract: String,
|
||||
tokenId: String,
|
||||
side: String,
|
||||
price: String,
|
||||
@@ -1150,7 +1196,8 @@ open class CopyOrderTrackingService(
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String,
|
||||
feeRateBps: String
|
||||
feeRateBps: String,
|
||||
signatureType: Int
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
|
||||
@@ -1165,12 +1212,20 @@ open class CopyOrderTrackingService(
|
||||
side = side,
|
||||
price = price,
|
||||
size = size,
|
||||
signatureType = 2, // Browser Wallet
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
expiration = "0",
|
||||
exchangeContract = exchangeContract
|
||||
)
|
||||
|
||||
// 校验 signer 与账户 walletAddress 一致,否则服务端会返回 invalid signature(POLY_ADDRESS 与 order.signer 需一致)
|
||||
if (signedOrder.signer.lowercase() != walletAddress.lowercase()) {
|
||||
val msg = "订单 signer 与账户 walletAddress 不一致,会导致 invalid signature。请确认该账户的私钥与 walletAddress 对应同一 EOA,且 API 密钥由该 EOA 创建。signer=${signedOrder.signer.take(10)}..., walletAddress=${walletAddress.take(10)}..."
|
||||
logger.error(msg)
|
||||
return Result.failure(IllegalStateException(msg))
|
||||
}
|
||||
|
||||
// 构建订单请求
|
||||
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||
|
||||
-2
@@ -198,8 +198,6 @@ class OrderStatusUpdateService(
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
|
||||
|
||||
// 按账户分组,避免重复创建 API 客户端
|
||||
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 尾盘策略订单 TG 通知轮询服务(与跟单一致)
|
||||
* 定时查询「下单成功且未发 TG」的触发记录,通过 CLOB getOrder 获取订单详情后发送 TG 并标记已发。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailOrderNotificationPollingService(
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val marketService: MarketService,
|
||||
private val telegramNotificationService: TelegramNotificationService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationPollingService::class.java)
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
|
||||
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||
this.applicationContext = applicationContext
|
||||
}
|
||||
|
||||
private fun getSelf(): CryptoTailOrderNotificationPollingService {
|
||||
return applicationContext?.getBean(CryptoTailOrderNotificationPollingService::class.java)
|
||||
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var notificationJob: Job? = null
|
||||
|
||||
@Scheduled(fixedDelay = 5000)
|
||||
fun scheduledSendPendingNotifications() {
|
||||
if (notificationJob != null && notificationJob!!.isActive) {
|
||||
logger.debug("上一轮尾盘 TG 通知任务仍在执行,跳过本次")
|
||||
return
|
||||
}
|
||||
notificationJob = scope.launch {
|
||||
try {
|
||||
getSelf().sendPendingNotifications()
|
||||
} catch (e: Exception) {
|
||||
logger.error("尾盘 TG 通知轮询异常: ${e.message}", e)
|
||||
} finally {
|
||||
notificationJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
suspend fun sendPendingNotifications() {
|
||||
val pending = triggerRepository.findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc("success")
|
||||
if (pending.isEmpty()) return
|
||||
for (trigger in pending) {
|
||||
try {
|
||||
if (trigger.resolved) {
|
||||
trigger.notificationSent = true
|
||||
triggerRepository.save(trigger)
|
||||
logger.debug("触发已结算,跳过请求并标记已通知: triggerId=${trigger.id}, orderId=${trigger.orderId}")
|
||||
continue
|
||||
}
|
||||
if (sendNotificationForTrigger(trigger)) {
|
||||
trigger.notificationSent = true
|
||||
triggerRepository.save(trigger)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendNotificationForTrigger(trigger: CryptoTailStrategyTrigger): Boolean {
|
||||
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return false
|
||||
val orderId = trigger.orderId ?: return false
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户未配置 API 凭证,跳过 TG: accountId=${account.id}")
|
||||
return false
|
||||
}
|
||||
val apiSecret = try {
|
||||
cryptoUtils.decrypt(account.apiSecret) ?: return false
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Secret 失败: accountId=${account.id}", e)
|
||||
return false
|
||||
}
|
||||
val apiPassphrase = try {
|
||||
cryptoUtils.decrypt(account.apiPassphrase) ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
)
|
||||
val orderResponse = clobApi.getOrder(orderId)
|
||||
if (!orderResponse.isSuccessful) {
|
||||
logger.debug("查询订单详情失败,等待下次轮询: orderId=$orderId, code=${orderResponse.code()}")
|
||||
return false
|
||||
}
|
||||
val order = orderResponse.body() ?: run {
|
||||
logger.debug("订单详情为空,等待下次轮询: orderId=$orderId")
|
||||
return false
|
||||
}
|
||||
val market = marketService.getMarket(order.market)
|
||||
val marketTitle = trigger.marketTitle?.takeIf { it.isNotBlank() } ?: market?.title ?: order.market
|
||||
val orderTimeMs = if (order.createdAt < 1_000_000_000_000L) order.createdAt * 1000 else order.createdAt
|
||||
telegramNotificationService.sendCryptoTailOrderSuccessNotification(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = order.market,
|
||||
marketSlug = market?.eventSlug ?: market?.slug,
|
||||
side = order.side,
|
||||
outcome = order.outcome,
|
||||
price = order.price,
|
||||
size = order.originalSize,
|
||||
strategyName = strategy.name,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
orderTime = orderTimeMs
|
||||
)
|
||||
logger.info("尾盘订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
|
||||
return true
|
||||
}
|
||||
}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
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.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.toJson
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Service
|
||||
import jakarta.annotation.PostConstruct
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 尾盘策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailOrderbookWsService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val executionService: CryptoTailStrategyExecutionService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
|
||||
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
|
||||
private val client = createClient().build()
|
||||
|
||||
/** 订阅成功后设置的倒计时 Job,在周期结束时自动刷新订阅 */
|
||||
private var periodEndCountdownJob: Job? = null
|
||||
|
||||
/** 重连延迟(毫秒) */
|
||||
private val reconnectDelayMs = 3_000L
|
||||
|
||||
/** 因无启用策略而主动关闭 WS 时置为 true,onClosing 中不触发重连 */
|
||||
private val closedForNoStrategies = AtomicBoolean(false)
|
||||
|
||||
/** 保护 connect() 的互斥锁,避免多线程并发创建连接 */
|
||||
private val connectLock = Any()
|
||||
|
||||
data class WsBookEntry(
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
val marketTitle: String?,
|
||||
val tokenIds: List<String>,
|
||||
val outcomeIndex: Int
|
||||
)
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
if (strategyRepository.findAllByEnabledTrue().isNotEmpty()) connect()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
synchronized(connectLock) {
|
||||
if (webSocket != null) return
|
||||
try {
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
logger.info("尾盘策略订单簿 WebSocket 已连接")
|
||||
refreshAndSubscribe(fromConnect = true)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleMessage(text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
this@CryptoTailOrderbookWsService.webSocket = null
|
||||
if (!closedForNoStrategies.getAndSet(false)) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
logger.warn("尾盘策略订单簿 WebSocket 异常: ${t.message}")
|
||||
this@CryptoTailOrderbookWsService.webSocket = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
logger.error("尾盘策略订单簿 WebSocket 连接失败: ${e.message}", e)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
delay(reconnectDelayMs)
|
||||
reconnectJob = null
|
||||
if (strategyRepository.findAllByEnabledTrue().isEmpty()) return@launch
|
||||
logger.info("尾盘策略订单簿 WebSocket 尝试重连")
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(text: String) {
|
||||
if (text == "pong" || text.isEmpty()) return
|
||||
if (closedForNoStrategies.get()) return
|
||||
maybeRefreshSubscriptionIfPeriodChanged()
|
||||
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
|
||||
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||
|
||||
when (eventType) {
|
||||
"book" -> {
|
||||
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()
|
||||
if (bestBid != null) onBestBid(assetId, bestBid)
|
||||
}
|
||||
|
||||
"price_change" -> {
|
||||
val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return
|
||||
for (i in 0 until priceChanges.size()) {
|
||||
val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue
|
||||
val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue
|
||||
val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString
|
||||
val bestBid = bestBidStr?.toSafeBigDecimal()
|
||||
if (bestBid != null) onBestBid(assetId, bestBid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
for (e in entries) {
|
||||
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
|
||||
val windowEnd = e.periodStartUnix + e.strategy.windowEndSeconds
|
||||
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
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件驱动:仅在收到 WS 消息时检查当前周期是否变化,若变化则刷新订阅,无需定时轮询。
|
||||
*/
|
||||
private fun maybeRefreshSubscriptionIfPeriodChanged() {
|
||||
val subscribed = tokenToEntries.get().values.flatten().distinctBy { it.strategy.id }
|
||||
.associate { it.strategy.id!! to it.periodStartUnix }
|
||||
if (subscribed.isEmpty()) return
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val currentStrategyIds = strategies.map { it.id!! }.toSet()
|
||||
if (subscribed.keys != currentStrategyIds) {
|
||||
refreshAndSubscribe()
|
||||
return
|
||||
}
|
||||
for (s in strategies) {
|
||||
val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds
|
||||
val subPeriod = subscribed[s.id!!] ?: continue
|
||||
if (currentPeriod != subPeriod) {
|
||||
refreshAndSubscribe()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
return
|
||||
}
|
||||
if (oldTokenIds == tokenIds.toSet()) {
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoMinSpreadForCurrentPeriods(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)
|
||||
precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅更新时关闭当前 WebSocket,由 onClosing 触发重连,重连后 onOpen 会重新订阅。
|
||||
*/
|
||||
private fun closeWebSocketAndReconnect() {
|
||||
val ws = webSocket
|
||||
if (ws != null) {
|
||||
webSocket = null
|
||||
try {
|
||||
ws.close(1000, "subscription_change")
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}")
|
||||
}
|
||||
logger.info("尾盘策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期最小价差,触发时直接用缓存。
|
||||
*/
|
||||
private fun precomputeAutoMinSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
||||
val autoPeriods = newMap.values.asSequence().flatten()
|
||||
.filter { it.strategy.minSpreadMode.uppercase() == "AUTO" }
|
||||
.distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
|
||||
.map { it.strategy.intervalSeconds to it.periodStartUnix }
|
||||
.toList()
|
||||
if (autoPeriods.isEmpty()) return
|
||||
scope.launch {
|
||||
for ((intervalSeconds, periodStartUnix) in autoPeriods) {
|
||||
try {
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
||||
if (pair != null) {
|
||||
logger.info(
|
||||
"周期开始初始价差: 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无启用策略或无需订阅时关闭 WebSocket,并取消重连;停用策略后刷新订阅会走到此处。
|
||||
*/
|
||||
private fun closeWebSocketForNoStrategies() {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
val ws = webSocket
|
||||
if (ws != null) {
|
||||
closedForNoStrategies.set(true)
|
||||
webSocket = null
|
||||
try {
|
||||
ws.close(1000, "no_enabled_strategies")
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}")
|
||||
}
|
||||
logger.info("尾盘策略订单簿 WebSocket 已关闭(无启用策略)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅成功后设置倒计时:在当前周期结束时自动刷新订阅,无需等消息触发。
|
||||
*/
|
||||
private fun scheduleRefreshAtPeriodEnd(newMap: Map<String, List<WsBookEntry>>) {
|
||||
val entries = newMap.values.flatten()
|
||||
if (entries.isEmpty()) return
|
||||
val nextPeriodEndSeconds = entries.minOf { it.periodStartUnix + it.strategy.intervalSeconds }
|
||||
val delayMs = (nextPeriodEndSeconds * 1000) - System.currentTimeMillis() + 2000
|
||||
if (delayMs <= 0) return
|
||||
periodEndCountdownJob = scope.launch {
|
||||
delay(delayMs)
|
||||
periodEndCountdownJob = null
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
logger.debug("尾盘策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新")
|
||||
}
|
||||
|
||||
private fun buildSubscriptionMap(): Pair<List<String>, Map<String, List<WsBookEntry>>> {
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val tokenIdSet = mutableSetOf<String>()
|
||||
val map = mutableMapOf<String, MutableList<WsBookEntry>>()
|
||||
|
||||
for (strategy in strategies) {
|
||||
val interval = strategy.intervalSeconds
|
||||
val periodStartUnix = (nowSeconds / interval) * interval
|
||||
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
||||
if (nowSeconds >= windowEnd) {
|
||||
logger.debug("尾盘策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd")
|
||||
continue
|
||||
}
|
||||
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||
val event = fetchEventBySlugWithRetry(slug).getOrNull()
|
||||
if (event == null) {
|
||||
logger.warn("尾盘策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试")
|
||||
continue
|
||||
}
|
||||
val market = event.markets?.firstOrNull()
|
||||
if (market == null) {
|
||||
logger.warn("尾盘策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug")
|
||||
continue
|
||||
}
|
||||
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||
if (tokenIds.size < 2) {
|
||||
logger.warn("尾盘策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}")
|
||||
continue
|
||||
}
|
||||
tokenIdSet.addAll(tokenIds)
|
||||
for (i in tokenIds.indices) {
|
||||
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||
WsBookEntry(strategy, periodStartUnix, event.title, tokenIds, i)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(tokenIdSet.toList(), map)
|
||||
}
|
||||
|
||||
/** 拉取事件,失败时重试最多 2 次(间隔 1s),避免瞬时失败导致多策略只订阅到其中一个 */
|
||||
private 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) }
|
||||
}
|
||||
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
|
||||
}
|
||||
|
||||
private fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val api = retrofitFactory.createGammaApi()
|
||||
val response = runBlocking { api.getEventBySlug(slug) }
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||
val parsed = clobTokenIds.fromJson<List<String>>()
|
||||
return parsed ?: emptyList()
|
||||
}
|
||||
|
||||
@EventListener
|
||||
fun onStrategyChanged(event: CryptoTailStrategyChangedEvent) {
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* 尾盘策略结算轮询服务
|
||||
* 定时扫描「状态成功但未结算」的触发记录,通过 Gamma 获取 conditionId、链上查询结算结果,计算收益并回写。
|
||||
* 实际成交价与成交量使用 Data API 的 activity 接口获取(getUserActivity),比 CLOB getOrder 更准确;失败时回退为触发时的 amountUsdc + 固定价 0.99。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailSettlementService(
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailSettlementService::class.java)
|
||||
|
||||
private val triggerFixedPrice = BigDecimal("0.99")
|
||||
private val pnlScale = 8
|
||||
|
||||
private val settlementScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
/** 跟踪上一轮结算任务的 Job,防止并发执行(与 OrderStatusUpdateService 一致) */
|
||||
@Volatile
|
||||
private var settlementJob: Job? = null
|
||||
|
||||
/**
|
||||
* 定时轮询:每 10 秒执行一次。
|
||||
* 若上一轮任务仍在执行则跳过本次,避免并发重叠。
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10_000)
|
||||
fun scheduledPollAndSettle() {
|
||||
val previousJob = settlementJob
|
||||
if (previousJob != null && previousJob.isActive) {
|
||||
logger.debug("上一轮尾盘结算任务仍在执行,跳过本次调度")
|
||||
return
|
||||
}
|
||||
settlementJob = settlementScope.launch {
|
||||
try {
|
||||
doPollAndSettle()
|
||||
} catch (e: Exception) {
|
||||
logger.error("尾盘策略结算定时任务异常: ${e.message}", e)
|
||||
} finally {
|
||||
settlementJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询入口:拉取所有 status=success 且 resolved=false 的触发记录,逐条尝试结算并更新。
|
||||
* Controller/定时任务调用此方法(内部对 suspend 使用 runBlocking)。
|
||||
*/
|
||||
@Transactional
|
||||
fun pollAndSettle(): Int = runBlocking {
|
||||
doPollAndSettle()
|
||||
}
|
||||
|
||||
private suspend fun doPollAndSettle(): Int {
|
||||
val pending = triggerRepository.findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc("success", false)
|
||||
if (pending.isEmpty()) return 0
|
||||
var settledCount = 0
|
||||
for (trigger in pending) {
|
||||
try {
|
||||
if (settleOne(trigger)) settledCount++
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘结算单条失败: triggerId=${trigger.id}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
if (settledCount > 0) {
|
||||
logger.info("尾盘策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount")
|
||||
}
|
||||
return settledCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单条触发记录:解析 conditionId -> 查链上结算 -> 若已结算则计算 pnl 并更新。
|
||||
* 通过 copy() 生成新实体再 save,不直接修改原实体;实际成交价与投入金额从 Data API activity 获取并更新 triggerPrice、amountUsdc。
|
||||
* @return true 表示本条已结算并更新
|
||||
*/
|
||||
private suspend fun settleOne(trigger: CryptoTailStrategyTrigger): Boolean {
|
||||
if (trigger.resolved) return false
|
||||
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
|
||||
val conditionId = resolveConditionId(strategy, trigger) ?: return false
|
||||
val fill = fetchActivityFill(trigger, strategy, conditionId)
|
||||
val (newTriggerPrice, newAmountUsdc) = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) {
|
||||
val amountUsdc = fill.usdcSize?.takeIf { it.gt(BigDecimal.ZERO) }
|
||||
?: fill.price.multi(fill.size).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
Pair(fill.price, amountUsdc)
|
||||
} else {
|
||||
Pair(trigger.triggerPrice, trigger.amountUsdc)
|
||||
}
|
||||
|
||||
val (_, payouts) = blockchainService.getCondition(conditionId).getOrNull() ?: run {
|
||||
if (fill != null) {
|
||||
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
|
||||
triggerRepository.save(updated)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (payouts.isEmpty()) {
|
||||
if (fill != null) {
|
||||
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
|
||||
triggerRepository.save(updated)
|
||||
}
|
||||
return false
|
||||
}
|
||||
val winnerIndex = payouts.indexOfFirst { it == java.math.BigInteger.ONE }
|
||||
if (winnerIndex < 0) return false
|
||||
|
||||
val won = trigger.outcomeIndex == winnerIndex
|
||||
val pnl = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) {
|
||||
if (won) newAmountUsdc.let { fill.size.subtract(it).setScale(pnlScale, RoundingMode.HALF_UP) }
|
||||
else newAmountUsdc.negate().setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
computePnlFallback(trigger.amountUsdc, won)
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val updated = trigger.copy(
|
||||
triggerPrice = newTriggerPrice,
|
||||
amountUsdc = newAmountUsdc,
|
||||
conditionId = conditionId,
|
||||
resolved = true,
|
||||
winnerOutcomeIndex = winnerIndex,
|
||||
realizedPnl = pnl,
|
||||
settledAt = now
|
||||
)
|
||||
triggerRepository.save(updated)
|
||||
logger.debug("尾盘结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun resolveConditionId(strategy: CryptoTailStrategy, trigger: CryptoTailStrategyTrigger): String? {
|
||||
if (!trigger.conditionId.isNullOrBlank()) return trigger.conditionId
|
||||
val slug = "${strategy.marketSlugPrefix}-${trigger.periodStartUnix}"
|
||||
val event = fetchEventBySlug(slug).getOrNull() ?: return null
|
||||
val markets = event.markets ?: return null
|
||||
val first = markets.firstOrNull() ?: return null
|
||||
return first.conditionId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.getEventBySlug(slug)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
val msg = if (response.code() == 404) "404" else "code=${response.code()}"
|
||||
Result.failure(Exception(msg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity 匹配到的一条 TRADE 的成交数据:价格、数量、实际投入 USDC(接口 usdcSize)。
|
||||
*/
|
||||
private data class ActivityFill(
|
||||
val price: BigDecimal,
|
||||
val size: BigDecimal,
|
||||
val usdcSize: BigDecimal?
|
||||
)
|
||||
|
||||
/**
|
||||
* 通过 Data API activity 接口获取该触发对应的实际成交价、成交量与投入金额(比 CLOB getOrder 更准确)。
|
||||
* 只有此接口返回匹配的 TRADE 且 price/size 有效时,结算才会更新 triggerPrice、amountUsdc(表现);投入金额优先用 activity 的 usdcSize。
|
||||
*/
|
||||
private suspend fun fetchActivityFill(
|
||||
trigger: CryptoTailStrategyTrigger,
|
||||
strategy: CryptoTailStrategy,
|
||||
conditionId: String
|
||||
): ActivityFill? {
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||
logger.warn("尾盘结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}")
|
||||
return null
|
||||
}
|
||||
val user = account.proxyAddress
|
||||
val triggerTimeSeconds = trigger.createdAt / 1000
|
||||
val start = triggerTimeSeconds - 120
|
||||
val end = triggerTimeSeconds + 600
|
||||
return try {
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
val response = dataApi.getUserActivity(
|
||||
user = user,
|
||||
type = listOf("TRADE"),
|
||||
start = start,
|
||||
end = end,
|
||||
limit = 50,
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "DESC"
|
||||
)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.warn("尾盘结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}")
|
||||
return null
|
||||
}
|
||||
val activities = response.body()!!
|
||||
// 只匹配 TRADE:返回里可能混有 REDEEM(outcomeIndex=999、price=0)等,需排除
|
||||
val match = activities.firstOrNull { a ->
|
||||
a.type == "TRADE" &&
|
||||
a.conditionId == conditionId &&
|
||||
a.outcomeIndex != null && a.outcomeIndex!! in 0..1 &&
|
||||
a.outcomeIndex == trigger.outcomeIndex &&
|
||||
a.side?.uppercase() == "BUY" &&
|
||||
a.price != null && a.price!! > 0 &&
|
||||
a.size != null && a.size!! > 0
|
||||
} ?: run {
|
||||
logger.debug("尾盘结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}")
|
||||
return null
|
||||
}
|
||||
val price = match.price!!.toSafeBigDecimal()
|
||||
val size = match.size!!.toSafeBigDecimal()
|
||||
val usdcSize = match.usdcSize?.toSafeBigDecimal()?.takeIf { it.gt(BigDecimal.ZERO) }
|
||||
if (price.gt(BigDecimal.ZERO) && size.gt(BigDecimal.ZERO)) {
|
||||
ActivityFill(price = price, size = size, usdcSize = usdcSize)
|
||||
} else {
|
||||
logger.debug("尾盘结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按实际成交价与成交量计算收益:成本 = sizeMatched * price;赢则赎回 sizeMatched * 1,输则 0。
|
||||
*/
|
||||
private fun computePnlFromFill(price: BigDecimal, sizeMatched: BigDecimal, won: Boolean): BigDecimal {
|
||||
val cost = sizeMatched.multi(price).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
return if (won) {
|
||||
sizeMatched.subtract(cost).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
cost.negate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退收益计算:无 API 数据时用触发时的 amountUsdc 与固定价 0.99。
|
||||
* 赢: pnl = amountUsdc/0.99 - amountUsdc;输: pnl = -amountUsdc
|
||||
*/
|
||||
private fun computePnlFallback(amountUsdc: BigDecimal, won: Boolean): BigDecimal {
|
||||
return if (won) {
|
||||
amountUsdc.divide(triggerFixedPrice, pnlScale, RoundingMode.HALF_UP).subtract(amountUsdc)
|
||||
} else {
|
||||
amountUsdc.negate()
|
||||
}
|
||||
}
|
||||
}
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.api.NewOrderRequest
|
||||
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.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
||||
private const val TRIGGER_FIXED_PRICE = "0.99"
|
||||
|
||||
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
||||
private const val SIZE_DECIMAL_SCALE = 2
|
||||
|
||||
/**
|
||||
* 周期内预置上下文:账户、解密凭证、费率、签名类型、CLOB 客户端;不含预签订单。
|
||||
* 触发时 FIXED/RATIO 均按 outcomeIndex 计算 size 并签名提交。
|
||||
*/
|
||||
private data class PeriodContext(
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
val account: Account,
|
||||
val decryptedPrivateKey: String,
|
||||
val apiSecretDecrypted: String,
|
||||
val apiPassphraseDecrypted: String,
|
||||
val clobApi: PolymarketClobApi,
|
||||
val feeRateByTokenId: Map<String, String>,
|
||||
val signatureType: Int,
|
||||
val tokenIds: List<String>,
|
||||
val marketTitle: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。
|
||||
* 周期开始预置账户、解密、费率、签名类型、CLOB 客户端;触发时按 outcomeIndex 计算 size 并签名提交。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailStrategyExecutionService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val accountService: AccountService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val orderSigningService: OrderSigningService,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val binanceKlineService: BinanceKlineService,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyExecutionService::class.java)
|
||||
|
||||
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
|
||||
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
|
||||
|
||||
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex =
|
||||
triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
|
||||
|
||||
/** 周期预置上下文缓存:(strategyId-periodStartUnix) -> PeriodContext,过期周期在读取时剔除 */
|
||||
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
|
||||
|
||||
/** 已打印「首次满足条件」日志的周期:LRU 容量 100,每周期只打一次 */
|
||||
private val conditionLoggedCache: Cache<String, Long> = Caffeine.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 在周期内首次需要时构建并缓存预置上下文;失败返回 null,触发流程将走完整路径。
|
||||
* 预置:账户、解密、费率、签名类型、CLOB 客户端;不预签订单,触发时再签名。
|
||||
*/
|
||||
private suspend fun ensurePeriodContext(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
tokenIds: List<String>,
|
||||
marketTitle: String?
|
||||
): PeriodContext? {
|
||||
val key = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
periodContextCache[key]?.let { return it }
|
||||
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return null
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) return null
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: return null
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘策略周期上下文解密私钥失败: accountId=${account.id}", e)
|
||||
return null
|
||||
}
|
||||
val apiSecret = try {
|
||||
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
val apiPassphrase = try {
|
||||
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val feeRateByTokenId = tokenIds.associate { tokenId ->
|
||||
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
|
||||
}
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < BigDecimal("1")) return null
|
||||
|
||||
val ctx = PeriodContext(
|
||||
strategy = strategy,
|
||||
periodStartUnix = periodStartUnix,
|
||||
account = account,
|
||||
decryptedPrivateKey = decryptedKey,
|
||||
apiSecretDecrypted = apiSecret,
|
||||
apiPassphraseDecrypted = apiPassphrase,
|
||||
clobApi = clobApi,
|
||||
feeRateByTokenId = feeRateByTokenId,
|
||||
signatureType = signatureType,
|
||||
tokenIds = tokenIds,
|
||||
marketTitle = marketTitle
|
||||
)
|
||||
periodContextCache[key] = ctx
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 按投入金额和价格计算可买张数:size = ceil(amountUsdc/price),保留小数,至少 1。
|
||||
* 与 OrderSigningService 一致使用小数数量,向上取整保证不超过投入金额。
|
||||
*/
|
||||
private fun computeSize(amountUsdc: BigDecimal, price: BigDecimal): String {
|
||||
val size = amountUsdc.divide(price, SIZE_DECIMAL_SCALE, RoundingMode.UP).max(BigDecimal.ONE)
|
||||
return size.toPlainString()
|
||||
}
|
||||
|
||||
private fun getOrInvalidatePeriodContext(strategy: CryptoTailStrategy, periodStartUnix: Long): PeriodContext? {
|
||||
val key = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val ctx = periodContextCache[key] ?: return null
|
||||
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
|
||||
periodContextCache.remove(key)
|
||||
return null
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 由订单簿 WebSocket 触发:当收到某 token 的 bestBid 且满足区间时调用,若本周期未触发则下单。
|
||||
*/
|
||||
suspend fun tryTriggerWithPriceFromWs(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
bestBid: BigDecimal
|
||||
) {
|
||||
if (outcomeIndex < 0 || outcomeIndex >= tokenIds.size) return
|
||||
if (bestBid < strategy.minPrice || bestBid > strategy.maxPrice) return
|
||||
|
||||
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
|
||||
mutex.withLock {
|
||||
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(strategy.id!!, periodStartUnix) != null) return@withLock
|
||||
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 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"
|
||||
logger.info(
|
||||
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
|
||||
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
|
||||
"direction=$direction, outcomeIndex=$outcomeIndex"
|
||||
)
|
||||
}
|
||||
if (!passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
||||
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
|
||||
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun passMinSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
||||
val mode = strategy.minSpreadMode.uppercase()
|
||||
if (mode == "NONE") return true
|
||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
|
||||
?: return false
|
||||
val (openP, closeP) = oc
|
||||
val spreadAbs = closeP.subtract(openP).abs()
|
||||
when (mode) {
|
||||
"FIXED" -> {
|
||||
val effectiveMinSpread = strategy.minSpreadValue?.takeIf { it > BigDecimal.ZERO }
|
||||
if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true
|
||||
return spreadAbs >= effectiveMinSpread
|
||||
}
|
||||
"AUTO" -> {
|
||||
val result = computeAutoEffectiveMinSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
||||
val effectiveMinSpread = result.effectiveMinSpread
|
||||
if (effectiveMinSpread <= BigDecimal.ZERO) return true
|
||||
return spreadAbs >= effectiveMinSpread
|
||||
}
|
||||
else -> return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效最小价差。
|
||||
*/
|
||||
private data class AutoSpreadResult(
|
||||
val baseSpread: BigDecimal,
|
||||
val coefficient: BigDecimal,
|
||||
val effectiveMinSpread: 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 }
|
||||
?: return null
|
||||
if (baseSpread <= BigDecimal.ZERO) return null
|
||||
val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L
|
||||
val windowEndMs = (periodStartUnix + strategy.windowEndSeconds) * 1000L
|
||||
val windowLenMs = windowEndMs - windowStartMs
|
||||
val coefficient = if (windowLenMs <= 0) {
|
||||
BigDecimal.ONE
|
||||
} else {
|
||||
val nowMs = System.currentTimeMillis()
|
||||
val elapsedMs = (nowMs - windowStartMs).toBigDecimal()
|
||||
val progress = elapsedMs.div(windowLenMs.toBigDecimal(), 18, RoundingMode.HALF_UP)
|
||||
.let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) }
|
||||
BigDecimal.ONE.subtract(progress.multi("0.5"))
|
||||
}
|
||||
val effectiveMinSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
|
||||
return AutoSpreadResult(baseSpread, coefficient, effectiveMinSpread)
|
||||
}
|
||||
|
||||
private suspend fun placeOrderForTrigger(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal
|
||||
) {
|
||||
val ctx = getOrInvalidatePeriodContext(strategy, periodStartUnix)
|
||||
|
||||
if (ctx != null) {
|
||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> {
|
||||
val balanceResult = accountService.getAccountBalance(ctx.account.id)
|
||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||
}
|
||||
else -> strategy.amountValue
|
||||
}
|
||||
if (amountUsdc < BigDecimal("1")) {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足")
|
||||
return
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
||||
return
|
||||
}
|
||||
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
val size = computeSize(amountUsdc, price)
|
||||
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = ctx.decryptedPrivateKey,
|
||||
makerAddress = ctx.account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
size = size,
|
||||
signatureType = ctx.signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = ctx.account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
||||
return
|
||||
}
|
||||
|
||||
placeOrderForTriggerSlowPath(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, triggerPrice)
|
||||
}
|
||||
|
||||
private suspend fun submitOrderAndSaveRecord(
|
||||
clobApi: PolymarketClobApi,
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderRequest: NewOrderRequest
|
||||
) {
|
||||
var failReason: String? = null
|
||||
try {
|
||||
val response = clobApi.createOrder(orderRequest)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.success && body.orderId != null) {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, body.orderId, "success", null)
|
||||
logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
||||
return
|
||||
}
|
||||
failReason = body.errorMsg ?: "unknown"
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
failReason = "HTTP ${response.code()} $errorBody"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failReason = e.message ?: e.toString()
|
||||
logger.error("尾盘策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
|
||||
}
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", failReason)
|
||||
logger.error("尾盘策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
|
||||
}
|
||||
|
||||
/** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */
|
||||
private suspend fun placeOrderForTriggerSlowPath(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal
|
||||
) {
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||
logger.warn("账户不存在: accountId=${strategy.accountId}")
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户不存在")
|
||||
return
|
||||
}
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.warn("账户未配置 API 凭证: accountId=${account.id}")
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户未配置API凭证")
|
||||
return
|
||||
}
|
||||
|
||||
val balanceResult = accountService.getAccountBalance(account.id)
|
||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||
else -> strategy.amountValue
|
||||
}
|
||||
if (amountUsdc < BigDecimal("1")) {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足")
|
||||
return
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
||||
return
|
||||
}
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
val size = computeSize(amountUsdc, price)
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: ""
|
||||
} catch (e: Exception) {
|
||||
logger.error("解密私钥失败: accountId=${account.id}", e)
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "解密私钥失败")
|
||||
return
|
||||
}
|
||||
val apiSecret = try {
|
||||
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
val apiPassphrase = try {
|
||||
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
size = size,
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
submitOrderAndSaveRecord(clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
||||
}
|
||||
|
||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.getEventBySlug(slug)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
val msg = if (response.code() == 404) "404" else "code=${response.code()}"
|
||||
Result.failure(Exception(msg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||
val parsed = clobTokenIds.fromJson<List<String>>()
|
||||
return parsed ?: emptyList()
|
||||
}
|
||||
|
||||
private fun saveTriggerRecord(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderId: String?,
|
||||
status: String,
|
||||
failReason: String?
|
||||
) {
|
||||
val record = CryptoTailStrategyTrigger(
|
||||
strategyId = strategy.id!!,
|
||||
periodStartUnix = periodStartUnix,
|
||||
marketTitle = marketTitle,
|
||||
outcomeIndex = outcomeIndex,
|
||||
triggerPrice = triggerPrice,
|
||||
amountUsdc = amountUsdc,
|
||||
orderId = orderId,
|
||||
status = status,
|
||||
failReason = failReason
|
||||
)
|
||||
triggerRepository.save(record)
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
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.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class CryptoTailStrategyService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyService::class.java)
|
||||
|
||||
private val maxWindowByInterval = mapOf(300 to 300, 900 to 900)
|
||||
|
||||
@Transactional
|
||||
fun create(request: CryptoTailStrategyCreateRequest): Result<CryptoTailStrategyDto> {
|
||||
return try {
|
||||
if (request.accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey))
|
||||
}
|
||||
if (request.marketSlugPrefix.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val interval = request.intervalSeconds
|
||||
if (interval != 300 && interval != 900) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey))
|
||||
}
|
||||
val maxWindow = maxWindowByInterval[interval] ?: 300
|
||||
if (request.windowStartSeconds > request.windowEndSeconds) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
|
||||
}
|
||||
if (request.windowEndSeconds > maxWindow) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
|
||||
}
|
||||
val amountMode = request.amountMode.uppercase()
|
||||
if (amountMode != "RATIO" && amountMode != "FIXED") {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey))
|
||||
}
|
||||
val minPrice = request.minPrice.toSafeBigDecimal()
|
||||
val maxPrice = (request.maxPrice ?: "1").toSafeBigDecimal()
|
||||
if (minPrice > maxPrice) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val amountValue = request.amountValue.toSafeBigDecimal()
|
||||
if (amountValue <= BigDecimal.ZERO) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val minSpreadMode = (request.minSpreadMode ?: "NONE").uppercase()
|
||||
if (minSpreadMode != "NONE" && minSpreadMode != "FIXED" && minSpreadMode != "AUTO") {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val minSpreadValue = request.minSpreadValue?.toSafeBigDecimal()
|
||||
if (minSpreadMode == "FIXED" && (minSpreadValue == null || minSpreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
|
||||
val nameToSave = request.name?.takeIf { it.isNotBlank() }
|
||||
?: generateStrategyName(request.marketSlugPrefix.trim())
|
||||
|
||||
val entity = CryptoTailStrategy(
|
||||
accountId = request.accountId,
|
||||
name = nameToSave,
|
||||
marketSlugPrefix = request.marketSlugPrefix.trim(),
|
||||
intervalSeconds = interval,
|
||||
windowStartSeconds = request.windowStartSeconds,
|
||||
windowEndSeconds = request.windowEndSeconds,
|
||||
minPrice = minPrice,
|
||||
maxPrice = maxPrice,
|
||||
amountMode = amountMode,
|
||||
amountValue = amountValue,
|
||||
minSpreadMode = minSpreadMode,
|
||||
minSpreadValue = minSpreadValue,
|
||||
enabled = request.enabled
|
||||
)
|
||||
val saved = strategyRepository.save(entity)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
Result.success(entityToDto(saved, null))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(request: CryptoTailStrategyUpdateRequest): Result<CryptoTailStrategyDto> {
|
||||
return try {
|
||||
val existing = strategyRepository.findById(request.strategyId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
val interval = existing.intervalSeconds
|
||||
val maxWindow = maxWindowByInterval[interval] ?: 300
|
||||
|
||||
request.windowStartSeconds?.let { ws ->
|
||||
request.windowEndSeconds?.let { we ->
|
||||
if (ws > we) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
|
||||
if (we > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
|
||||
}
|
||||
}
|
||||
request.windowStartSeconds?.let { if (it > (request.windowEndSeconds ?: existing.windowEndSeconds)) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey)) }
|
||||
request.windowEndSeconds?.let { if (it > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey)) }
|
||||
|
||||
val nameToSave = request.name?.takeIf { it.isNotBlank() }
|
||||
?: existing.name?.takeIf { it.isNotBlank() }
|
||||
?: generateStrategyName(existing.marketSlugPrefix)
|
||||
|
||||
val newMinSpreadMode = request.minSpreadMode?.uppercase() ?: existing.minSpreadMode
|
||||
if (newMinSpreadMode != "NONE" && newMinSpreadMode != "FIXED" && newMinSpreadMode != "AUTO") {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val newMinSpreadValue = request.minSpreadValue?.toSafeBigDecimal() ?: existing.minSpreadValue
|
||||
if (newMinSpreadMode == "FIXED" && (newMinSpreadValue == null || newMinSpreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
|
||||
val updated = existing.copy(
|
||||
name = nameToSave,
|
||||
windowStartSeconds = request.windowStartSeconds ?: existing.windowStartSeconds,
|
||||
windowEndSeconds = request.windowEndSeconds ?: existing.windowEndSeconds,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: existing.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
|
||||
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
|
||||
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
|
||||
minSpreadMode = newMinSpreadMode,
|
||||
minSpreadValue = newMinSpreadValue,
|
||||
enabled = request.enabled ?: existing.enabled,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
if (updated.minPrice > updated.maxPrice) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
request.amountMode?.uppercase()?.let { if (it != "RATIO" && it != "FIXED") return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey)) }
|
||||
val saved = strategyRepository.save(updated)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
val lastTrigger = triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(saved.id!!, PageRequest.of(0, 1))
|
||||
.content.firstOrNull()?.createdAt
|
||||
Result.success(entityToDto(saved, lastTrigger))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun delete(strategyId: Long): Result<Unit> {
|
||||
return try {
|
||||
if (!strategyRepository.existsById(strategyId)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
}
|
||||
strategyRepository.deleteById(strategyId)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun list(request: CryptoTailStrategyListRequest): Result<CryptoTailStrategyListResponse> {
|
||||
return try {
|
||||
val list = when {
|
||||
request.accountId != null && request.enabled != null -> strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
|
||||
request.accountId != null -> strategyRepository.findAllByAccountId(request.accountId)
|
||||
request.enabled == true -> strategyRepository.findAllByEnabledTrue()
|
||||
request.enabled == false -> strategyRepository.findAll().filter { !it.enabled }
|
||||
else -> strategyRepository.findAll()
|
||||
}
|
||||
val lastTriggerMap = list.map { it.id!! }.associateWith { id ->
|
||||
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(id, PageRequest.of(0, 1))
|
||||
.content.firstOrNull()?.createdAt
|
||||
}
|
||||
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
|
||||
Result.success(CryptoTailStrategyListResponse(list = dtos))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询尾盘策略列表失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
|
||||
return try {
|
||||
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
|
||||
val startTs = request.startDate ?: 0L
|
||||
val endTs = request.endDate ?: Long.MAX_VALUE
|
||||
val useTimeRange = request.startDate != null || request.endDate != null
|
||||
val pageResult = when {
|
||||
useTimeRange && request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(
|
||||
request.strategyId, request.status, startTs, endTs, page
|
||||
)
|
||||
useTimeRange ->
|
||||
triggerRepository.findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(
|
||||
request.strategyId, startTs, endTs, page
|
||||
)
|
||||
request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page)
|
||||
else ->
|
||||
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page)
|
||||
}
|
||||
val list = pageResult.content.map { triggerToDto(it) }
|
||||
val total = when {
|
||||
useTimeRange && request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.countByStrategyIdAndStatusAndCreatedAtBetween(request.strategyId, request.status, startTs, endTs)
|
||||
useTimeRange ->
|
||||
triggerRepository.countByStrategyIdAndCreatedAtBetween(request.strategyId, startTs, endTs)
|
||||
request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status)
|
||||
else ->
|
||||
pageResult.totalElements
|
||||
}
|
||||
Result.success(CryptoTailStrategyTriggerListResponse(list = list, total = total))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getStrategy(strategyId: Long): CryptoTailStrategy? = strategyRepository.findById(strategyId).orElse(null)
|
||||
|
||||
private fun generateStrategyName(marketSlugPrefix: String): String {
|
||||
val suffix = Instant.now().atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||
return "尾盘策略-${marketSlugPrefix}-$suffix"
|
||||
}
|
||||
|
||||
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto {
|
||||
val strategyId = e.id ?: 0L
|
||||
val totalPnl = triggerRepository.sumRealizedPnlByStrategyId(strategyId)
|
||||
val settledCount = triggerRepository.countResolvedByStrategyId(strategyId)
|
||||
val winCount = triggerRepository.countWinsByStrategyId(strategyId)
|
||||
val winRateStr = if (settledCount > 0L) {
|
||||
BigDecimal(winCount).divide(BigDecimal(settledCount), 4, java.math.RoundingMode.HALF_UP).toPlainString()
|
||||
} else null
|
||||
return CryptoTailStrategyDto(
|
||||
id = strategyId,
|
||||
accountId = e.accountId,
|
||||
name = e.name,
|
||||
marketSlugPrefix = e.marketSlugPrefix,
|
||||
marketTitle = null,
|
||||
intervalSeconds = e.intervalSeconds,
|
||||
windowStartSeconds = e.windowStartSeconds,
|
||||
windowEndSeconds = e.windowEndSeconds,
|
||||
minPrice = e.minPrice.toPlainString(),
|
||||
maxPrice = e.maxPrice.toPlainString(),
|
||||
amountMode = e.amountMode,
|
||||
amountValue = e.amountValue.toPlainString(),
|
||||
minSpreadMode = e.minSpreadMode,
|
||||
minSpreadValue = e.minSpreadValue?.toPlainString(),
|
||||
enabled = e.enabled,
|
||||
lastTriggerAt = lastTriggerAt,
|
||||
totalRealizedPnl = totalPnl?.toPlainString(),
|
||||
settledCount = settledCount,
|
||||
winCount = winCount,
|
||||
winRate = winRateStr,
|
||||
createdAt = e.createdAt,
|
||||
updatedAt = e.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
private fun triggerToDto(t: CryptoTailStrategyTrigger): CryptoTailStrategyTriggerDto = CryptoTailStrategyTriggerDto(
|
||||
id = t.id ?: 0L,
|
||||
strategyId = t.strategyId,
|
||||
periodStartUnix = t.periodStartUnix,
|
||||
marketTitle = t.marketTitle,
|
||||
outcomeIndex = t.outcomeIndex,
|
||||
triggerPrice = t.triggerPrice.toPlainString(),
|
||||
amountUsdc = t.amountUsdc.toPlainString(),
|
||||
orderId = t.orderId,
|
||||
status = t.status,
|
||||
failReason = t.failReason,
|
||||
resolved = t.resolved,
|
||||
realizedPnl = t.realizedPnl?.toPlainString(),
|
||||
winnerOutcomeIndex = t.winnerOutcomeIndex,
|
||||
settledAt = t.settledAt,
|
||||
createdAt = t.createdAt
|
||||
)
|
||||
}
|
||||
+75
@@ -15,6 +15,7 @@ import org.springframework.context.ApplicationContextAware
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.PolymarketActivityWsService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.UnifiedOnChainWsService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -76,6 +77,17 @@ class ApiHealthCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 BinanceKlineService(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getBinanceKlineService(): BinanceKlineService? {
|
||||
return try {
|
||||
applicationContext?.getBean(BinanceKlineService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
|
||||
|
||||
/**
|
||||
@@ -91,6 +103,8 @@ class ApiHealthCheckService(
|
||||
async { checkDataApi() },
|
||||
async { checkGammaApi() },
|
||||
async { checkPolygonRpc() },
|
||||
async { checkBinanceApi() },
|
||||
async { checkBinanceWebSocket() },
|
||||
async { checkPolymarketRtdsWebSocket() },
|
||||
async { checkPolymarketActivityWebSocket() },
|
||||
async { checkUnifiedOnChainWebSocket() },
|
||||
@@ -197,6 +211,67 @@ class ApiHealthCheckService(
|
||||
checkJsonRpcApi("Polygon RPC", rpcUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查币安 API(用于 K 线等)
|
||||
* 使用 /api/v3/ping 端点
|
||||
*/
|
||||
private suspend fun checkBinanceApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val url = "https://api.binance.com/api/v3/ping"
|
||||
checkApi("币安 API", url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查币安 K 线 WebSocket 连接状态(5m / 15m)
|
||||
*/
|
||||
private suspend fun checkBinanceWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
val binanceWsUrl = "wss://stream.binance.com:9443"
|
||||
try {
|
||||
val binanceKlineService = getBinanceKlineService()
|
||||
if (binanceKlineService == null) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "服务未初始化"
|
||||
)
|
||||
}
|
||||
val statuses = binanceKlineService.getConnectionStatuses()
|
||||
val total = statuses.size
|
||||
val connected = statuses.values.count { it }
|
||||
if (connected == total && total > 0) {
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "success",
|
||||
message = "连接正常 (5m、15m)"
|
||||
)
|
||||
} else if (connected > 0) {
|
||||
val which = statuses.filter { it.value }.keys.joinToString("、")
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "部分连接正常 ($which)"
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "连接断开"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查币安 WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket RTDS WebSocket 连接状态
|
||||
* 用于订单推送服务
|
||||
|
||||
+259
-12
@@ -4,6 +4,7 @@ import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
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.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -40,6 +41,15 @@ class RelayClientService(
|
||||
// 空集合ID
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
// Polygon PROXY(Magic)合约地址,参考 builder-relayer-client config
|
||||
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
||||
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
|
||||
private val defaultProxyGasLimit = "10000000"
|
||||
|
||||
// Builder Relayer API 交易类型常量
|
||||
private val RELAYER_TYPE_PROXY = "PROXY"
|
||||
private val RELAYER_TYPE_SAFE = "SAFE"
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
||||
@@ -201,33 +211,45 @@ class RelayClientService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Safe 交易(通过 Proxy.execTransaction)
|
||||
* 执行代理交易(Safe 或 Magic PROXY)
|
||||
* 参考 TypeScript: RelayClient.execute()
|
||||
*
|
||||
* 优先使用 Builder Relayer(Gasless),如果未配置则回退到手动发送交易
|
||||
*
|
||||
* @param privateKey 私钥
|
||||
* @param proxyAddress 代理钱包地址
|
||||
* @param safeTx Safe 交易对象
|
||||
* @param safeTx 交易对象(to/data/value)
|
||||
* @param walletType 钱包类型:MAGIC 使用 PROXY Gasless,SAFE 使用 Safe 流程
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun execute(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
safeTx: SafeTransaction
|
||||
safeTx: SafeTransaction,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 验证参数
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 检查 Builder API Key 是否已配置
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
// 优先使用 Builder Relayer(Gasless)
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API Key(Gasless)"))
|
||||
}
|
||||
logger.info("使用 Builder Relayer PROXY 执行 Magic 赎回")
|
||||
return executeViaBuilderRelayerProxy(
|
||||
privateKey,
|
||||
proxyAddress,
|
||||
safeTx,
|
||||
builderApiKey!!,
|
||||
builderSecret!!,
|
||||
builderPassphrase!!
|
||||
)
|
||||
}
|
||||
|
||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
||||
return executeViaBuilderRelayer(
|
||||
@@ -240,15 +262,240 @@ class RelayClientService(
|
||||
)
|
||||
}
|
||||
|
||||
// 回退到手动发送交易(需要用户支付 gas)
|
||||
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
|
||||
return executeManually(privateKey, proxyAddress, safeTx)
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行 Safe 交易失败: ${e.message}", e)
|
||||
logger.error("执行交易失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 执行 PROXY(Magic)交易(Gasless)
|
||||
* 参考: builder-relayer-client client.ts executeProxyTransactions, builder/proxy.ts
|
||||
*/
|
||||
private suspend fun executeViaBuilderRelayerProxy(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
safeTx: SafeTransaction,
|
||||
builderApiKey: String,
|
||||
builderSecret: String,
|
||||
builderPassphrase: String
|
||||
): Result<String> {
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey,
|
||||
secret = builderSecret,
|
||||
passphrase = builderPassphrase
|
||||
)
|
||||
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
|
||||
val fromAddress = credentials.address
|
||||
|
||||
val relayPayloadResponse = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY)
|
||||
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
|
||||
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
|
||||
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
|
||||
}
|
||||
val relayPayload = relayPayloadResponse.body()!!
|
||||
val relayAddress = relayPayload.address
|
||||
val nonce = relayPayload.nonce
|
||||
|
||||
val proxyCallData = encodeProxyTransactionData(safeTx)
|
||||
|
||||
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit)
|
||||
val gasLimit = try {
|
||||
estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
|
||||
defaultProxyGasLimit
|
||||
}
|
||||
|
||||
val structHash = createProxyStructHash(
|
||||
from = fromAddress,
|
||||
to = proxyFactoryAddress,
|
||||
data = proxyCallData,
|
||||
txFee = "0",
|
||||
gasPrice = "0",
|
||||
gasLimit = gasLimit,
|
||||
nonce = nonce,
|
||||
relayHubAddress = relayHubAddress,
|
||||
relayAddress = relayAddress
|
||||
)
|
||||
|
||||
val prefix = "\u0019Ethereum Signed Message:\n32".toByteArray(Charsets.UTF_8)
|
||||
val messageWithPrefix = ByteArray(prefix.size + structHash.size)
|
||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||
System.arraycopy(structHash, 0, messageWithPrefix, prefix.size, structHash.size)
|
||||
|
||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
val sigHex = "0x" + org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') +
|
||||
org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') +
|
||||
String.format("%02x", (signature.v as ByteArray).getOrElse(0) { 0 }.toInt() and 0xff)
|
||||
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = RELAYER_TYPE_PROXY,
|
||||
from = fromAddress,
|
||||
to = proxyFactoryAddress,
|
||||
proxyWallet = proxyAddress,
|
||||
data = proxyCallData,
|
||||
nonce = nonce,
|
||||
signature = sigHex,
|
||||
signatureParams = BuilderRelayerApi.SignatureParams(
|
||||
gasPrice = "0",
|
||||
gasLimit = gasLimit,
|
||||
relayerFee = "0",
|
||||
relayHub = relayHubAddress,
|
||||
relay = relayAddress
|
||||
),
|
||||
metadata = "Redeem positions via Builder Relayer PROXY"
|
||||
)
|
||||
|
||||
val response = relayerApi.submitTransaction(request)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
|
||||
val relayerResponse = response.body()!!
|
||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
logger.info("Builder Relayer PROXY 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
|
||||
return Result.success(txHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 ProxyFactory.proxy(calls) 调用数据
|
||||
* 参考: builder-relayer-client encode/proxy.ts, abis proxyFactory proxy((uint8,address,uint256,bytes)[])
|
||||
*
|
||||
* ABI 编码规则:当 tuple 数组中的 tuple 包含动态类型(bytes)时,需要先存储 tuple offset
|
||||
* 结构:
|
||||
* - selector (4 bytes)
|
||||
* - array offset (32 bytes) = 32
|
||||
* - array length (32 bytes) = 1
|
||||
* - tuple[0] offset (32 bytes) = 32 (指向 tuple 数据开始,从 array length 之后计算)
|
||||
* - tuple[0] 数据:
|
||||
* - typeCode (32 bytes) = 1
|
||||
* - to (32 bytes)
|
||||
* - value (32 bytes) = 0
|
||||
* - data offset (32 bytes) = 128 (从 tuple 数据开始计算)
|
||||
* - data length (32 bytes)
|
||||
* - data (padded to 32-byte boundary)
|
||||
*/
|
||||
private fun encodeProxyTransactionData(safeTx: SafeTransaction): String {
|
||||
val selector = EthereumUtils.getFunctionSelector("proxy((uint8,address,uint256,bytes)[])")
|
||||
val callData = safeTx.data.removePrefix("0x")
|
||||
val dataLen = callData.length / 2
|
||||
val dataLenPadded = (dataLen + 31) / 32 * 32 * 2
|
||||
val dataPadded = callData.padEnd(dataLenPadded, '0')
|
||||
|
||||
// ABI 编码:tuple 数组,tuple 包含动态类型 bytes
|
||||
// 1. array offset: 32 (指向 array length)
|
||||
val arrayOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
// 2. array length: 1
|
||||
val arrayLength = EthereumUtils.encodeUint256(BigInteger.ONE)
|
||||
// 3. tuple[0] offset: 32 (指向 tuple 数据开始,从 array length 之后计算)
|
||||
val tupleOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
// 4. tuple[0] 数据:
|
||||
// - typeCode: 1
|
||||
val typeCode = EthereumUtils.encodeUint256(BigInteger.ONE)
|
||||
// - to: address
|
||||
val toEncoded = EthereumUtils.encodeAddress(safeTx.to)
|
||||
// - value: 0
|
||||
val valueEncoded = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
||||
// - data offset: 128 (从 tuple 数据开始计算,typeCode+to+value = 3*32 = 96,加上 offset 字段 = 128)
|
||||
val dataOffsetInTuple = BigInteger.valueOf(128)
|
||||
val dataOffsetEncoded = EthereumUtils.encodeUint256(dataOffsetInTuple)
|
||||
// - data length
|
||||
val dataLengthEncoded = EthereumUtils.encodeUint256(BigInteger.valueOf(dataLen.toLong()))
|
||||
// - data (padded)
|
||||
|
||||
return "0x" + selector.removePrefix("0x") + arrayOffset + arrayLength +
|
||||
tupleOffset + typeCode + toEncoded + valueEncoded + dataOffsetEncoded +
|
||||
dataLengthEncoded + dataPadded
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算 PROXY 交易的 gas limit
|
||||
* 参考: builder-relayer-client builder/proxy.ts getGasLimit
|
||||
*/
|
||||
private suspend fun estimateProxyGasLimit(
|
||||
from: String,
|
||||
to: String,
|
||||
data: String
|
||||
): String {
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_estimateGas",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"from" to from,
|
||||
"to" to to,
|
||||
"data" to data
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("eth_estimateGas 调用失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
throw Exception("eth_estimateGas 返回错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexGasLimit = rpcResponse.result?.asString
|
||||
?: throw Exception("eth_estimateGas 结果为空")
|
||||
|
||||
// 将十六进制转换为十进制字符串
|
||||
val gasLimitBigInt = BigInteger(hexGasLimit.removePrefix("0x"), 16)
|
||||
return gasLimitBigInt.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 PROXY 结构哈希,参考 builder-relayer-client builder/proxy.ts createStructHash
|
||||
* concat: "rlx:" + from + to + data + txFee + gasPrice + gasLimit + nonce + relayHub + relay, then keccak256
|
||||
*/
|
||||
private fun createProxyStructHash(
|
||||
from: String,
|
||||
to: String,
|
||||
data: String,
|
||||
txFee: String,
|
||||
gasPrice: String,
|
||||
gasLimit: String,
|
||||
nonce: String,
|
||||
relayHubAddress: String,
|
||||
relayAddress: String
|
||||
): ByteArray {
|
||||
val rlxPrefix = "rlx:".toByteArray(Charsets.UTF_8)
|
||||
val fromBytes = EthereumUtils.hexToBytes(from.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val toBytes = EthereumUtils.hexToBytes(to.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val dataBytes = EthereumUtils.hexToBytes(data.removePrefix("0x"))
|
||||
val txFeeBytes = EthereumUtils.encodeUint256(BigInteger(txFee)).let { EthereumUtils.hexToBytes(it) }
|
||||
val gasPriceBytes = EthereumUtils.encodeUint256(BigInteger(gasPrice)).let { EthereumUtils.hexToBytes(it) }
|
||||
val gasLimitBytes = EthereumUtils.encodeUint256(BigInteger(gasLimit)).let { EthereumUtils.hexToBytes(it) }
|
||||
val nonceBytes = EthereumUtils.encodeUint256(BigInteger(nonce)).let { EthereumUtils.hexToBytes(it) }
|
||||
val relayHubBytes = EthereumUtils.hexToBytes(relayHubAddress.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val relayBytes = EthereumUtils.hexToBytes(relayAddress.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
|
||||
val concat = rlxPrefix + fromBytes + toBytes + dataBytes + txFeeBytes + gasPriceBytes +
|
||||
gasLimitBytes + nonceBytes + relayHubBytes + relayBytes
|
||||
return EthereumUtils.keccak256(concat)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 执行交易(Gasless)
|
||||
* 参考: builder-relayer-client/src/client.ts 的 execute 方法
|
||||
@@ -278,7 +525,7 @@ class RelayClientService(
|
||||
val redeemCallData = safeTx.data
|
||||
|
||||
// 获取 Proxy 的 nonce(通过 Builder Relayer API)
|
||||
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
|
||||
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE)
|
||||
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
|
||||
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
|
||||
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
|
||||
@@ -345,7 +592,7 @@ class RelayClientService(
|
||||
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts)
|
||||
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = "SAFE",
|
||||
type = RELAYER_TYPE_SAFE,
|
||||
from = fromAddress,
|
||||
to = safeTx.to,
|
||||
proxyWallet = proxyAddress,
|
||||
|
||||
+142
@@ -303,6 +303,69 @@ class TelegramNotificationService(
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送尾盘策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配尾盘订单后调用)
|
||||
*/
|
||||
suspend fun sendCryptoTailOrderSuccessNotification(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String? = null,
|
||||
marketSlug: String? = null,
|
||||
side: String,
|
||||
outcome: String? = null,
|
||||
price: String,
|
||||
size: String,
|
||||
strategyName: String? = null,
|
||||
accountName: String? = null,
|
||||
walletAddress: String? = null,
|
||||
locale: java.util.Locale? = null,
|
||||
orderTime: Long? = null
|
||||
) {
|
||||
if (orderId != null) {
|
||||
val lastSentTime = sentOrderIds[orderId]
|
||||
if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
|
||||
logger.info("尾盘订单通知已发送过(5分钟内),跳过: orderId=$orderId")
|
||||
return
|
||||
}
|
||||
sentOrderIds[orderId] = System.currentTimeMillis()
|
||||
if (sentOrderIds.size > 1000) {
|
||||
val expiryTime = System.currentTimeMillis() - 5 * 60 * 1000
|
||||
sentOrderIds.entries.removeIf { it.value < expiryTime }
|
||||
}
|
||||
}
|
||||
val currentLocale = locale ?: try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
|
||||
java.util.Locale("zh", "CN")
|
||||
}
|
||||
val amount = try {
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
val sizeDecimal = size.toSafeBigDecimal()
|
||||
priceDecimal.multiply(sizeDecimal).toString()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
val message = buildCryptoTailOrderSuccessMessage(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = side,
|
||||
outcome = outcome,
|
||||
price = price,
|
||||
size = size,
|
||||
amount = amount,
|
||||
strategyName = strategyName,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
locale = currentLocale,
|
||||
orderTime = orderTime
|
||||
)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单被过滤消息
|
||||
*/
|
||||
@@ -827,6 +890,85 @@ class TelegramNotificationService(
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建尾盘策略下单成功消息(与订单成功格式一致,增加「尾盘策略」标题与策略名)
|
||||
*/
|
||||
private fun buildCryptoTailOrderSuccessMessage(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
strategyName: String?,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
orderTime: Long?
|
||||
): String {
|
||||
val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "尾盘策略下单成功", locale)
|
||||
val strategyLabel = messageSource.getMessage("notification.tail.strategy", null, "策略", locale)
|
||||
val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale)
|
||||
val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale)
|
||||
val marketLabel = messageSource.getMessage("notification.order.market", null, "市场", locale)
|
||||
val sideLabel = messageSource.getMessage("notification.order.side", null, "方向", locale)
|
||||
val outcomeLabel = messageSource.getMessage("notification.order.outcome", null, "市场方向", locale)
|
||||
val priceLabel = messageSource.getMessage("notification.order.price", null, "价格", locale)
|
||||
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
|
||||
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
||||
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val unknown: String = messageSource.getMessage("common.unknown", null, "未知", locale) ?: "未知"
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale)
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale)
|
||||
else -> side
|
||||
}
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">")
|
||||
val strategyDisplay = strategyName?.takeIf { it.isNotBlank() } ?: unknown
|
||||
val escapedStrategyName = strategyDisplay.replace("<", "<").replace(">", ">")
|
||||
val amountDisplay = if (amount != null) {
|
||||
try {
|
||||
val amountDecimal = amount.toSafeBigDecimal()
|
||||
val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) { amount }
|
||||
} else calculateFailed
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> null
|
||||
}
|
||||
val marketDisplay = if (marketLink != null) "<a href=\"$marketLink\">$escapedMarketTitle</a>" else escapedMarketTitle
|
||||
val outcomeDisplay = if (!outcome.isNullOrBlank()) {
|
||||
val escapedOutcome = outcome.replace("<", "<").replace(">", ">")
|
||||
"\n• $outcomeLabel: <b>$escapedOutcome</b>"
|
||||
} else ""
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
return """🚀 <b>$tailOrderSuccess</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
||||
• $strategyLabel: $escapedStrategyName
|
||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.BinanceApi
|
||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.GitHubApi
|
||||
@@ -300,7 +301,18 @@ class RetrofitFactory(
|
||||
fun createDataApi(): PolymarketDataApi {
|
||||
return dataApi
|
||||
}
|
||||
|
||||
|
||||
private val binanceApi: BinanceApi by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl("https://api.binance.com/")
|
||||
.client(sharedOkHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(BinanceApi::class.java)
|
||||
}
|
||||
|
||||
fun createBinanceApi(): BinanceApi = binanceApi
|
||||
|
||||
/**
|
||||
* 创建 Builder Relayer API 客户端
|
||||
* 按 relayerUrl 缓存,避免重复创建
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- ============================================
|
||||
-- V32: 添加回测价格区间过滤字段
|
||||
-- 用于配置价格区间,仅在指定价格区间内的订单才会跟单
|
||||
-- ============================================
|
||||
|
||||
-- 添加价格区间字段到回测任务表
|
||||
ALTER TABLE backtest_task
|
||||
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
|
||||
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
-- ============================================
|
||||
-- V33: 唯一约束从 wallet_address 改为 proxy_address
|
||||
-- 允许同一 EOA 以不同代理类型(Magic/Safe)各导入一个账户,按代理地址去重
|
||||
-- ============================================
|
||||
|
||||
-- 将已存在账户的 wallet_type 统一为 safe(历史数据兼容)
|
||||
UPDATE wallet_accounts SET wallet_type = 'safe';
|
||||
|
||||
-- 删除 wallet_address 上的唯一约束(通过 KEY_COLUMN_USAGE 定位到该列的约束名)
|
||||
SET @uk_name = (SELECT kcu.CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
||||
ON kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA AND kcu.TABLE_NAME = tc.TABLE_NAME AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
|
||||
WHERE kcu.TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.TABLE_NAME = 'wallet_accounts'
|
||||
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND kcu.COLUMN_NAME = 'wallet_address'
|
||||
LIMIT 1);
|
||||
SET @sql = IF(@uk_name IS NOT NULL,
|
||||
CONCAT('ALTER TABLE wallet_accounts DROP INDEX ', @uk_name),
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为 proxy_address 添加唯一约束(若已存在则跳过)
|
||||
SET @uk_exists = (SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'wallet_accounts'
|
||||
AND CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND CONSTRAINT_NAME = 'uk_wallet_accounts_proxy_address'
|
||||
LIMIT 1);
|
||||
SET @sql2 = IF(@uk_exists IS NULL,
|
||||
'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_proxy_address (proxy_address)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt2 FROM @sql2;
|
||||
EXECUTE stmt2;
|
||||
DEALLOCATE PREPARE stmt2;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- ============================================
|
||||
-- V34: 加密市场尾盘策略表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS crypto_tail_strategy (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '策略ID',
|
||||
account_id BIGINT NOT NULL COMMENT '钱包账户ID',
|
||||
name VARCHAR(255) DEFAULT NULL COMMENT '策略名称(可选,用于列表展示)',
|
||||
market_slug_prefix VARCHAR(64) NOT NULL COMMENT '市场 slug 前缀,如 btc-updown-5m、btc-updown-15m',
|
||||
interval_seconds INT NOT NULL COMMENT '周期长度秒数:300(5分钟) 或 900(15分钟)',
|
||||
window_start_seconds INT NOT NULL COMMENT '时间窗口开始秒数(相对周期起点)',
|
||||
window_end_seconds INT NOT NULL COMMENT '时间窗口结束秒数(相对周期起点)',
|
||||
min_price DECIMAL(20, 8) NOT NULL COMMENT '最低触发价格 0~1',
|
||||
max_price DECIMAL(20, 8) NOT NULL DEFAULT 1 COMMENT '最高触发价格 0~1,默认1',
|
||||
amount_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '投入方式: RATIO=按比例, FIXED=固定金额',
|
||||
amount_value DECIMAL(20, 8) NOT NULL COMMENT '比例(0~100)或固定USDC金额',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用: 0=停用, 1=启用',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间',
|
||||
INDEX idx_account_id (account_id),
|
||||
INDEX idx_enabled (enabled),
|
||||
FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='加密市场尾盘策略表';
|
||||
|
||||
-- ============================================
|
||||
-- 触发记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS crypto_tail_strategy_trigger (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
|
||||
strategy_id BIGINT NOT NULL COMMENT '策略ID',
|
||||
period_start_unix BIGINT NOT NULL COMMENT '周期起点 Unix 秒',
|
||||
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
|
||||
outcome_index INT NOT NULL COMMENT '方向: 0=Up, 1=Down',
|
||||
trigger_price DECIMAL(20, 8) NOT NULL COMMENT '触发时价格',
|
||||
amount_usdc DECIMAL(20, 8) NOT NULL COMMENT '投入金额 USDC',
|
||||
order_id VARCHAR(128) DEFAULT NULL COMMENT '订单ID(成功时有值)',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态: success, fail',
|
||||
fail_reason VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
INDEX idx_strategy_id (strategy_id),
|
||||
INDEX idx_period (strategy_id, period_start_unix),
|
||||
INDEX idx_created_at (created_at),
|
||||
FOREIGN KEY (strategy_id) REFERENCES crypto_tail_strategy(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='尾盘策略触发记录表';
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- ============================================
|
||||
-- V35: 尾盘策略触发记录 - 结算与收益字段
|
||||
-- 用于轮询服务:扫描 success 但未结算的订单,查链上结算结果并回写收益
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE crypto_tail_strategy_trigger
|
||||
ADD COLUMN condition_id VARCHAR(66) DEFAULT NULL COMMENT '市场 conditionId(用于查链上结算)' AFTER order_id,
|
||||
ADD COLUMN resolved TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已结算: 0=未结算, 1=已结算',
|
||||
ADD COLUMN winner_outcome_index INT DEFAULT NULL COMMENT '市场赢家 outcome 索引(结算后写入)',
|
||||
ADD COLUMN realized_pnl DECIMAL(20, 8) DEFAULT NULL COMMENT '已实现盈亏 USDC(赢为正,输为负)',
|
||||
ADD COLUMN settled_at BIGINT DEFAULT NULL COMMENT '结算时间(毫秒时间戳)';
|
||||
|
||||
CREATE INDEX idx_trigger_settlement ON crypto_tail_strategy_trigger (status, resolved);
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- ============================================
|
||||
-- V36: 尾盘策略触发记录 - TG 通知已发标记(与跟单轮询发 TG 一致)
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE crypto_tail_strategy_trigger
|
||||
ADD COLUMN notification_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已发送 TG 通知: 0=未发送, 1=已发送';
|
||||
|
||||
CREATE INDEX idx_trigger_notification ON crypto_tail_strategy_trigger (status, notification_sent);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 尾盘策略最小价差:NONE=不校验, FIXED=固定值, AUTO=历史计算
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
ADD COLUMN min_spread_mode VARCHAR(16) NOT NULL DEFAULT 'NONE' COMMENT '最小价差模式: NONE, FIXED, AUTO',
|
||||
ADD COLUMN min_spread_value DECIMAL(20, 8) NULL COMMENT '最小价差数值(FIXED 时必填;AUTO 时可存计算值)';
|
||||
@@ -16,6 +16,8 @@ notification.order.time=Time
|
||||
notification.order.error_info=Error Information
|
||||
notification.order.unknown_account=Unknown Account
|
||||
notification.order.calculate_failed=Calculation Failed
|
||||
notification.tail.order.success=Tail Session Order Success
|
||||
notification.tail.strategy=Strategy
|
||||
notification.redeem.success=Position Redeemed Successfully
|
||||
notification.redeem.info=Redeem Information
|
||||
notification.redeem.transaction_hash=Transaction Hash
|
||||
@@ -274,6 +276,18 @@ error.server.backtest_historical_data_fetch_failed=Failed to fetch historical da
|
||||
error.server.backtest_stop_failed=Failed to stop backtest task
|
||||
error.server.backtest_retry_failed=Failed to retry backtest task
|
||||
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
|
||||
|
||||
# Crypto tail strategy
|
||||
error.crypto_tail_strategy_not_found=Crypto tail strategy not found
|
||||
error.crypto_tail_strategy_window_invalid=Window start must not be greater than window end
|
||||
error.crypto_tail_strategy_window_exceed=Time window must not exceed period length
|
||||
error.crypto_tail_strategy_interval_invalid=Interval must be 300 or 900 seconds
|
||||
error.crypto_tail_strategy_amount_mode_invalid=Amount mode must be RATIO or FIXED
|
||||
error.server.crypto_tail_strategy_create_failed=Failed to create crypto tail strategy
|
||||
error.server.crypto_tail_strategy_update_failed=Failed to update crypto tail strategy
|
||||
error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto tail strategy
|
||||
error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto tail strategy list
|
||||
error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
|
||||
# Backtest Management
|
||||
backtest.title=Backtest Management
|
||||
backtest.create_task=Create Backtest
|
||||
|
||||
@@ -16,6 +16,8 @@ notification.order.time=时间
|
||||
notification.order.error_info=错误信息
|
||||
notification.order.unknown_account=未知账户
|
||||
notification.order.calculate_failed=计算失败
|
||||
notification.tail.order.success=尾盘策略下单成功
|
||||
notification.tail.strategy=策略
|
||||
notification.redeem.success=仓位赎回成功
|
||||
notification.redeem.info=赎回信息
|
||||
notification.redeem.transaction_hash=交易哈希
|
||||
@@ -274,6 +276,18 @@ error.server.backtest_historical_data_fetch_failed=历史数据获取失败
|
||||
error.server.backtest_stop_failed=停止回测任务失败
|
||||
error.server.backtest_retry_failed=重试回测任务失败
|
||||
error.server.backtest_rerun_failed=按配置重新测试失败
|
||||
|
||||
# 尾盘策略
|
||||
error.crypto_tail_strategy_not_found=尾盘策略不存在
|
||||
error.crypto_tail_strategy_window_invalid=时间区间开始不能大于结束
|
||||
error.crypto_tail_strategy_window_exceed=时间区间不能超过周期长度
|
||||
error.crypto_tail_strategy_interval_invalid=周期仅支持 300 或 900 秒
|
||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式仅支持 RATIO 或 FIXED
|
||||
error.server.crypto_tail_strategy_create_failed=创建尾盘策略失败
|
||||
error.server.crypto_tail_strategy_update_failed=更新尾盘策略失败
|
||||
error.server.crypto_tail_strategy_delete_failed=删除尾盘策略失败
|
||||
error.server.crypto_tail_strategy_list_fetch_failed=查询尾盘策略列表失败
|
||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败
|
||||
# 回测管理
|
||||
backtest.title=回测管理
|
||||
backtest.create_task=新增回测
|
||||
|
||||
@@ -16,6 +16,8 @@ notification.order.time=時間
|
||||
notification.order.error_info=錯誤信息
|
||||
notification.order.unknown_account=未知賬戶
|
||||
notification.order.calculate_failed=計算失敗
|
||||
notification.tail.order.success=尾盤策略下單成功
|
||||
notification.tail.strategy=策略
|
||||
notification.redeem.success=倉位贖回成功
|
||||
notification.redeem.info=贖回信息
|
||||
notification.redeem.transaction_hash=交易哈希
|
||||
@@ -274,6 +276,18 @@ error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗
|
||||
error.server.backtest_stop_failed=停止回測任務失敗
|
||||
error.server.backtest_retry_failed=重試回測任務失敗
|
||||
error.server.backtest_rerun_failed=依配置重新測試失敗
|
||||
|
||||
# 尾盤策略
|
||||
error.crypto_tail_strategy_not_found=尾盤策略不存在
|
||||
error.crypto_tail_strategy_window_invalid=時間區間開始不能大於結束
|
||||
error.crypto_tail_strategy_window_exceed=時間區間不能超過週期長度
|
||||
error.crypto_tail_strategy_interval_invalid=週期僅支援 300 或 900 秒
|
||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式僅支援 RATIO 或 FIXED
|
||||
error.server.crypto_tail_strategy_create_failed=創建尾盤策略失敗
|
||||
error.server.crypto_tail_strategy_update_failed=更新尾盤策略失敗
|
||||
error.server.crypto_tail_strategy_delete_failed=刪除尾盤策略失敗
|
||||
error.server.crypto_tail_strategy_list_fetch_failed=查詢尾盤策略列表失敗
|
||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
|
||||
# 回測管理
|
||||
backtest.title=回測管理
|
||||
backtest.create_task=新增回測
|
||||
|
||||
@@ -161,10 +161,15 @@ deploy() {
|
||||
# 注意:这里需要手动修改 docker-compose.yml,或者使用环境变量
|
||||
warn "请确保 docker-compose.yml 中已配置使用 image: wrbug/polyhermes:latest"
|
||||
else
|
||||
# 获取当前分支名作为版本号
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
# 如果分支名包含 /,替换为 -(Docker tag 不支持 /)
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
# 版本号:优先环境变量 DOCKER_VERSION,其次 .env 中的 DOCKER_VERSION,否则用当前分支名
|
||||
if [ -z "${DOCKER_VERSION}" ] && [ -f ".env" ]; then
|
||||
DOCKER_VERSION=$(grep "^DOCKER_VERSION=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r')
|
||||
fi
|
||||
if [ -z "${DOCKER_VERSION}" ]; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
fi
|
||||
export DOCKER_VERSION
|
||||
|
||||
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION})..."
|
||||
|
||||
@@ -216,9 +221,14 @@ main() {
|
||||
info "访问地址: http://localhost:${SERVER_PORT:-80}"
|
||||
echo ""
|
||||
if [ "$USE_DOCKER_HUB" != "true" ]; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
info "提示:本地构建的版本号为当前分支名: ${DOCKER_VERSION}"
|
||||
if [ -z "${DOCKER_VERSION}" ] && [ -f ".env" ]; then
|
||||
DOCKER_VERSION=$(grep "^DOCKER_VERSION=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r')
|
||||
fi
|
||||
if [ -z "${DOCKER_VERSION}" ]; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
fi
|
||||
info "提示:本地构建的版本号: ${DOCKER_VERSION}(可在 .env 或环境变量中设置 DOCKER_VERSION)"
|
||||
info "生产环境推荐使用 Docker Hub 镜像:"
|
||||
info " ./deploy.sh --use-docker-hub"
|
||||
info " 或修改 docker-compose.yml 使用 image: wrbug/polyhermes:latest"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# AUTO 最小价差:100%→50% 动态系数方案
|
||||
|
||||
## 现状
|
||||
|
||||
- **BinanceKlineAutoSpreadService**:拉取历史 K 线 → IQR 剔除异常值 → 求平均得到「基础价差」→ **固定 ×0.7** 后缓存。
|
||||
- 预加载(周期开始时):`computeAndCache()` 计算并缓存的是 **已乘 0.7** 的值。
|
||||
- 触发时:`getAutoMinSpread()` 直接返回缓存值,等价于始终用 **70%** 的系数。
|
||||
|
||||
问题:70% 固定,无法随周期内时间变化放宽或收紧。
|
||||
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
1. **预加载提供 100% 数值**:缓存里存「基础价差」(IQR 平均),不再乘 0.7,即预加载 = 100% 基准。
|
||||
2. **系数随区间时间点动态递减**:从 **100%** 线性递减到 **50%**,根据「当前时间在区间内的进度」计算。
|
||||
|
||||
---
|
||||
|
||||
## 方案一:按「触发窗口」进度(推荐)
|
||||
|
||||
**区间**:策略的触发窗口 `[periodStartUnix + windowStartSeconds, periodStartUnix + windowEndSeconds]`。
|
||||
|
||||
- 窗口起始:系数 = **100%**(最严,价差要求最高)。
|
||||
- 窗口内时间越靠后,系数越小;窗口结束:系数 = **50%**(最松,更容易触发)。
|
||||
|
||||
公式(**progress 按毫秒计算**,保证精度):
|
||||
|
||||
```
|
||||
windowStartMs = (periodStartUnix + windowStartSeconds) * 1000
|
||||
windowEndMs = (periodStartUnix + windowEndSeconds) * 1000
|
||||
windowLenMs = windowEndMs - windowStartMs
|
||||
nowMs = System.currentTimeMillis()
|
||||
|
||||
progress = (nowMs - windowStartMs) / windowLenMs
|
||||
progress = clamp(progress, 0, 1)
|
||||
|
||||
// 比例系数 = progress × (100% - 50%),即已「消耗」的系数降幅
|
||||
// 真正系数 = 100% - 比例系数
|
||||
coefficient = 1.0 - progress × (1.0 - 0.5) = 1.0 - 0.5 × progress
|
||||
|
||||
effectiveMinSpread = baseSpread × coefficient
|
||||
```
|
||||
|
||||
**计算示例**(时间区间 14分0秒~15分0秒,窗口 60 秒 = 60000 ms):
|
||||
|
||||
| 时刻 | 进入窗口的毫秒数 | progress(按毫秒) | 比例系数 | 真正系数 |
|
||||
|------------|------------------|--------------------|--------------------|------------|
|
||||
| 14:00 | 0 | 0/60000 = 0% | 0% × 50% = 0% | 100% |
|
||||
| 14:15 | 15000 | 15000/60000 = 25% | 25% × 50% = 12.5% | **87.5%** |
|
||||
| 14:30 | 30000 | 30000/60000 = 50% | 50% × 50% = 25% | 75% |
|
||||
| 15:00 | 60000 | 60000/60000 = 100% | 100% × 50% = 50% | 50% |
|
||||
|
||||
即:在 14分15秒 时,progress = 15000ms / 60000ms = 25%,比例系数 = 12.5%,真正系数 = **87.5%**。实现时统一用毫秒计算 progress,避免秒级舍入误差。
|
||||
|
||||
- 需要策略的 `windowStartSeconds`、`windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。
|
||||
|
||||
**优点**:与「尾盘只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。
|
||||
**缺点**:`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。
|
||||
|
||||
---
|
||||
|
||||
## 方案二:按「整周期」进度
|
||||
|
||||
**区间**:整个周期 `[periodStartUnix, periodStartUnix + intervalSeconds]`。**progress 按毫秒计算**。
|
||||
|
||||
```
|
||||
periodStartMs = periodStartUnix * 1000
|
||||
periodEndMs = (periodStartUnix + intervalSeconds) * 1000
|
||||
periodLenMs = intervalSeconds * 1000L
|
||||
nowMs = System.currentTimeMillis()
|
||||
|
||||
progress = (nowMs - periodStartMs) / periodLenMs
|
||||
progress = clamp(progress, 0, 1)
|
||||
|
||||
coefficient = 1.0 - 0.5 * progress
|
||||
effectiveMinSpread = baseSpread × coefficient
|
||||
```
|
||||
|
||||
**优点**:只依赖 `intervalSeconds`、`periodStartUnix`、`nowSeconds`,不依赖窗口配置。
|
||||
**缺点**:若窗口只占周期后半段,周期前半段也会在算系数,语义上不如按窗口精确。
|
||||
|
||||
---
|
||||
|
||||
## 实现要点
|
||||
|
||||
### 1. 缓存 100% 基准值
|
||||
|
||||
- **BinanceKlineAutoSpreadService**:
|
||||
- `computeAndCache()`:缓存 **不乘 0.7** 的 (avgUp, avgDown),即 IQR 平均后的原始值(100% 基准)。
|
||||
- 可保留方法名与入参不变,仅去掉 `autoSpreadCoefficient` 的乘法;或新增 `getBaseSpread()` 语义,内部仍用同一缓存。
|
||||
|
||||
### 2. 动态系数计算位置
|
||||
|
||||
- 系数依赖「当前时间」和「区间定义」,适合在 **触发校验处** 算,而不是在 AutoSpread 服务里写死。
|
||||
- **CryptoTailStrategyExecutionService.passMinSpreadCheck()**:
|
||||
- 当前:`getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)` 得到已乘系数的值。
|
||||
- 改为:
|
||||
- 取「基础价差」:`getAutoMinSpreadBase(intervalSeconds, periodStartUnix, outcomeIndex)` 或由现有缓存返回 100% 值。
|
||||
- 在 `passMinSpreadCheck` 内根据 `strategy.windowStartSeconds/windowEndSeconds` 和 `System.currentTimeMillis()`(毫秒)算 `progress`(按毫秒)→ `coefficient` → `effectiveMinSpread = baseSpread × coefficient`。
|
||||
|
||||
### 3. 接口形态建议
|
||||
|
||||
- **BinanceKlineAutoSpreadService**:
|
||||
- `computeAndCache(interval, periodStartUnix)`:只缓存 100% 基准 (baseUp, baseDown),不再乘 0.7。
|
||||
- `getAutoMinSpreadBase(interval, periodStartUnix, outcomeIndex): BigDecimal?`:仅返回缓存的基础价差;若需兼容旧名,可保留 `getAutoMinSpread` 但增加可选参数 `coefficient`,默认 1.0。
|
||||
- **CryptoTailStrategyExecutionService**:
|
||||
- 在 `passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)` 内:
|
||||
- 取 `baseSpread = getAutoMinSpreadBase(...)`。
|
||||
- 计算 `progress`(按方案一用 windowStart/End,或方案二用 interval)。
|
||||
- `coefficient = 1.0 - 0.5 * progress`,再 `effectiveMinSpread = baseSpread * coefficient` 做比较。
|
||||
|
||||
### 4. 边界与兼容
|
||||
|
||||
- 窗口长度为 0:可约定 `coefficient = 0.5` 或 1.0,避免除零。
|
||||
- 已有策略未配置窗口(全 0):若用方案一,可退化为「整周期」或固定 0.5/1.0」。
|
||||
- 预加载逻辑(如 CryptoTailOrderbookWsService 的 `precomputeAutoMinSpreadForCurrentPeriods`)无需改,仍调用 `computeAndCache`,只是缓存内容变为 100% 基准。
|
||||
|
||||
---
|
||||
|
||||
## 小结
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------------|------|
|
||||
| 预加载 | 缓存 100% 基础价差(去掉固定 0.7) |
|
||||
| 系数范围 | 100% → 50% 线性递减 |
|
||||
| 推荐区间 | 按触发窗口 `windowStartSeconds`~`windowEndSeconds` 计算进度(方案一) |
|
||||
| progress | **按毫秒计算**:`(nowMs - windowStartMs) / windowLenMs`,避免秒级舍入误差 |
|
||||
| 计算位置 | 触发时在 `passMinSpreadCheck` 中算 progress → coefficient → effectiveMinSpread |
|
||||
|
||||
按上述实现后,AUTO 模式即为「预加载提供 100% 数值 + 随区间时间点从 100% 递减到 50%」的动态方案。
|
||||
@@ -0,0 +1,204 @@
|
||||
# 加密市场尾盘策略 - 流程图
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ POST 创建/更新 ┌──────────────────────────┐
|
||||
│ 前端 / API │ ──────────────────────►│ CryptoTailStrategyController│
|
||||
└─────────────────┘ └──────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ CryptoTailStrategyService │
|
||||
│ create / update │
|
||||
│ save → publishEvent │
|
||||
└──────────────┬─────────────┘
|
||||
│
|
||||
┌─────────────────────────────────────────┼─────────────────────────────────────────┐
|
||||
│ CryptoTailStrategyChangedEvent │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ CryptoTailStrategyScheduler │ │ CryptoTailOrderbookWsService │ │ (其他监听方,如有) │
|
||||
│ @EventListener │ │ @EventListener │ └──────────────────────────────┘
|
||||
│ → runCycle() 一次(补充) │ │ → refreshAndSubscribe() │
|
||||
└──────────────┬───────────────┘ └──────────────┬───────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────┐ │
|
||||
│ CryptoTailStrategyExecution │ │ 每 25 秒 + 事件时
|
||||
│ runCycle() │ │ refreshAndSubscribe()
|
||||
│ (HTTP 拉订单簿,满足则下单) │ ▼
|
||||
└──────────────────────────────┘ ┌──────────────────────────────┐
|
||||
│ CLOB Market WebSocket │
|
||||
│ wss://.../ws/market │
|
||||
│ subscribe assets_ids │
|
||||
└──────────────┬───────────────┘
|
||||
│ book / price_change
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ onBestBid(tokenId, bestBid) │
|
||||
│ → tryTriggerWithPriceFromWs │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ CryptoTailStrategyExecution │
|
||||
│ placeOrderForTrigger │
|
||||
│ → CLOB 下单 + 写触发记录 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、策略创建/更新流程(API → 事件)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant API as Controller
|
||||
participant Svc as CryptoTailStrategyService
|
||||
participant DB as DB
|
||||
participant Event as ApplicationEventPublisher
|
||||
|
||||
API->>Svc: create(request) / update(request)
|
||||
Svc->>Svc: 参数校验(账户、窗口、价格、金额模式等)
|
||||
Svc->>DB: save(entity)
|
||||
Svc->>Event: publishEvent(CryptoTailStrategyChangedEvent)
|
||||
Svc->>API: Result.success(dto)
|
||||
```
|
||||
|
||||
- **创建**:校验通过后落库,发布 `CryptoTailStrategyChangedEvent`,返回 DTO。
|
||||
- **更新**:同上,更新实体后发布同一事件。
|
||||
- **删除**:不发布事件(策略已移除,WS 下次刷新订阅时会自然不再包含该策略)。
|
||||
|
||||
---
|
||||
|
||||
## 三、策略变更后:双路响应
|
||||
|
||||
事件发出后,两个监听方并行执行,互不阻塞:
|
||||
|
||||
| 监听方 | 动作 | 说明 |
|
||||
|--------|------|------|
|
||||
| **CryptoTailStrategyScheduler** | `onStrategyChanged` → `runCycle()` 一次 | 用 HTTP 拉订单簿做一轮检查,作为 WS 未就绪时的补充。 |
|
||||
| **CryptoTailOrderbookWsService** | `onStrategyChanged` → `refreshAndSubscribe()` | 按当前启用策略重新算 token 列表,向 WS 发送新的 `assets_ids` 订阅。 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 事件
|
||||
E[CryptoTailStrategyChangedEvent]
|
||||
end
|
||||
subgraph 调度器
|
||||
S[Scheduler.onStrategyChanged]
|
||||
R[executionService.runCycle]
|
||||
S --> R
|
||||
end
|
||||
subgraph WS服务
|
||||
W[OrderbookWsService.onStrategyChanged]
|
||||
Ref[refreshAndSubscribe]
|
||||
W --> Ref
|
||||
end
|
||||
E --> S
|
||||
E --> W
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、WebSocket 订单簿监听流程(主路径)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph 启动与连接
|
||||
A[PostConstruct init] --> B[connect]
|
||||
B --> C[OkHttp WebSocket 连接 wss://.../ws/market]
|
||||
C --> D[onOpen: refreshAndSubscribe]
|
||||
end
|
||||
|
||||
subgraph 订阅维护
|
||||
D --> E[buildSubscriptionMap]
|
||||
E --> F[遍历 enabled 策略]
|
||||
F --> G[当前周期 periodStartUnix]
|
||||
G --> H[slug = prefix-periodStartUnix]
|
||||
H --> I[Gamma getEventBySlug]
|
||||
I --> J[得到 tokenIds]
|
||||
J --> K[tokenId → List of WsBookEntry]
|
||||
K --> L[发送 type=MARKET, assets_ids=[...]]
|
||||
T[每 25 秒 @Scheduled] --> E
|
||||
EV[onStrategyChanged] --> E
|
||||
end
|
||||
|
||||
subgraph 收消息与触发
|
||||
M[onMessage: book / price_change]
|
||||
M --> N[解析 asset_id, best_bid]
|
||||
N --> O[onBestBid tokenId, bestBid]
|
||||
O --> P[查 tokenToEntries 得到策略列表]
|
||||
P --> Q[筛时间窗内]
|
||||
Q --> R[scope.launch tryTriggerWithPriceFromWs]
|
||||
R --> S[placeOrderForTrigger]
|
||||
end
|
||||
|
||||
L --> M
|
||||
```
|
||||
|
||||
- **buildSubscriptionMap**:只包含「当前时间仍在窗口内」的策略(`nowSeconds < windowEnd`),并只订阅这些策略对应周期的 token。
|
||||
- **onBestBid**:再按当前时间过滤一次时间窗,对每个命中策略在协程里调用 `tryTriggerWithPriceFromWs`,内部会查「本周期是否已触发」和价格区间,通过则 `placeOrderForTrigger`。
|
||||
|
||||
---
|
||||
|
||||
## 五、执行层:下单条件与顺序(ExecutionService)
|
||||
|
||||
无论来自 **runCycle(HTTP)** 还是 **tryTriggerWithPriceFromWs(WS)**,最终都走同一套下单逻辑。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph runCycle 入口
|
||||
A[runCycle] --> B[findAllByEnabledTrue]
|
||||
B --> C[processStrategy 每个策略]
|
||||
C --> D[在时间窗? 本周期已触发?]
|
||||
D --> E[Gamma getEventBySlug]
|
||||
E --> F[HTTP getOrderbook 两个 token]
|
||||
F --> G[第一个 bestBid 在 minPrice~maxPrice?]
|
||||
G --> H[placeOrderForTrigger]
|
||||
end
|
||||
|
||||
subgraph tryTriggerWithPriceFromWs 入口
|
||||
I[WS onBestBid] --> J[tryTriggerWithPriceFromWs]
|
||||
J --> K[本周期已触发? bestBid 在区间?]
|
||||
K --> H
|
||||
end
|
||||
|
||||
subgraph placeOrderForTrigger 统一
|
||||
H --> L[账户、API 凭证]
|
||||
L --> M[余额、下单金额]
|
||||
M --> N[最优价、数量]
|
||||
N --> O[签名、CLOB 下单]
|
||||
O --> P[保存 CryptoTailStrategyTrigger]
|
||||
end
|
||||
```
|
||||
|
||||
- **每周期最多触发一次**:由 `triggerRepository.findByStrategyIdAndPeriodStartUnix` 保证。
|
||||
- **价格区间**:`minPrice ≤ bestBid ≤ maxPrice` 才触发。
|
||||
- **时间窗**:仅当 `windowStart ≤ now < windowEnd`(以当前周期的 `periodStartUnix` 为基准)才参与检查/下单。
|
||||
|
||||
---
|
||||
|
||||
## 六、关键数据流小结
|
||||
|
||||
| 阶段 | 输入 | 输出/动作 |
|
||||
|------|------|-----------|
|
||||
| 创建/更新策略 | API 请求体 | 落库 + 发布 `CryptoTailStrategyChangedEvent` |
|
||||
| 事件 → 调度器 | 事件 | 执行一次 `runCycle()`(HTTP 拉订单簿,满足则下单) |
|
||||
| 事件 → WS 服务 | 事件 | `refreshAndSubscribe()`,更新订阅的 `assets_ids` |
|
||||
| 定时刷新订阅 | 每 25 秒 | `refreshAndSubscribe()`,保证新周期、新策略被订阅 |
|
||||
| WS 收 book/price_change | asset_id, best_bid | `onBestBid` → 时间窗内策略 → `tryTriggerWithPriceFromWs` → 未触发且价格在区间则 `placeOrderForTrigger` |
|
||||
| placeOrderForTrigger | 策略、周期、token、outcome、价格 | 账户/余额/价格/签名 → CLOB 下单 → 写触发记录 |
|
||||
|
||||
---
|
||||
|
||||
## 七、涉及类与职责
|
||||
|
||||
| 类 | 职责 |
|
||||
|----|------|
|
||||
| **CryptoTailStrategyController** | 接收 list/create/update/delete/triggers/marketOptions 的 POST。 |
|
||||
| **CryptoTailStrategyService** | 策略 CRUD、校验、发布 `CryptoTailStrategyChangedEvent`。 |
|
||||
| **CryptoTailStrategyScheduler** | 监听策略变更事件,执行一次 `runCycle()`。 |
|
||||
| **CryptoTailOrderbookWsService** | 连接 CLOB Market WS、维护订阅(事件 + 每 25 秒)、处理 book/price_change、调用 `tryTriggerWithPriceFromWs`。 |
|
||||
| **CryptoTailStrategyExecutionService** | `runCycle()`(HTTP 路径)、`tryTriggerWithPriceFromWs()`(WS 路径)、`placeOrderForTrigger()`(统一下单与写触发记录)。 |
|
||||
@@ -0,0 +1,178 @@
|
||||
# 加密市场尾盘策略 - 5/15 分钟市场数据获取说明
|
||||
|
||||
> 前端 UI 与交互详见 `crypto-tail-strategy-ui-spec.md`。
|
||||
|
||||
## 1. 数据源
|
||||
|
||||
- **Gamma API**:`https://gamma-api.polymarket.com`
|
||||
- 用于获取市场元数据:conditionId、开始/结束时间、标题、clobTokenIds 等。
|
||||
- 无需鉴权。
|
||||
|
||||
## 2. 市场类型与 Slug 规则
|
||||
|
||||
| 类型 | Event Slug 规则 | 周期长度 | 说明 |
|
||||
|------|-----------------|----------|------|
|
||||
| Bitcoin 5 分钟 | `btc-updown-5m-{periodStartUnix}` | 5 min | periodStartUnix 为 5 分钟边界的 Unix 时间戳(秒) |
|
||||
| Bitcoin 15 分钟 | `btc-updown-15m-{periodStartUnix}` | 15 min | periodStartUnix 为 15 分钟边界:`(now // 900) * 900` |
|
||||
| Ethereum 5 分钟 | `eth-updown-5m-{ts}` | 5 min | 暂未验证是否在平台上线;如有可按相同规则推导 |
|
||||
| Ethereum 15 分钟 | `eth-updown-15m-{ts}` | 15 min | 已验证存在 |
|
||||
|
||||
- 5 分钟周期:按 **300 秒** 对齐;当前周期起点可用 `(nowUnix // 300) * 300`,下一周期为 `+300`。
|
||||
- 15 分钟周期:按 **900 秒** 对齐;当前周期起点可用 `(nowUnix // 900) * 900`。slug 中的时间戳即为周期起始 Unix 秒;周期结束以 API 的 endDate 为准。
|
||||
|
||||
## 3. 获取单个周期市场(开始时间、结束时间)
|
||||
|
||||
### 3.1 请求
|
||||
|
||||
```bash
|
||||
# 5 分钟 - 当前周期(示例时间戳需替换为当前周期起点)
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"
|
||||
|
||||
# 15 分钟 - 需使用实际存在的时间戳(可从前端或历史 slug 得知)
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1770882300"
|
||||
```
|
||||
|
||||
### 3.2 响应结构(与开始/结束时间相关)
|
||||
|
||||
- **Event 层**:`startDate`、`endDate`(ISO 8601)。
|
||||
- **markets[]**:每个市场有 `conditionId`、`question`、`startDate`、`endDate`、`clobTokenIds` 等。
|
||||
|
||||
**周期本身**:例如 5 分钟市场 "1:30PM-1:35PM ET",理应是 **startDate = 1:30 PM**、**endDate = 1:35 PM**。
|
||||
|
||||
**API 返回值与周期起止的对应关系(已用脚本验证)**:
|
||||
|
||||
| 字段 | 是否等于周期起止 | 说明 |
|
||||
|------|------------------|------|
|
||||
| **endDate**(Event / Market) | **是**,等于周期结束时间(如 1:35 PM) | API 的 endDate 即周期终点,可直接用。 |
|
||||
| **startDate**(Event / Market) | **否**,不等于周期开始时间(1:30 PM) | API 的 startDate 是市场创建/开放时间,不是周期起点,故**不能**当 1:30 PM 用。 |
|
||||
|
||||
**正确做法**:周期起点(1:30 PM)用 **slug 中的时间戳** 推导;周期终点(1:35 PM)用 API 的 **endDate**。
|
||||
|
||||
- **5 分钟**:周期开始 = `slug_ts`(即 slug 中的 Unix 秒),周期结束 = `endDate`(或 `slug_ts + 300`)。
|
||||
- **15 分钟**:周期开始 = `slug_ts`,周期结束 = `endDate`(或 `slug_ts + 900`)。
|
||||
|
||||
**示例(脚本输出解读)**:若 current 5m slug 为 `btc-updown-5m-1771007400`、title 为 "1:30PM-1:35PM ET"、endDate 为 `2026-02-13T18:35:00Z`,则 1771007400 = 18:30 UTC = 1:30 PM ET,即周期起点;endDate 18:35 UTC = 1:35 PM ET = 周期终点。next 5m slug 为 1771007700 = 1771007400 + 300,即下一周期起点。15m 同理:current slug 1771007400(1:30–1:45 PM ET),next 1771008300 = 1771007400 + 900(1:45–2:00 PM ET)。
|
||||
|
||||
## 4. 如何列出“当前及未来”5/15 分钟市场
|
||||
|
||||
- Gamma 未提供按“5 分钟 / 15 分钟”或“Up or Down”的 tag 筛选;`tag_id=744`(cryptocurrency)未返回这些短期市场。
|
||||
- **可行方式**:
|
||||
1. **按周期时间戳生成 slug 并逐个请求**
|
||||
- 5 分钟:当前周期 `ts = (nowUnix // 300) * 300`,下一周期 `ts + 300`,再下一周期 `ts + 600` …
|
||||
- 15 分钟:`ts = (nowUnix // 900) * 900`,然后 `ts + 900`、`ts + 1800` …
|
||||
- 请求 `GET /events/slug/btc-updown-5m-{ts}` 或 `btc-updown-15m-{ts}`;若返回 404 表示该周期尚未创建或已过期,可跳过。
|
||||
2. **用户选择“市场”时**:若前端/后端已知“系列”(如 Bitcoin 5 minute),则只需约定 slug 前缀(`btc-updown-5m`、`btc-updown-15m`)与周期长度(300/900),按当前时间计算周期起点并请求对应 slug 即可得到当前周期的 conditionId、startDate、endDate;下一周期同理。
|
||||
|
||||
## 5. 周期边界与“每周期监听”
|
||||
|
||||
- **周期开始**:使用 **slug 中的时间戳** `periodStartUnix`(即请求 slug 时的 `btc-updown-5m-{ts}` 里的 `ts`),不要用 API 返回的 startDate。
|
||||
- **周期结束**:使用 API 返回的 **event.endDate 或 market.endDate**(与 slug_ts + 300/900 一致)。
|
||||
- 判断“当前是否在该周期内”:`periodStartUnix <= nowUnix < endDateUnix`,其中 `periodStartUnix` 从 slug 得到,`endDateUnix` 由 endDate 解析。
|
||||
- 策略“每周期开始时开始监听”:当 `now` 跨过当前周期的 endDate(或下一周期的 periodStartUnix)时,视为新周期开始,重置“本周期是否已触发”等状态。
|
||||
|
||||
## 6. 如何保证每个周期的市场都能正确处理
|
||||
|
||||
### 6.1 用“当前时间”唯一确定当前周期
|
||||
|
||||
- 服务端只用**当前 Unix 时间**推导周期,不依赖 API 的 startDate。
|
||||
- **5 分钟**:`periodStartUnix = (nowUnix / 300) * 300`(整除)。
|
||||
- **15 分钟**:`periodStartUnix = (nowUnix / 900) * 900`。
|
||||
- 同一时刻算出的 `periodStartUnix` 唯一,对应唯一 slug(如 `btc-updown-5m-{periodStartUnix}`),从而对应唯一市场(conditionId、tokenIds、endDate)。
|
||||
|
||||
### 6.2 按周期拉取市场并切换
|
||||
|
||||
- **首次进入或策略启用**:用当前的 `periodStartUnix` 拼 slug,请求 Gamma `GET /events/slug/{slug}`,拿到该周期的 conditionId、endDate、clobTokenIds;用 endDate 解析得到 `endDateUnix`。
|
||||
- **每次需要判断“是否还在本周期”或“是否该下单”时**:先算当前 `currentPeriodStart = (nowUnix / interval) * interval`(interval 为 300 或 900)。若 `currentPeriodStart` 大于上一笔使用的 `periodStartUnix`,说明已进入**下一周期**:
|
||||
- 用新的 `currentPeriodStart` 拼 slug,重新请求 Gamma,拿到**新周期**的 conditionId、endDate、clobTokenIds;
|
||||
- 用新周期的 tokenIds 订阅/拉取订单簿,用新 endDate 作为本周期结束时间;
|
||||
- 重置本周期“是否已触发”等状态,避免把上一周期的状态带到新周期。
|
||||
- **周期内**:始终用**本周期**的 conditionId、tokenIds、endDate 做价格监听与下单,不要混用上一周期的数据。
|
||||
|
||||
### 6.3 周期切换时机与 404 处理
|
||||
|
||||
- **切换时机**:以 `nowUnix >= endDateUnix` 或 `(nowUnix / interval) * interval > periodStartUnix` 作为“本周期已结束”,立刻按 6.2 用新 `periodStartUnix` 拉新周期市场。
|
||||
- **新周期市场尚未创建(404)**:Gamma 可能稍晚才创建下一周期 event。若请求 slug 返回 404,可短间隔重试(如 5–15 秒)或等到下一整点/对齐点再试;重试时仍用**同一** `periodStartUnix`,避免用错周期。若长时间 404,可记录日志并跳过该周期,下一周期再正常拉取。
|
||||
|
||||
### 6.4 下单失败重试规则(每周期最多下单一次)
|
||||
|
||||
- 市价单提交失败时,**最多重试 2 次**(即 1 次初始 + 2 次重试,共 3 次尝试)。
|
||||
- 若 3 次均失败:
|
||||
- 本周期**不再**对该 outcome 下单;
|
||||
- 记录失败原因与状态(便于审计与前端展示触发记录)。
|
||||
- 周期切换时(6.2)重置为“未下单”,仅对新周期做新的判断与尝试。
|
||||
|
||||
### 6.5 去重与幂等(每周期最多触发一次)
|
||||
|
||||
- 以「策略 + 周期」唯一标识一次执行,例如 `(strategyId, periodStartUnix)` 或 `(accountId, slugPrefix, periodStartUnix)`。
|
||||
- 在数据库或内存中记录:本周期是否已触发、是否已下单。若已触发,同一周期内不再根据价格区间下单。
|
||||
- 周期切换时(6.2)清空或更新为“新周期未触发”,只对新周期的 conditionId/tokenIds 做监听与下单。
|
||||
|
||||
### 6.6 时间区间(窗口)内才触发
|
||||
|
||||
- 策略可配置**时间区间**:从周期起点起算的「开始秒数」与「结束秒数」,例如 5 分钟市场可选 0~300 秒内的一段,15 分钟市场可选 0~900 秒内的一段(对应前端“分+秒”下拉,如 3 分 0 秒~12 分 0 秒即 180~720 秒)。
|
||||
- **执行规则**:仅当 `periodStartUnix + windowStartSeconds <= nowUnix < periodStartUnix + windowEndSeconds` 时,才根据 7.1 判断价格是否进入 [minPrice, maxPrice] 并执行下单;**区间外不进行价格判断与下单**。
|
||||
- 存储:策略表(或配置)中保存 `windowStartSeconds`、`windowEndSeconds`(整数,单位秒);校验:`windowStartSeconds <= windowEndSeconds`,且不超过周期长度(5min 市场 ≤ 300,15min 市场 ≤ 900)。详见 [UI 规格 - 时间区间](crypto-tail-strategy-ui-spec.md)。
|
||||
|
||||
### 6.7 小结
|
||||
|
||||
| 要点 | 做法 |
|
||||
|------|------|
|
||||
| 周期唯一性 | 用 `(nowUnix / interval) * interval` 得到 periodStartUnix,再拼 slug,不依赖 API startDate。 |
|
||||
| 周期数据 | 每周期用**该周期**的 slug 请求 Gamma,使用返回的 conditionId、endDate、clobTokenIds。 |
|
||||
| 切换 | 当 `nowUnix >= endDateUnix` 或当前算出的 periodStartUnix 变化时,拉取新周期并重置状态。 |
|
||||
| 404 | 同一 periodStartUnix 重试;长时间 404 可跳过该周期并打日志。 |
|
||||
| 下单失败 | 失败后最多重试 2 次;仍失败则本周期不再下单并记录状态。 |
|
||||
| 每周期只触发一次 | 用 (策略, periodStartUnix) 做去重,周期切换时重置“已触发”状态。 |
|
||||
| 时间区间 | 仅当 periodStartUnix + windowStartSeconds ≤ now < periodStartUnix + windowEndSeconds 时做价格判断与下单;区间外不处理。 |
|
||||
|
||||
按上述方式,每个周期都会对应到正确的 slug、正确的市场与 endDate,并在周期结束时切换到下一周期;仅在配置的时间窗口内才根据价格触发下单,避免混周期或漏周期。
|
||||
|
||||
## 7. 与订单簿 / 价格的关系
|
||||
|
||||
- 价格由 **CLOB 订单簿**(或 WebSocket)获取,不依赖 Gamma;Gamma 仅提供市场元数据。
|
||||
- 使用 market.conditionId 与 markets[].clobTokenIds 解析出 tokenId,再订阅或请求该 token 的订单簿即可得到实时价格,用于区间判断与市价下单。
|
||||
|
||||
### 7.1 价格区间与「反方向」判断(如 minPrice = 0.92)
|
||||
|
||||
二元市场(Up or Down)有两个 outcome:通常 outcomeIndex 0 = Up,1 = Down,各对应一个 tokenId 和订单簿。
|
||||
|
||||
- **配置含义**:用户配置 minPrice = 0.92(及可选 maxPrice,默认 1)表示「当**某个 outcome 的价格**落在 [0.92, 1] 时触发市价买入**该** outcome」。
|
||||
- **不预先选方向**:不需要用户选「买 Up 还是买 Down」;谁的价格先进入区间就买谁。
|
||||
- **订单簿取价方式(与现有市价单逻辑一致)**:
|
||||
- 对每个 outcome,取该 tokenId 订单簿的 **bestBid**(最高买入价)作为当前价格用于区间判断;若取价规则与现有市价买入逻辑不同,请以系统现有规则为准并在实现文档中写明。
|
||||
- **判断方式**:
|
||||
- 同时取**两个 outcome** 的当前价格(按上述取价规则)。
|
||||
- 对 **outcome 0**:若 `price0 >= minPrice && price0 <= maxPrice` → 满足触发条件,买入 outcome 0(Up)。
|
||||
- 对 **outcome 1**:若 `price1 >= minPrice && price1 <= maxPrice` → 满足触发条件,买入 outcome 1(Down)。
|
||||
- **反方向**:「反方向」即另一个 outcome。例如若本轮已因 outcome 0 进入 [0.92, 1] 而买入 Up,则本周期内**不再**检查 outcome 1 是否也进入区间、也不再买 Down;反之若先触发的是 outcome 1(Down),则本周期不再买 Up。实现上:一旦本周期已对**任意一个** outcome 触发并下单,即标记本周期已触发,不再对**另一个 outcome(反方向)**做区间判断与下单。
|
||||
- **同一时刻两边都进区间**:若同一时刻 Up 和 Down 的价格都在 [0.92, 1](理论上二元市场 Up+Down≈1 时不会同时 ≥0.92,但若出现),可约定按 outcomeIndex 优先(如先判 0 再判 1)或先到先得,只执行一笔买入,本周期不再买反方向。
|
||||
|
||||
总结:配置 0.92 时,对**两个方向**都做同一区间判断;先满足区间的那一侧触发买入,另一侧即为反方向,本周期不再触发。
|
||||
|
||||
## 8. 验证方式
|
||||
|
||||
**startDate/endDate 验证结论**:已用脚本对比 slug 时间戳与 API 返回的 startDate/endDate。**endDate 等于当前周期结束时间**;**startDate 不等于周期起始点**(为市场创建/开放时间),周期起始点应以 slug 中的时间戳为准。详见上文 3.2、5 节。
|
||||
|
||||
### 8.1 脚本(推荐)
|
||||
|
||||
项目内脚本,会请求当前/下一 5 分钟与 15 分钟 BTC 市场并打印 conditionId、startDate、endDate、clobTokenIds:
|
||||
|
||||
```bash
|
||||
python3 scripts/fetch_crypto_minute_markets.py
|
||||
```
|
||||
|
||||
### 8.2 curl 示例
|
||||
|
||||
```bash
|
||||
# 5 分钟 - 当前或下一周期(时间戳需替换为实际周期起点)
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"
|
||||
|
||||
# 15 分钟 - 当前周期(时间戳需替换为实际周期起点)
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1771006500"
|
||||
|
||||
# 15 分钟 - 历史存在的事件
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1770882300"
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/eth-updown-15m-1770801300"
|
||||
```
|
||||
|
||||
若返回 403,可加 User-Agent:`curl -s -H "User-Agent: PolymarketBot/1.0" "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"`
|
||||
@@ -0,0 +1,247 @@
|
||||
# 尾盘策略 - 最小价差参数流程分析
|
||||
|
||||
## 一、需求摘要
|
||||
|
||||
在现有尾盘策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。
|
||||
|
||||
- **后端**:需订阅币安对应币对(如 BTC/USDC)的 K 线,维护当前周期的**开盘价**与**实时收盘价**,并在触发时做价差校验。
|
||||
- **前端**:可配置三种场景——无、固定、自动(见下)。
|
||||
|
||||
---
|
||||
|
||||
## 二、前端配置场景
|
||||
|
||||
| 场景 | 配置方式 | 校验逻辑 |
|
||||
|------|----------|----------|
|
||||
| **无** | 不进行价差校验 | 与现有一致:仅判断时间窗 + 价格区间,满足即下单。 |
|
||||
| **固定** | 用户输入一个固定价差(如 30) | 当 \|收盘价 − 开盘价\| ≥ 该固定值时,校验通过,再下单。 |
|
||||
| **自动** | 由系统根据历史数据计算最小价差 | 见下文「自动模式计算逻辑」;得到数值后,后续与固定模式一致:\|收盘价 − 开盘价\| ≥ 计算值 则通过。 |
|
||||
|
||||
### 自动模式计算逻辑
|
||||
|
||||
- 通过币安 API 获取**历史 20 根** K 线(与策略周期一致:5m 取 5m K 线,15m 取 15m K 线)。
|
||||
- **下单方向 = Down**(outcomeIndex = 1):只取「收盘价 < 开盘价」的 K 线,得到价差序列(开盘价 − 收盘价)。
|
||||
- **下单方向 = Up**(outcomeIndex = 0):只取「收盘价 > 开盘价」的 K 线,得到价差序列(收盘价 − 开盘价)。
|
||||
- **异常值剔除**:对上述价差序列做异常值过滤(见下文「异常值剔除」),再用**剩余样本**求平均价差,乘以系数 **80%** 得到最小价差;后续用该值做 \|收盘价 − 开盘价\| ≥ 该值 的校验。
|
||||
- **历史数据获取时机**:**在该周期开始时就拉取并计算**,不在保存策略时计算。订单簿 WS 在周期开始时刷新订阅(含每 25 秒或周期切换时的 refreshAndSubscribe),此时对当前周期内所有启用且为 AUTO 的策略,按 (intervalSeconds, periodStartUnix) 预拉该周期前 20 根已收盘 K 线并计算 minSpreadUp/minSpreadDown 写入缓存;该周期内触发时直接用缓存,无需在触发时再调 REST。
|
||||
|
||||
### 异常值剔除
|
||||
|
||||
- **目的**:避免少数极端 K 线(如 14 组价差在 50 以内、1 组价差 200)拉高平均价差,导致最小价差偏大、难以触发。
|
||||
- **做法**:在按方向得到价差序列后,先**剔除异常值**,再对剩余价差求平均并 × 0.8。
|
||||
- **推荐方法:IQR(四分位距)**
|
||||
- 对价差序列排序,计算 Q1(25% 分位)、Q3(75% 分位)、IQR = Q3 − Q1。
|
||||
- 保留区间 **[Q1 − 1.5×IQR, Q3 + 1.5×IQR]** 内的价差,剔除该区间外的点。
|
||||
- 示例:15 组价差,14 组在 50 以内、1 组为 200 → 200 会超出上界被剔除,只用 14 组参与平均。
|
||||
- **边界与降级**
|
||||
- 若剔除后剩余样本数过少(如 < 3),则**不剔除**:用全部价差样本求平均 × 0.8。
|
||||
- 若无满足方向的 K 线(如 20 根里没有 close < open),仍按原文档降级处理(全量 \|close−open\| 或返回 0)。
|
||||
|
||||
---
|
||||
|
||||
## 三、整体流程(含价差校验)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. 数据源与订阅 │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ • CLOB 订单簿 WS(现有):Polymarket 订单簿 → bestBid。 │
|
||||
│ • 币安 K 线 WS(新增):订阅 BTCUSDC 对应周期(5m/15m),维护「当前周期」的开盘价 │
|
||||
│ open、实时收盘价 close(每根 K 线未收盘前 close 会持续更新)。 │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. 触发入口(与现有一致) │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ • 入口 A:CryptoTailOrderbookWsService.onBestBid(tokenId, bestBid) │
|
||||
│ • 入口 B:CryptoTailStrategyExecutionService.runCycle()(HTTP 拉订单簿) │
|
||||
│ 两者在「时间窗 + 价格区间 + 本周期未触发」通过后,都会调用执行层「尝试下单」。 │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 3. 执行层增加「价差校验」 │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 在现有 tryTriggerWithPriceFromWs / runCycle → placeOrderForTrigger 之前增加: │
|
||||
│ │
|
||||
│ if (策略.minSpreadMode == NONE) → 直接进入 placeOrderForTrigger。 │
|
||||
│ else: │
|
||||
│ • 从「币安 K 线服务」取当前周期(与 strategy.intervalSeconds 对齐)的 open、 │
|
||||
│ close(实时)。 │
|
||||
│ • 若取不到 open/close(例如该周期尚未有数据)→ 本轮不下单,等待下次 WS 更新。 │
|
||||
│ • 计算 effectiveMinSpread: │
|
||||
│ - FIXED:effectiveMinSpread = 策略.minSpreadValue(用户填的固定值) │
|
||||
│ - AUTO:effectiveMinSpread = 按当前下单方向(outcomeIndex)取「自动计算 │
|
||||
│ 的最小价差」(见下节;若尚未计算则先拉 20 根历史 K 线并计算、缓存)。 │
|
||||
│ • 若 |close − open| < effectiveMinSpread → 本轮不下单,等待价差满足。 │
|
||||
│ • 若 |close − open| >= effectiveMinSpread → 通过价差校验,进入 │
|
||||
│ placeOrderForTrigger(与现有逻辑一致:预签/签名、提交 CLOB 订单、写触发记录)。│
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **「等待价差满足」**:不主动轮询;下次 CLOB 订单簿或币安 K 线有推送时,会再次进入上述判断,此时 close 可能已更新,价差可能已满足,再决定是否下单。
|
||||
- **每周期最多触发一次**:仍由现有「本周期是否已触发」保证;价差不满足时**不写触发记录**,也不占「已触发」名额,直到某次检查同时满足价格区间与价差后才下单并标记已触发。
|
||||
|
||||
---
|
||||
|
||||
## 四、自动模式:何时拉历史、如何算、如何用
|
||||
|
||||
- **何时拉 20 根历史 K 线并计算**
|
||||
- **在该周期开始时就预计算**,不在保存策略时计算。
|
||||
- 订单簿 WS 在**周期开始时**会刷新订阅(`refreshAndSubscribe`:每 25 秒或检测到周期切换时),此时对当前周期内所有启用且 minSpreadMode=AUTO 的策略,按 `(intervalSeconds, periodStartUnix)` 异步拉取该周期前 20 根已收盘 K 线(REST `endTime = periodStartUnix * 1000`),按 Up/Down 分别算 avgSpread × 0.8(含 IQR 剔除)并写入缓存。该周期内后续触发时直接用缓存,**不在触发时再调 REST**。
|
||||
- 若某周期未做预计算(如服务刚启动且尚未到刷新时机),触发时仍会按需调用 `computeAndCache` 并缓存,保证逻辑正确。
|
||||
- 前端「自动最小价差」接口仅作**预览**,实际下单校验不依赖该接口。
|
||||
|
||||
- **计算细节**
|
||||
- 历史 20 根:币安 REST `GET /api/v3/klines?symbol=BTCUSDC&interval=5m|15m&limit=20`(或 21 取前 20 根已收盘),每根格式为 [openTime, open, high, low, close, ...]。
|
||||
- **Down(outcomeIndex=1)**:筛选 close < open,价差 = open − close,得到价差序列 → **异常值剔除(IQR)** → 对剩余价差求平均,再 × 0.8 → minSpreadDown。
|
||||
- **Up(outcomeIndex=0)**:筛选 close > open,价差 = close − open,得到价差序列 → **异常值剔除(IQR)** → 对剩余价差求平均,再 × 0.8 → minSpreadUp。
|
||||
- **异常值剔除**:见上文「异常值剔除」;剔除后再平均。若剔除后剩余样本 < 3,则不剔除,用全部价差样本求平均。
|
||||
- 若无满足方向的 K 线(例如 20 根里没有一根 close < open),可降级:用全部 20 根的 |close−open| 平均 × 0.8,或返回 0/不校验,具体产品可定。
|
||||
|
||||
- **触发时使用**
|
||||
- 当前要下单的是 outcomeIndex(0=Up, 1=Down),取对应的 minSpreadUp 或 minSpreadDown 作为 effectiveMinSpread,再与 |close − open| 比较。
|
||||
|
||||
---
|
||||
|
||||
## 五、后端模块与数据流
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| **BinanceKlineService(新)** | 1)订阅币安 WS:BTCUSDC 的 5m、15m K 线流(可按需只订阅有策略使用的周期)。<br>2)维护「当前周期」数据:以 periodStartUnix(或 K 线 t 对齐)为 key,存 (open, close);K 线 WS 推送时更新 close,新周期首条推送时更新 open。<br>3)提供 getCurrentOpenClose(symbol, intervalSeconds, periodStartUnix) → (open, close)?,供执行层价差校验使用。 |
|
||||
| **BinanceKlineAutoSpreadService 或合入上者(新)** | 1)按**周期**拉取:以 periodStartUnix 为界,REST 拉取该周期前的 20 根已收盘 K 线。<br>2)按 Up/Down 得到价差序列 → **IQR 异常值剔除** → 对剩余价差求平均 × 0.8,缓存 (intervalSeconds, periodStartUnix) → (minSpreadUp, minSpreadDown)。<br>3)提供 getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex) 与 computeAndCache(intervalSeconds, periodStartUnix)。**周期开始时**由 CryptoTailOrderbookWsService 在 refreshAndSubscribe 后对当前周期内 AUTO 策略预调 computeAndCache;触发时直接用缓存,未命中时再按需计算。 |
|
||||
| **CryptoTailStrategy(实体)** | 新增字段建议:minSpreadMode(NONE/FIXED/AUTO)、minSpreadValue(固定时使用;AUTO 时可为空或存上次计算值用于展示)。 |
|
||||
| **CryptoTailStrategyExecutionService(现有)** | 在 tryTriggerWithPriceFromWs 与 runCycle 分支中,在调用 placeOrderForTrigger 前:若 minSpreadMode != NONE,则取 open/close 与 effectiveMinSpread,校验 \|close−open\| >= effectiveMinSpread;不通过则 return,不调用 placeOrderForTrigger。 |
|
||||
| **CryptoTailOrderbookWsService(现有)** | 仍只根据 CLOB bestBid 触发;价差校验在执行层统一做。**新增**:refreshAndSubscribe 完成后,对当前周期内所有启用且 minSpreadMode=AUTO 的策略,异步调用 BinanceKlineAutoSpreadService.computeAndCache,在周期开始即预计算最小价差。 |
|
||||
|
||||
- **币安 K 线与周期对齐**
|
||||
- 策略周期:periodStartUnix 为秒(如 5m 周期 = 300 的倍数,15m = 900 的倍数)。
|
||||
- 币安 K 线:t 为毫秒,同一周期:t_ms = periodStartUnix * 1000。
|
||||
- 用 (intervalSeconds, periodStartUnix) 或 (interval, t_ms) 对齐即可从 BinanceKlineService 取到「当前周期」的 open 和实时 close。
|
||||
|
||||
---
|
||||
|
||||
## 六、固定(FIXED)与自动(AUTO)时序图
|
||||
|
||||
### 6.1 固定(FIXED)时序图
|
||||
|
||||
固定模式:用户保存策略时写入 `minSpreadValue`(如 30);触发时直接用该值与当前周期 \|close−open\| 比较,不拉历史 K 线。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant API as Controller
|
||||
participant Svc as CryptoTailStrategyService
|
||||
participant DB as 数据库
|
||||
participant CLOB_WS as CLOB 订单簿 WS
|
||||
participant Orderbook as CryptoTailOrderbookWsService
|
||||
participant Exec as CryptoTailStrategyExecutionService
|
||||
participant BinanceWS as BinanceKlineService
|
||||
participant CLOB as Polymarket CLOB
|
||||
|
||||
User->>API: 保存策略 minSpreadMode=FIXED, minSpreadValue=30
|
||||
API->>Svc: create/update
|
||||
Svc->>DB: 写入 min_spread_mode, min_spread_value
|
||||
Svc-->>API: 成功
|
||||
API-->>User: 成功
|
||||
|
||||
Note over BinanceWS: 后台持续:币安 K 线 WS 更新当前周期 (open, close)
|
||||
|
||||
CLOB_WS->>Orderbook: onMessage(book/price_change) → bestBid
|
||||
Orderbook->>Orderbook: 时间窗内?价格在 [min,max]?本周期未触发?
|
||||
Orderbook->>Exec: tryTriggerWithPriceFromWs(strategy, periodStartUnix, ..., bestBid)
|
||||
Exec->>Exec: mutex 锁
|
||||
Exec->>Exec: 本周期已触发?→ 是则 return
|
||||
Exec->>Exec: passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)
|
||||
Exec->>Exec: mode==FIXED → effectiveMinSpread = strategy.minSpreadValue (30)
|
||||
Exec->>BinanceWS: getCurrentOpenClose(intervalSeconds, periodStartUnix)
|
||||
BinanceWS-->>Exec: (open, close) 来自内存
|
||||
Exec->>Exec: |close−open| >= 30 ? 否 → return,不下单
|
||||
Exec->>Exec: 是 → 通过价差校验
|
||||
Exec->>Exec: ensurePeriodContext → placeOrderForTrigger
|
||||
Exec->>CLOB: 提交订单
|
||||
CLOB-->>Exec: orderId
|
||||
Exec->>DB: 写入触发记录 (本周期已触发)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.2 自动(AUTO)时序图
|
||||
|
||||
自动模式:不在保存策略时计算。**在该周期开始时就预计算**(订单簿 WS 刷新订阅时对该周期内 AUTO 策略异步拉 20 根历史 K 线并计算、缓存);触发时直接用缓存,同一周期内复用。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant API as Controller
|
||||
participant Svc as CryptoTailStrategyService
|
||||
participant DB as 数据库
|
||||
participant CLOB_WS as CLOB 订单簿 WS
|
||||
participant Orderbook as CryptoTailOrderbookWsService
|
||||
participant Exec as CryptoTailStrategyExecutionService
|
||||
participant BinanceWS as BinanceKlineService
|
||||
participant AutoSpread as BinanceKlineAutoSpreadService
|
||||
participant BinanceREST as 币安 REST API
|
||||
participant CLOB as Polymarket CLOB
|
||||
|
||||
User->>API: 保存策略 minSpreadMode=AUTO(不填 minSpreadValue)
|
||||
API->>Svc: create/update
|
||||
Svc->>DB: 写入 min_spread_mode=AUTO
|
||||
Svc-->>API: 成功
|
||||
API-->>User: 成功
|
||||
|
||||
Note over BinanceWS: 后台持续:币安 K 线 WS 更新当前周期 (open, close)
|
||||
|
||||
CLOB_WS->>Orderbook: onMessage → bestBid
|
||||
Orderbook->>Orderbook: 时间窗 + 价格区间 + 本周期未触发 ✓
|
||||
Orderbook->>Exec: tryTriggerWithPriceFromWs(strategy, periodStartUnix, ..., bestBid)
|
||||
Exec->>Exec: mutex 锁
|
||||
Exec->>Exec: passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)
|
||||
Exec->>BinanceWS: getCurrentOpenClose(intervalSeconds, periodStartUnix)
|
||||
BinanceWS-->>Exec: (open, close)
|
||||
Note over Orderbook,AutoSpread: 周期开始时 refreshAndSubscribe 已对该周期预计算(见下)
|
||||
Exec->>AutoSpread: getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)
|
||||
AutoSpread->>AutoSpread: 查缓存 (intervalSeconds, periodStartUnix) → 命中(周期开始已预计算)
|
||||
AutoSpread-->>Exec: effectiveMinSpread
|
||||
Exec->>Exec: |close−open| >= effectiveMinSpread ? 否 → return
|
||||
Exec->>Exec: 是 → 通过价差校验
|
||||
Exec->>Exec: placeOrderForTrigger → CLOB 下单
|
||||
Exec->>DB: 写入触发记录
|
||||
|
||||
Note over Orderbook,AutoSpread: 周期开始时(refreshAndSubscribe 或周期切换)
|
||||
Orderbook->>Orderbook: refreshAndSubscribe() → buildSubscriptionMap() → newMap
|
||||
Orderbook->>Orderbook: precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
||||
Orderbook->>AutoSpread: computeAndCache(intervalSeconds, periodStartUnix) [异步]
|
||||
AutoSpread->>BinanceREST: GET /api/v3/klines?symbol=BTCUSDC&interval=15m&limit=20&endTime=periodStart*1000
|
||||
BinanceREST-->>AutoSpread: 20 根已收盘 K 线
|
||||
AutoSpread->>AutoSpread: 按 Up/Down 拆价差 → IQR 剔除 → 平均×0.8 → 缓存
|
||||
|
||||
Note over CLOB_WS,Exec: 同一周期内再次触发(如另一 outcome 或再次 bestBid)
|
||||
CLOB_WS->>Orderbook: onMessage → bestBid
|
||||
Orderbook->>Exec: tryTriggerWithPriceFromWs(...)
|
||||
Exec->>AutoSpread: getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)
|
||||
AutoSpread->>AutoSpread: 查缓存 → 命中
|
||||
AutoSpread-->>Exec: effectiveMinSpread(不再调 REST)
|
||||
Exec->>Exec: 价差校验 → 通过则下单(或本周期已触发则跳过)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、流程小结(按执行顺序)
|
||||
|
||||
1. **策略配置**
|
||||
- 用户选择:无 / 固定(输入数值)/ 自动。
|
||||
- 固定:必填 minSpreadValue,保存到 DB。
|
||||
- 自动:不填 minSpreadValue,**不在保存时计算**;按周期在首次需要时计算并缓存。
|
||||
|
||||
2. **运行时**
|
||||
- 币安 WS 持续更新当前周期的 (open, close)。
|
||||
- CLOB 订单簿(或 HTTP)带来 bestBid;若时间窗 + 价格区间 + 本周期未触发 均满足:
|
||||
- 若 minSpreadMode == NONE → 直接 placeOrderForTrigger。
|
||||
- 否则取当前周期 open/close 与 effectiveMinSpread(固定值或自动缓存值),若 \|close−open\| >= effectiveMinSpread → placeOrderForTrigger;否则本轮不下单,等后续推送再判。
|
||||
|
||||
3. **下单与去重**
|
||||
- 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。
|
||||
|
||||
按上述流程即可在现有尾盘策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。
|
||||
@@ -0,0 +1,150 @@
|
||||
# 加密市场尾盘策略 - 任务梳理
|
||||
|
||||
> 需求与 UI 见 `crypto-tail-strategy-ui-spec.md`,市场数据与执行规则见 `crypto-tail-strategy-market-data.md`。
|
||||
|
||||
以下按**文档 / 数据库 / 后端 / 前端**拆分为可执行任务,便于排期与验收。
|
||||
|
||||
---
|
||||
|
||||
## 一、文档(已完成)
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| PRD 与需求 | ✅ | 周期、价格区间、每周期最多触发一次、重试 2 次等 |
|
||||
| 市场数据文档 | ✅ | `crypto-tail-strategy-market-data.md`:Gamma slug、周期、时间区间、价格判断 |
|
||||
| UI 规格 | ✅ | `crypto-tail-strategy-ui-spec.md`:列表、表单、时间区间、触发记录、赎回前置检查 |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| D1 | 策略表 migration | 新建表,字段建议:id, account_id, name, market_slug_prefix(如 btc-updown-5m), interval_seconds(300/900), window_start_seconds, window_end_seconds, min_price, max_price, amount_mode(ratio/fixed), amount_value(比例或 USDC 字符串), enabled, created_at, updated_at。唯一/外键按现有规范。 |
|
||||
| D2 | 触发记录表 migration | 新建表,字段建议:id, strategy_id, period_start_unix, market_title, outcome_index(0=Up/1=Down), trigger_price, amount_usdc, order_id(可空), status(success/fail), fail_reason(可空), created_at。便于列表与筛选。 |
|
||||
|
||||
---
|
||||
|
||||
## 三、后端(Kotlin)
|
||||
|
||||
### 3.1 实体与 Repository
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B1 | 策略实体 Entity | 对应策略表;ID 用 Long?;时间 Long 时间戳;金额 BigDecimal;遵守 backend.mdc 实体规范。 |
|
||||
| B2 | 触发记录实体 Entity | 对应触发记录表。 |
|
||||
| B3 | JpaRepository | 策略、触发记录的 Repository;按 strategyId、时间等查记录。 |
|
||||
|
||||
### 3.2 外部依赖与领域
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B4 | Gamma API 按 slug 拉市场 | 已有或扩展 PolymarketGammaApi:GET /events/slug/{slug},返回 conditionId、endDate、clobTokenIds 等;与 market-data 文档 3、4 节一致。 |
|
||||
| B5 | 周期与 slug 推导 | 工具或 Service:根据 interval(300/900)、当前时间算 periodStartUnix;拼 slug(如 btc-updown-5m-{ts});解析 endDate 得 endDateUnix。 |
|
||||
| B6 | 订单簿价格 | 使用现有 CLOB/订单簿能力,按 conditionId、clobTokenIds 取各 outcome 的 bestBid;与 market-data 7.1 一致。 |
|
||||
| B7 | 市价单与重试 | 按策略的 amount 计算下单金额;市价买入指定 outcome;失败时最多重试 2 次(共 3 次),仍失败则写触发记录状态为失败并记原因。 |
|
||||
|
||||
### 3.3 策略执行核心逻辑(按 market-data 第 6、7 节)
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B8 | 周期内时间窗口判断 | 仅当 `periodStartUnix + windowStartSeconds <= nowUnix < periodStartUnix + windowEndSeconds` 时,才做价格区间判断与下单;区间外不处理。 |
|
||||
| B9 | 价格区间与「先满足先买」 | 对两个 outcome 取价,若某 outcome 价格 ∈ [minPrice, maxPrice],则触发买该 outcome;另一 outcome 本周期不再触发(7.1)。 |
|
||||
| B10 | 每周期只触发一次 | 以 (strategyId, periodStartUnix) 去重;周期切换时重置「本周期已触发」状态;结合 B8、B9 实现。 |
|
||||
| B11 | 周期切换与 404 | 当 now >= endDateUnix 或新 periodStartUnix 时,用新 periodStartUnix 拉新 slug;404 时同 periodStartUnix 短间隔重试,长时间 404 可跳过本周期并打日志。 |
|
||||
|
||||
### 3.4 API 与 DTO
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B12 | 策略 CRUD API | 列表(分页/筛选)、创建、更新、删除、启用/停用;请求/响应为 DTO,不用 Map;统一 ApiResponse;错误码与 MessageSource。 |
|
||||
| B13 | 策略 DTO | 创建/更新包含:accountId, name, marketSlugPrefix, intervalSeconds, windowStartSeconds, windowEndSeconds, minPrice, maxPrice(可选默认 1), amountMode, amountValue;校验 windowStart <= windowEnd,且不超过周期长度。 |
|
||||
| B14 | 触发记录 API | 按 strategyId 分页查询触发记录;返回列表 DTO(时间、市场、方向、价格、金额、订单 ID、状态)。 |
|
||||
| B15 | 5/15 分钟市场列表 API(可选) | 若前端需要「可选市场」列表:可按当前/下一周期拼 slug 调 Gamma 返回市场信息,供前端选择;或前端直接按 slug 规则+周期展示。 |
|
||||
|
||||
### 3.5 自动赎回与调度
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B16 | 自动赎回包含尾盘策略仓位 | 尾盘策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 |
|
||||
| B17 | 调度/定时或常驻 | 对已启用策略按周期(如每 10–30 秒)检查:当前周期、是否在时间窗口内、是否已触发、价格是否进区间;满足则执行下单并写触发记录。 |
|
||||
|
||||
---
|
||||
|
||||
## 四、前端(React + TypeScript)
|
||||
|
||||
### 4.1 路由与导航
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F1 | 路由 | App.tsx 增加 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id`。 |
|
||||
| F2 | 菜单 | Layout 中增加「尾盘策略」菜单项,与跟单同级或在其下;key 与路由一致。 |
|
||||
|
||||
### 4.2 列表页
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F3 | 列表页组件 | 如 CryptoTailStrategyList.tsx;页面标题、钱包提示 Alert、新增按钮、筛选(账户、状态)。 |
|
||||
| F4 | 列表展示 | 桌面 Table / 移动 Card:策略名、关联市场、时间区间、价格区间、投入方式、状态、最近触发、操作(编辑、启用/停用、删除、查看触发记录);删除 Popconfirm。 |
|
||||
| F5 | 创建前检查 | 点击「新增策略」先调接口判断是否已配置自动赎回(如 builderApiKeyConfigured);未配置则弹出「请先配置自动赎回」Modal(去配置 → /system-settings,取消),不打开表单。 |
|
||||
|
||||
### 4.3 新增/编辑表单
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F6 | 表单弹窗 | 策略名、选择账户、选择市场、时间区间、minPrice、maxPrice、投入方式(比例/固定)、启用状态。 |
|
||||
| F7 | 时间区间控件 | 区间开始/结束:下拉选「分钟」+「秒」;5min 市场 0–5 分+0–59 秒(总≤5min),15min 市场 0–15 分+0–59 秒(总≤15min);校验**开始 ≤ 结束**;提交时转为 windowStartSeconds、windowEndSeconds。 |
|
||||
| F8 | 市场选择器 | 仅展示 5/15 分钟加密市场;支持搜索;展示市场标题+周期;选后用于校验时间区间上界(5min 结束≤300s,15min≤900s)。 |
|
||||
| F9 | 表单校验与提交 | 市场类型、时间区间 start≤end 且不超周期、minPrice/maxPrice、比例或固定金额合法;提交后刷新列表、成功提示。 |
|
||||
|
||||
### 4.4 触发记录
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F10 | 触发记录展示 | 弹窗或独立页:触发时间、市场、方向(Up/Down)、触发价格、投入金额、订单 ID、状态;支持按时间、状态筛选;formatUSDC;移动端 Card/折叠。 |
|
||||
|
||||
### 4.5 通用
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F11 | 类型定义 | 策略、触发记录等 TypeScript 类型;无 any。 |
|
||||
| F12 | API 封装 | apiService 中 cryptoTailStrategy.list/create/update/delete/toggle、records(strategyId) 等。 |
|
||||
| F13 | 多语言 | locales 中 zh-CN、zh-TW、en 的 cryptoTailStrategy.*:list.title、list.walletTip、form.walletTip、redeemRequiredModal.*、时间区间/价格区间等文案。 |
|
||||
|
||||
---
|
||||
|
||||
## 五、依赖关系简图
|
||||
|
||||
```
|
||||
文档 ✅
|
||||
↓
|
||||
D1,D2 数据库
|
||||
↓
|
||||
B1–B3 实体与 Repository
|
||||
↓
|
||||
B4–B7 外部 API、周期、价格、下单
|
||||
↓
|
||||
B8–B11 执行逻辑(时间窗口+价格+去重+周期切换)
|
||||
↓
|
||||
B12–B15 API 与 DTO
|
||||
B16 自动赎回
|
||||
B17 调度
|
||||
↓
|
||||
F1–F2 路由与菜单
|
||||
F11–F12 类型与 API 封装
|
||||
F13 多语言
|
||||
↓
|
||||
F3–F5 列表与创建前检查
|
||||
F6–F9 表单(含时间区间)
|
||||
F10 触发记录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、验收要点
|
||||
|
||||
- **时间区间**:仅当周期内当前时间落在 [windowStartSeconds, windowEndSeconds] 时才判断价格并下单;前端区间开始 ≤ 结束,且不超出 5min/15min。
|
||||
- **每周期一次**:同一策略同一周期只触发一次(先满足价格的 outcome 买入,反方向不买)。
|
||||
- **重试**:下单失败最多重试 2 次,共 3 次;仍失败记入触发记录为失败。
|
||||
- **自动赎回**:尾盘策略产生的仓位可被自动赎回,无排除逻辑。
|
||||
- **创建前检查**:未配置自动赎回时点击新增策略弹出「去配置」弹窗,不打开表单。
|
||||
@@ -0,0 +1,177 @@
|
||||
# 加密市场尾盘策略 - 前端 UI 规格
|
||||
|
||||
> 周期推导与市场数据获取详见 `crypto-tail-strategy-market-data.md`。
|
||||
|
||||
与现有跟单/回测保持同一风格(Ant Design、响应式、多语言),以下为页面结构及所含元素。
|
||||
|
||||
---
|
||||
|
||||
## 1. 导航与路由
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **菜单** | 在「跟单管理」同级或其下增加一项,如「尾盘策略」,key 建议 `/crypto-tail-strategy`。 |
|
||||
| **路由** | 列表页 `/crypto-tail-strategy`;可选详情/触发记录 `/crypto-tail-strategy/records/:id`。 |
|
||||
|
||||
参考:`Layout.tsx` 中 `/copy-trading`、`/backtest` 的配置;`App.tsx` 中对应 `Route`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 列表页(主页面)
|
||||
|
||||
**路径**:`/crypto-tail-strategy`
|
||||
**组件**:如 `CryptoTailStrategyList.tsx`(或 `TailStrategyList.tsx`)。
|
||||
|
||||
### 2.1 顶部操作区
|
||||
|
||||
| 元素 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 页面标题 | 标题文案 | 如「加密尾盘策略」,用 `t('cryptoTailStrategy.list.title')`。 |
|
||||
| **钱包使用提示** | **Alert(Warning)** | **必须**在页面顶部或标题下方展示:提示用户**使用单独/专用钱包**运行本策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,进而造成策略执行异常(如余额不足、下单失败等)。文案走多语言 `t('cryptoTailStrategy.list.walletTip')`,可带 `showIcon`。 |
|
||||
| 新增策略 | Button(Primary) | 点击时**先检查自动赎回相关配置**(见 2.4);若未配置则弹出「去配置」简易弹窗,若已配置则打开「新增策略」表单弹窗。图标可用 `PlusOutlined`。 |
|
||||
| 筛选(可选) | Select / 筛选项 | 按账户、启用状态筛选;移动端可收起到抽屉或折叠。 |
|
||||
|
||||
### 2.2 列表内容(桌面端:Table,移动端:Card 列表)
|
||||
|
||||
| 列/卡片项 | 说明 |
|
||||
|-----------|------|
|
||||
| 策略名称 | 用户填的配置名或自动生成名。 |
|
||||
| 关联市场 | 展示市场标题 + 周期,如「Bitcoin Up or Down - 5 minute」。 |
|
||||
| 时间区间 | 如「3 分 0 秒 ~ 12 分 0 秒」(与周期类型一致:5min 为 0–5 分,15min 为 0–15 分)。 |
|
||||
| 价格区间 | 如 `[0.92, 1]` 或「0.92 ~ 1」(maxPrice 为空时显示为 1)。 |
|
||||
| 投入方式 | 「比例 10%」或「固定 100 USDC」,用 `formatUSDC` 格式化金额。 |
|
||||
| 状态 | Tag 或 Switch:启用 / 停用。 |
|
||||
| 最近触发 | 最近一次触发时间(若有);无则「-」。 |
|
||||
| 操作 | 编辑、启用/停用、删除、查看触发记录。删除前 Popconfirm 二次确认。 |
|
||||
|
||||
### 2.3 与现有风格对齐
|
||||
|
||||
- 加载态:`Spin` 包裹列表。
|
||||
- 空状态:无数据时展示空状态插画 + 引导「新增策略」。
|
||||
- 响应式:`useMediaQuery({ maxWidth: 768 })`,桌面用 Table,移动用 Card + 操作折叠/抽屉。
|
||||
|
||||
参考:`CopyTradingList.tsx` 的 Table 列、Card 布局、筛选与 Modal 打开方式。
|
||||
|
||||
### 2.4 创建前检查:自动赎回配置(必须)
|
||||
|
||||
策略依赖**自动赎回**(需通过 Relayer/Builder API 提交链上赎回)。用户点击「新增策略」时:
|
||||
|
||||
1. **检查**:请求系统配置(如 `apiService.systemConfig.getConfig()` 或已有接口),判断是否已配置 Builder API Key(及可选:自动赎回已开启)。若 `builderApiKeyConfigured === false`(或后端约定之「未配置」状态),视为未配置。
|
||||
2. **未配置时**:不打开新增策略表单,改为弹出**简易弹窗**(Modal),内容建议:
|
||||
- **标题**:如「请先配置自动赎回」,`t('cryptoTailStrategy.redeemRequiredModal.title')`。
|
||||
- **正文**:简短说明尾盘策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`。
|
||||
- **操作**:
|
||||
- **去配置**:主按钮,点击后关闭弹窗并跳转到系统设置页(如 `/system-settings`,该页含 Relayer 配置与自动赎回开关)。
|
||||
- **取消**:次按钮或关闭图标,仅关闭弹窗。
|
||||
3. **已配置时**:正常打开新增策略表单弹窗。
|
||||
|
||||
弹窗保持简易,无需表单,仅提示 + 跳转;多语言键示例:`cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 新增 / 编辑策略弹窗(Modal)
|
||||
|
||||
**组件**:如 `CryptoTailStrategyFormModal.tsx` 或内嵌在列表页的 Modal。
|
||||
|
||||
### 3.1 表单字段
|
||||
|
||||
| 表单项 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| **钱包提示(简短)** | **Alert(Warning)** | - | 在「选择账户」上方或表单单列顶部展示简短提示:建议使用**专用钱包**,避免手动操作等导致异常。文案如 `t('cryptoTailStrategy.form.walletTip')`。 |
|
||||
| 策略名称 | Input | 否 | 用于列表展示,可占位「自动生成」。 |
|
||||
| 选择账户 | Select | 是 | 下拉已导入账户(与跟单一致,来自 `useAccountStore()` 或接口)。 |
|
||||
| 选择市场 | 市场选择器 | 是 | 仅展示 5/15 分钟加密市场;支持搜索;展示市场标题 + 周期(5min/15min);一个策略绑一个市场。 |
|
||||
| **时间区间** | **开始 / 结束** | 是 | 仅在本周期内的该时间窗口内,价格满足时才下单;区间外不处理。见下方说明。 |
|
||||
| 区间开始 | 下拉(分 + 秒) | 是 | 从周期起点起算的「开始」偏移。5 分钟市场可选 0~5 分 + 0~59 秒(总不超过 5 分钟);15 分钟市场可选 0~15 分 + 0~59 秒(总不超过 15 分钟)。 |
|
||||
| 区间结束 | 下拉(分 + 秒) | 是 | 从周期起点起算的「结束」偏移。范围同上,且**区间开始不得大于区间结束**(前端校验)。 |
|
||||
| 最低价 minPrice | InputNumber | 是 | 0~1,精度 2~4 位小数;校验 minPrice ≤ 1。 |
|
||||
| 最高价 maxPrice | InputNumber | 否 | 0~1,占位「不填默认为 1」;若填则校验 minPrice ≤ maxPrice ≤ 1。 |
|
||||
| 投入方式 | Radio.Group | 是 | 选项:「按比例」「固定金额」。 |
|
||||
| 比例 % | InputNumber | 条件必填 | 选「按比例」时显示;0~100;可展示当前账户 USDC 余额与预估金额。 |
|
||||
| 固定金额 (USDC) | InputNumber | 条件必填 | 选「固定金额」时显示;≥ 最小下单额,≤ 账户余额;用 `formatUSDC` 展示。 |
|
||||
| 启用状态 | Switch | 否 | 新增默认开启;编辑可切换。 |
|
||||
|
||||
**时间区间说明**:例如 15 分钟市场配置「3 分 0 秒」~「12 分 0 秒」,表示从周期开始后第 3 分钟到第 12 分钟之间,若价格进入 [minPrice, maxPrice] 才下单;第 0~3 分钟、第 12~15 分钟即使价格满足也不下单。5 分钟市场同理,可选 0~5 分钟内的一段(如 0~2、2~5)。前端用下拉选择「分钟」+「秒」,后端存为相对周期起点的秒数(如 windowStartSeconds、windowEndSeconds)。
|
||||
|
||||
### 3.2 校验与提交
|
||||
|
||||
- 提交前:市场为 5/15 分钟、**时间区间开始 ≤ 时间区间结束**、时间区间不超出周期长度(5min 市场结束 ≤ 5 分 0 秒,15min 市场结束 ≤ 15 分 0 秒)、minPrice 合法、maxPrice 若填则 ≥ minPrice、余额/比例合法。
|
||||
- 提交后:关闭弹窗、刷新列表、`message.success`;失败在表单上展示接口错误信息。
|
||||
|
||||
参考:`CopyTradingOrders/AddModal.tsx` 的 Form 布局、`Form.Item` + `rules`、条件显示(比例/固定金额)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 触发记录
|
||||
|
||||
**入口**:列表行操作「查看触发记录」或单独 Tab/页。
|
||||
|
||||
### 4.1 展示方式(二选一或并存)
|
||||
|
||||
- **弹窗**:Modal 内 Table,按策略 ID 拉取该策略的触发记录。
|
||||
- **独立页**:路由如 `/crypto-tail-strategy/records/:strategyId`,页面内 Table 或 Card 列表。
|
||||
|
||||
### 4.2 记录列表字段
|
||||
|
||||
| 列/项 | 说明 |
|
||||
|-------|------|
|
||||
| 触发时间 | 时间戳格式化为本地时间。 |
|
||||
| 市场 | 市场标题 + 周期。 |
|
||||
| 方向 (outcome) | Up / Down。 |
|
||||
| 触发价格 | 当时进入区间的价格。 |
|
||||
| 投入金额 | USDC,用 `formatUSDC`。 |
|
||||
| 订单 ID | 若有;可截断 + Tooltip 全量。 |
|
||||
| 状态 | 成功 / 失败。 |
|
||||
|
||||
支持按时间范围、状态筛选;移动端用 Card 或折叠列表。
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件与技术要点
|
||||
|
||||
| 要点 | 说明 |
|
||||
|------|------|
|
||||
| **钱包提示** | 列表页与新增/编辑表单**必须**包含「使用单独钱包」的 Alert 提示,避免用户用混用钱包导致异常;文案走多语言。 |
|
||||
| **创建前检查** | 点击「新增策略」时先检查自动赎回/Builder API 是否已配置;未配置则弹出简易「去配置」弹窗,引导用户到系统设置配置 API Key 与自动赎回,不打开策略表单。 |
|
||||
| 多语言 | 所有文案 `t('cryptoTailStrategy.xxx')`,在 `locales/zh-CN`、`zh-TW`、`en` 的 `common.json` 中增加键。需包含:`cryptoTailStrategy.list.walletTip`、`cryptoTailStrategy.form.walletTip`,以及 `cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。文案示例:列表页 `walletTip`:「请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。」表单内 `walletTip`:「建议使用专用钱包,避免手动操作等导致余额或下单异常。」未配置赎回弹窗 `title`:「请先配置自动赎回」;`description`:「尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。」;`goToSettings`:「去配置」;`cancel`:「取消」。 |
|
||||
| 金额 | 统一 `formatUSDC`(见 frontend.mdc)。 |
|
||||
| 响应式 | `useMediaQuery`;按钮触摸目标 ≥ 44px;移动端主操作突出。 |
|
||||
| 类型 | 不用 `any`;为策略、触发记录定义 TypeScript 类型。 |
|
||||
| API | 通过 `apiService` 封装(如 `apiService.cryptoTailStrategy.list/create/update/delete/records`)。 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 页面与文件建议对应
|
||||
|
||||
| 功能 | 建议路径/文件 |
|
||||
|------|----------------|
|
||||
| 列表页 | `frontend/src/pages/CryptoTailStrategyList.tsx` |
|
||||
| 未配置赎回时的简易弹窗 | 内嵌在列表页的 Modal,或 `CryptoTailStrategyList/RedeemRequiredModal.tsx` |
|
||||
| 新增/编辑弹窗 | `frontend/src/pages/CryptoTailStrategyList/FormModal.tsx` 或内嵌 Modal |
|
||||
| 触发记录 | `frontend/src/pages/CryptoTailStrategyList/TriggerRecordsModal.tsx` 或 `CryptoTailStrategyRecords.tsx` |
|
||||
| 路由 | `App.tsx` 中 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id` |
|
||||
| 菜单 | `Layout.tsx` 中增加「尾盘策略」菜单项 |
|
||||
| 类型 | `frontend/src/types/index.ts` 或 `types/cryptoTailStrategy.ts` 中增加策略与触发记录类型 |
|
||||
| 多语言 | `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 中增加 `cryptoTailStrategy.*` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 小结:UI 包含的主要元素
|
||||
|
||||
- **导航**:主导航中「尾盘策略」入口。
|
||||
- **列表页**:标题、钱包提示 Alert、新增按钮(点击前先检查赎回配置,未配置则弹「去配置」简易弹窗)、筛选、表格/卡片(策略名、市场、价格区间、投入方式、状态、最近触发、操作)、加载与空状态。
|
||||
- **未配置赎回弹窗**:简易 Modal,提示依赖自动赎回、需先配置 Builder API Key 与自动赎回;按钮「去配置」(跳转 `/system-settings`)、「取消」。
|
||||
- **表单弹窗**:策略名、账户、市场选择、minPrice/maxPrice、投入方式(比例/固定)、启用开关、提交/取消。
|
||||
- **触发记录**:时间、市场、outcome、触发价格、金额、订单 ID、状态;支持弹窗或独立页。
|
||||
- **通用**:Ant Design 组件、响应式、多语言、formatUSDC、TypeScript 类型。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A 后端/产品要求:自动赎回须支持本策略仓位
|
||||
|
||||
自动赎回逻辑**必须支持赎回由尾盘策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「尾盘策略」而被排除。后端实现时需保证:
|
||||
|
||||
- 尾盘策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回;
|
||||
- 若当前自动赎回按账户或仓位类型过滤,需将「尾盘策略订单产生的仓位」包含在内。
|
||||
|
||||
这样前端所依赖的「自动赎回」对该策略才完整有效。
|
||||
@@ -34,6 +34,7 @@ import RpcNodeSettings from './pages/RpcNodeSettings'
|
||||
import Announcements from './pages/Announcements'
|
||||
import BacktestList from './pages/BacktestList'
|
||||
import BacktestDetail from './pages/BacktestDetail'
|
||||
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
import { apiService } from './services/api'
|
||||
@@ -250,6 +251,7 @@ function App() {
|
||||
<Route path="/templates/add" element={<ProtectedRoute><TemplateAdd /></ProtectedRoute>} />
|
||||
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
|
||||
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
||||
{/* 保留旧路由以保持向后兼容 */}
|
||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd'
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Form, Input, Button, Radio, Space, Card, Spin, message, Alert, Steps, Tag } from 'antd'
|
||||
import { KeyOutlined, WalletOutlined, UserOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import {
|
||||
@@ -9,18 +9,19 @@ import {
|
||||
getPrivateKeyFromMnemonic,
|
||||
isValidWalletAddress,
|
||||
isValidPrivateKey,
|
||||
isValidMnemonic
|
||||
isValidMnemonic,
|
||||
formatUSDC
|
||||
} from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { apiService } from '../services/api'
|
||||
import type { ProxyOption } from '../types'
|
||||
|
||||
type ImportType = 'privateKey' | 'mnemonic'
|
||||
type WalletType = 'magic' | 'safe'
|
||||
|
||||
interface AccountImportFormProps {
|
||||
form: any
|
||||
onSuccess?: (accountId: number) => void
|
||||
onCancel?: () => void
|
||||
showAlert?: boolean
|
||||
showCancelButton?: boolean
|
||||
}
|
||||
|
||||
@@ -28,23 +29,33 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
form,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
showAlert = true,
|
||||
showCancelButton = true
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { importAccount, loading } = useAccountStore()
|
||||
const [importType, setImportType] = useState<ImportType>('privateKey')
|
||||
const [walletType, setWalletType] = useState<WalletType>('safe')
|
||||
const [derivedAddress, setDerivedAddress] = useState<string>('')
|
||||
const [addressError, setAddressError] = useState<string>('')
|
||||
const [proxyOptions, setProxyOptions] = useState<ProxyOption[]>([])
|
||||
const [selectedProxyType, setSelectedProxyType] = useState<string>('')
|
||||
const [loadingProxyOptions, setLoadingProxyOptions] = useState<boolean>(false)
|
||||
const [step, setStep] = useState<'input' | 'select'>('input') // 步骤:输入 -> 选择代理地址
|
||||
|
||||
// 当私钥输入时,自动推导地址
|
||||
// 当私钥输入时,自动推导地址(不支持换行,自动去除换行符)
|
||||
const handlePrivateKeyChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const privateKey = e.target.value.trim()
|
||||
const raw = e.target.value
|
||||
const normalized = raw.replace(/\r?\n/g, '')
|
||||
if (normalized !== raw) {
|
||||
form.setFieldsValue({ privateKey: normalized })
|
||||
}
|
||||
const privateKey = normalized.trim()
|
||||
if (!privateKey) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,6 +63,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
if (!isValidPrivateKey(privateKey)) {
|
||||
setAddressError(t('accountImport.privateKeyInvalid'))
|
||||
setDerivedAddress('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,18 +76,34 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
|
||||
// 延迟获取代理选项(避免频繁请求)
|
||||
setTimeout(() => {
|
||||
fetchProxyOptions(address, privateKey, null)
|
||||
}, 500)
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || t('accountImport.addressError'))
|
||||
setDerivedAddress('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
}
|
||||
}
|
||||
|
||||
// 当助记词输入时,自动推导地址
|
||||
// 当助记词输入时,自动推导地址(不支持换行,换行符转为空格)
|
||||
const handleMnemonicChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const mnemonic = e.target.value.trim()
|
||||
const raw = e.target.value
|
||||
const normalized = raw.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trimStart()
|
||||
if (/\r?\n/.test(raw)) {
|
||||
form.setFieldsValue({ mnemonic: normalized })
|
||||
}
|
||||
const mnemonic = normalized.trim()
|
||||
if (!mnemonic) {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,6 +111,9 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
if (!isValidMnemonic(mnemonic)) {
|
||||
setAddressError(t('accountImport.mnemonicInvalid'))
|
||||
setDerivedAddress('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -91,14 +124,85 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
|
||||
// 自动填充钱包地址字段
|
||||
form.setFieldsValue({ walletAddress: address })
|
||||
|
||||
// 延迟获取代理选项(避免频繁请求)
|
||||
setTimeout(() => {
|
||||
fetchProxyOptions(address, null, mnemonic)
|
||||
}, 500)
|
||||
} catch (error: any) {
|
||||
setAddressError(error.message || t('accountImport.addressErrorMnemonic'))
|
||||
setDerivedAddress('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取代理地址选项
|
||||
const fetchProxyOptions = async (walletAddress: string, privateKey: string | null, mnemonic: string | null) => {
|
||||
if (!walletAddress || (!privateKey && !mnemonic)) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingProxyOptions(true)
|
||||
try {
|
||||
const response = await apiService.accounts.checkProxyOptions({
|
||||
walletAddress,
|
||||
privateKey: privateKey || undefined,
|
||||
mnemonic: mnemonic || undefined
|
||||
})
|
||||
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const options = response.data.data.options || []
|
||||
setProxyOptions(options)
|
||||
|
||||
// 如果有选项,进入选择步骤
|
||||
if (options.length > 0) {
|
||||
setStep('select')
|
||||
// 如果有资产,默认选择第一个有资产的选项
|
||||
const hasAssetsOption = options.find((opt: ProxyOption) => opt.hasAssets)
|
||||
if (hasAssetsOption) {
|
||||
setSelectedProxyType(hasAssetsOption.walletType)
|
||||
} else {
|
||||
// 否则选择第一个选项
|
||||
setSelectedProxyType(options[0].walletType)
|
||||
}
|
||||
} else {
|
||||
setStep('input')
|
||||
message.warning(t('accountImport.proxyOption.error') || '未获取到代理地址选项')
|
||||
}
|
||||
} else {
|
||||
setProxyOptions([])
|
||||
setStep('input')
|
||||
message.error(response.data.msg || '获取代理地址选项失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
setProxyOptions([])
|
||||
setStep('input')
|
||||
message.error(error.message || '获取代理地址选项失败')
|
||||
} finally {
|
||||
setLoadingProxyOptions(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换导入方式时重置状态
|
||||
useEffect(() => {
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
setProxyOptions([])
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [importType])
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
// 如果还在输入步骤,需要先选择代理地址
|
||||
if (step === 'input' || !selectedProxyType) {
|
||||
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
|
||||
}
|
||||
|
||||
let privateKey: string
|
||||
let walletAddress: string
|
||||
|
||||
@@ -124,14 +228,11 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
// 如果用户手动输入了地址,验证是否与推导的地址一致
|
||||
if (values.walletAddress) {
|
||||
if (values.walletAddress !== derivedAddressFromMnemonic) {
|
||||
// 地址不匹配,使用推导的地址(因为私钥是从助记词导出的,必须使用对应的地址)
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
} else {
|
||||
// 地址匹配,使用用户输入的地址
|
||||
walletAddress = values.walletAddress
|
||||
}
|
||||
} else {
|
||||
// 如果用户没有输入地址,使用推导的地址
|
||||
walletAddress = derivedAddressFromMnemonic
|
||||
}
|
||||
}
|
||||
@@ -145,14 +246,13 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
privateKey: privateKey,
|
||||
walletAddress: walletAddress,
|
||||
accountName: values.accountName,
|
||||
walletType: walletType
|
||||
walletType: selectedProxyType
|
||||
})
|
||||
|
||||
// 等待store更新
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 获取新添加的账户ID(通过API获取,因为store可能还没更新)
|
||||
const { apiService } = await import('../services/api')
|
||||
const accountsResponse = await apiService.accounts.list()
|
||||
if (accountsResponse.data.code === 0 && accountsResponse.data.data) {
|
||||
const newAccounts = accountsResponse.data.data.list || []
|
||||
@@ -160,76 +260,53 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
if (newAccount && onSuccess) {
|
||||
onSuccess(newAccount.id)
|
||||
} else if (onSuccess) {
|
||||
// 如果找不到账户,仍然调用onSuccess(可能在其他地方处理)
|
||||
onSuccess(0)
|
||||
}
|
||||
} else if (onSuccess) {
|
||||
// API调用失败,仍然调用onSuccess
|
||||
onSuccess(0)
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error & { code?: number }
|
||||
const isDuplicate = err?.code === 4601
|
||||
message.error(isDuplicate ? t('accountImport.duplicateAccount') : (err?.message ?? t('accountImport.importFailed')))
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
const currentStep = step === 'input' ? 0 : 1
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAlert && (
|
||||
<Alert
|
||||
message={t('accountImport.securityTip')}
|
||||
description={t('accountImport.securityTipDesc')}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ padding: isMobile ? '0 4px' : '0 8px' }}>
|
||||
<Steps
|
||||
current={currentStep}
|
||||
size="small"
|
||||
style={{ marginBottom: 24 }}
|
||||
items={[
|
||||
{ title: t('accountImport.importMethod'), icon: <KeyOutlined /> },
|
||||
{ title: t('accountImport.selectProxyOption'), icon: <WalletOutlined /> },
|
||||
{ title: t('accountImport.accountName'), icon: <UserOutlined /> }
|
||||
]}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Form.Item label={t('accountImport.importMethod')}>
|
||||
<Form.Item label={t('accountImport.importMethod')} style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={importType}
|
||||
onChange={(e) => {
|
||||
setImportType(e.target.value)
|
||||
setDerivedAddress('')
|
||||
setAddressError('')
|
||||
form.setFieldsValue({ walletAddress: '' })
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
<Radio value="privateKey">{t('accountImport.privateKey')}</Radio>
|
||||
<Radio value="mnemonic">{t('accountImport.mnemonic')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
{t('accountImport.walletType')}{' '}
|
||||
<Tooltip
|
||||
title={t('accountImport.walletTypeHelp')}
|
||||
overlayInnerStyle={{ whiteSpace: 'pre-line', maxWidth: '300px' }}
|
||||
>
|
||||
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Radio.Group
|
||||
value={walletType}
|
||||
onChange={(e) => setWalletType(e.target.value)}
|
||||
>
|
||||
<Radio value="safe">
|
||||
{t('accountImport.walletTypeSafe')}
|
||||
</Radio>
|
||||
<Radio value="magic" disabled>
|
||||
{t('accountImport.walletTypeMagic')} {t('accountImport.magicNotSupported')}
|
||||
</Radio>
|
||||
<Radio.Button value="privateKey">{t('accountImport.privateKey')}</Radio.Button>
|
||||
<Radio.Button value="mnemonic">{t('accountImport.mnemonic')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
@@ -250,13 +327,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
|
||||
help={addressError || ''}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
rows={2}
|
||||
placeholder={t('accountImport.privateKeyPlaceholder')}
|
||||
onChange={handlePrivateKeyChange}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
||||
disabled={loadingProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -282,6 +361,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
<Input
|
||||
placeholder={t('accountImport.walletAddressPlaceholder')}
|
||||
readOnly={!!derivedAddress}
|
||||
disabled={loadingProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
@@ -302,13 +382,15 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
}
|
||||
}
|
||||
]}
|
||||
help={addressError || (derivedAddress ? `${t('accountImport.derivedAddress')}: ${derivedAddress}` : '')}
|
||||
help={addressError || ''}
|
||||
validateStatus={addressError ? 'error' : derivedAddress ? 'success' : ''}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
rows={2}
|
||||
placeholder={t('accountImport.mnemonicPlaceholder')}
|
||||
onChange={handleMnemonicChange}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
||||
disabled={loadingProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -334,39 +416,143 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
<Input
|
||||
placeholder={t('accountImport.walletAddressPlaceholder')}
|
||||
readOnly={!!derivedAddress}
|
||||
disabled={loadingProxyOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 请求代理地址时的 loading 提示 */}
|
||||
{loadingProxyOptions && step === 'input' && (
|
||||
<Form.Item>
|
||||
<Alert
|
||||
message={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Spin size="small" />
|
||||
<span>{t('accountImport.loadingProxyOptions')}</span>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon={false}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 代理地址选项选择 */}
|
||||
{step === 'select' && (
|
||||
<Form.Item
|
||||
label={t('accountImport.selectProxyOption')}
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
validator: () => {
|
||||
if (!selectedProxyType) {
|
||||
return Promise.reject(new Error(t('accountImport.proxyOptionRequired')))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}
|
||||
style={{ marginBottom: 20 }}
|
||||
>
|
||||
{loadingProxyOptions ? (
|
||||
<div style={{ padding: '32px 0', textAlign: 'center' }}>
|
||||
<Spin tip={t('accountImport.loadingProxyOptions')} />
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
{proxyOptions.map((option) => {
|
||||
const isSelected = selectedProxyType === option.walletType
|
||||
const typeLabel = option.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'
|
||||
return (
|
||||
<Card
|
||||
key={option.walletType}
|
||||
hoverable
|
||||
onClick={() => setSelectedProxyType(option.walletType)}
|
||||
size="small"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: isSelected ? 'var(--ant-color-primary)' : undefined,
|
||||
borderWidth: isSelected ? 2 : 1,
|
||||
backgroundColor: isSelected ? 'var(--ant-color-primary-bg)' : undefined,
|
||||
transition: 'border-color 0.2s, background-color 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Space size="middle">
|
||||
<Radio checked={isSelected} />
|
||||
<Tag color={option.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
|
||||
{typeLabel}
|
||||
</Tag>
|
||||
{option.hasAssets && (
|
||||
<span style={{ color: '#52c41a', fontSize: 12 }}>
|
||||
<CheckCircleOutlined /> {t('accountImport.proxyOption.hasAssets')}
|
||||
</span>
|
||||
)}
|
||||
{option.error && (
|
||||
<span style={{ color: 'var(--ant-color-error)', fontSize: 12 }}>
|
||||
<ExclamationCircleOutlined /> {t('accountImport.proxyOption.error')}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
{!option.error && (
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ant-color-primary)' }}>
|
||||
{formatUSDC(option.totalBalance)} USDC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', wordBreak: 'break-all' }}>
|
||||
{option.proxyAddress ? (
|
||||
<span style={{ fontFamily: 'monospace' }}>{option.proxyAddress}</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
{option.error && (
|
||||
<span style={{ color: 'var(--ant-color-error)', marginLeft: 8 }}>{option.error}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, marginLeft: 28, fontSize: 12, color: 'var(--ant-color-text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('accountImport.proxyOption.proxyAddressHelp')}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={t('accountImport.accountName')}
|
||||
name="accountName"
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Input placeholder={t('accountImport.accountNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
disabled={step !== 'select' || !selectedProxyType || loadingProxyOptions}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
style={isMobile ? { minHeight: 44 } : undefined}
|
||||
>
|
||||
{t('accountImport.importAccount')}
|
||||
</Button>
|
||||
{showCancelButton && onCancel && (
|
||||
<Button onClick={onCancel}>
|
||||
<Button onClick={onCancel} size={isMobile ? 'middle' : 'large'}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountImportForm
|
||||
|
||||
|
||||
@@ -157,6 +157,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/crypto-tail-strategy',
|
||||
icon: <LineChartOutlined />,
|
||||
label: t('menu.cryptoTailStrategy')
|
||||
},
|
||||
{
|
||||
key: '/positions',
|
||||
icon: <UnorderedListOutlined />,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"accountId": "Account ID",
|
||||
"accountName": "Account Name",
|
||||
"walletAddress": "Wallet Address",
|
||||
"walletType": "Wallet Type",
|
||||
"balance": "Account Balance",
|
||||
"refreshBalance": "Refresh Balance",
|
||||
"apiCredentials": "API Credentials Configuration",
|
||||
@@ -122,6 +123,7 @@
|
||||
"importAccount": "Import Account",
|
||||
"accountName": "Account Name",
|
||||
"walletAddress": "Wallet Address",
|
||||
"walletType": "Wallet Type",
|
||||
"proxyAddress": "Proxy Wallet Address",
|
||||
"apiCredentials": "API Credentials",
|
||||
"balance": "Balance",
|
||||
@@ -178,8 +180,6 @@
|
||||
"accountImport": {
|
||||
"title": "Import Account",
|
||||
"back": "Back",
|
||||
"securityTip": "Security Tip",
|
||||
"securityTipDesc": "Private keys will be stored in the backend database. Please ensure database access is secure. HTTPS is recommended.",
|
||||
"importMethod": "Import Method",
|
||||
"privateKey": "Private Key",
|
||||
"mnemonic": "Mnemonic",
|
||||
@@ -213,9 +213,29 @@
|
||||
"addressErrorMnemonic": "Cannot derive address from mnemonic",
|
||||
"walletType": "Wallet Type",
|
||||
"walletTypeHelp": "Web3 Wallet: Polymarket accounts connected via browser wallets like MetaMask\nMagic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.)",
|
||||
"walletTypeMagic": "Magic (Email/Social Login)",
|
||||
"walletTypeSafe": "Web3 Wallet",
|
||||
"magicNotSupported": "(Not Supported)"
|
||||
"loadingProxyOptions": "Loading proxy addresses and asset information...",
|
||||
"selectProxyOption": "Please select account type",
|
||||
"proxyOptionRequired": "Please select a proxy address",
|
||||
"proxyOption": {
|
||||
"magic": {
|
||||
"description": "Email/Social Login Account (Magic)",
|
||||
"title": "Magic Proxy Address"
|
||||
},
|
||||
"safe": {
|
||||
"description": "MetaMask Browser Wallet Account (Safe)",
|
||||
"title": "Safe Proxy Address"
|
||||
},
|
||||
"proxyAddress": "Proxy Address",
|
||||
"proxyAddressHelp": "The proxy address is the wallet address you actually use on Polymarket: Magic for email/social login accounts, Safe for browser wallet (e.g. MetaMask) accounts. Please select the one that matches how you log in to Polymarket.",
|
||||
"availableBalance": "Available Balance",
|
||||
"positionBalance": "Position Balance",
|
||||
"totalBalance": "Total Balance",
|
||||
"positionCount": "Position Count",
|
||||
"noAssets": "No Assets",
|
||||
"hasAssets": "Has Assets",
|
||||
"error": "Failed to fetch",
|
||||
"select": "Select this proxy address"
|
||||
}
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader Management",
|
||||
@@ -246,6 +266,7 @@
|
||||
"leaders": "Leader Management",
|
||||
"templates": "Templates",
|
||||
"copyTradingConfig": "Copy Trading Config",
|
||||
"cryptoTailStrategy": "Tail Strategy",
|
||||
"positions": "Position Management",
|
||||
"backtest": "Backtest",
|
||||
"statistics": "Statistics",
|
||||
@@ -1363,6 +1384,10 @@
|
||||
"fixedAmountRequired": "Please enter fixed amount",
|
||||
"fixedAmountInvalid": "Fixed amount must be greater than 0",
|
||||
"priceFilters": "Price Filters",
|
||||
"priceRange": "Price Range",
|
||||
"priceRangeTooltip": "Only copy Leader orders with price within the specified range. Leave empty to disable. Example: Enter 0.11 and 0.89 to only copy orders with price between 0.11 and 0.89; enter only max price 0.89 to only copy orders with price below 0.89; enter only min price 0.11 to only copy orders with price above 0.11.",
|
||||
"minPricePlaceholder": "Min price (leave empty to disable)",
|
||||
"maxPricePlaceholder": "Max price (leave empty to disable)",
|
||||
"keywordsPlaceholder": "Please enter keywords, press Enter to add",
|
||||
"maxPositionValuePlaceholder": "Leave empty to disable max position limit",
|
||||
"delaySecondsHint": "Delay execution to simulate real copy trading delay",
|
||||
@@ -1375,5 +1400,95 @@
|
||||
"rerunTaskNamePlaceholder": "New task name (leave empty for \"Original name (copy)\")",
|
||||
"rerunSuccess": "New backtest task created",
|
||||
"rerunFailed": "Re-run failed"
|
||||
},
|
||||
"cryptoTailStrategy": {
|
||||
"binanceApiAlert": {
|
||||
"title": "Cannot connect to Binance API — strategy cannot run for now",
|
||||
"description": "Tail strategy needs Binance market data to work. The connection failed; this may be a network issue or Binance outage. Try again later by clicking the button below.",
|
||||
"recheck": "Re-check"
|
||||
},
|
||||
"list": {
|
||||
"title": "Crypto Tail Strategy",
|
||||
"walletTip": "Use a dedicated wallet for tail strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.",
|
||||
"addStrategy": "Add Strategy",
|
||||
"strategyName": "Strategy Name",
|
||||
"account": "Account",
|
||||
"market": "Market",
|
||||
"timeWindow": "Time Window",
|
||||
"priceRange": "Price Range",
|
||||
"amountMode": "Amount Mode",
|
||||
"ratio": "Ratio",
|
||||
"fixed": "Fixed",
|
||||
"totalRealizedPnl": "Total PnL",
|
||||
"winRate": "Win Rate",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"delete": "Delete",
|
||||
"viewTriggers": "Orders",
|
||||
"deleteConfirm": "Delete this strategy?",
|
||||
"fetchFailed": "Failed to fetch list"
|
||||
},
|
||||
"form": {
|
||||
"walletTip": "Use a dedicated wallet to avoid balance or order issues.",
|
||||
"strategyName": "Strategy Name",
|
||||
"strategyNamePlaceholder": "Optional, leave blank to auto-generate",
|
||||
"selectAccount": "Select Account",
|
||||
"selectMarket": "Select Market",
|
||||
"timeWindowStart": "Window Start",
|
||||
"timeWindowEnd": "Window End",
|
||||
"minute": "min",
|
||||
"second": "sec",
|
||||
"minPrice": "Min Price",
|
||||
"maxPrice": "Max Price",
|
||||
"maxPricePlaceholder": "Default 1",
|
||||
"amountMode": "Amount Mode",
|
||||
"ratioPercent": "Ratio %",
|
||||
"fixedUsdc": "Fixed (USDC)",
|
||||
"enabled": "Enabled",
|
||||
"create": "Create",
|
||||
"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"
|
||||
},
|
||||
"redeemRequiredModal": {
|
||||
"title": "Configure Auto Redeem First",
|
||||
"description": "Tail strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.",
|
||||
"goToSettings": "Go to Settings",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"triggerRecords": {
|
||||
"title": "Trigger Records",
|
||||
"timeRange": "Time Range",
|
||||
"startDate": "Start Date",
|
||||
"endDate": "End Date",
|
||||
"successTab": "Success",
|
||||
"failTab": "Failed",
|
||||
"triggerTime": "Time",
|
||||
"market": "Market",
|
||||
"direction": "Direction",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"triggerPrice": "Trigger Price",
|
||||
"amount": "Amount",
|
||||
"orderId": "Order ID",
|
||||
"status": "Status",
|
||||
"success": "Success",
|
||||
"fail": "Fail",
|
||||
"failReason": "Failure Reason",
|
||||
"realizedPnl": "PnL",
|
||||
"resolved": "Settled",
|
||||
"emptySuccess": "No success records",
|
||||
"emptyFail": "No failed records",
|
||||
"totalCount": "{count} record(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@
|
||||
"importAccount": "导入账户",
|
||||
"accountName": "账户名称",
|
||||
"walletAddress": "钱包地址",
|
||||
"walletType": "钱包类型",
|
||||
"proxyAddress": "代理钱包地址",
|
||||
"apiCredentials": "API 凭证",
|
||||
"balance": "余额",
|
||||
@@ -178,8 +179,6 @@
|
||||
"accountImport": {
|
||||
"title": "导入账户",
|
||||
"back": "返回",
|
||||
"securityTip": "安全提示",
|
||||
"securityTipDesc": "私钥将存储在后端数据库中,请确保数据库访问安全。建议使用 HTTPS 连接。",
|
||||
"importMethod": "导入方式",
|
||||
"privateKey": "私钥",
|
||||
"mnemonic": "助记词",
|
||||
@@ -213,9 +212,29 @@
|
||||
"addressErrorMnemonic": "无法从助记词推导地址",
|
||||
"walletType": "钱包类型",
|
||||
"walletTypeHelp": "Web3钱包:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户\nMagic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户",
|
||||
"walletTypeMagic": "Magic(邮箱/社交账号登录)",
|
||||
"walletTypeSafe": "Web3钱包",
|
||||
"magicNotSupported": "(暂不支持)"
|
||||
"loadingProxyOptions": "正在获取代理地址和资产信息...",
|
||||
"selectProxyOption": "请选择账户类型",
|
||||
"proxyOptionRequired": "请选择一个代理地址",
|
||||
"proxyOption": {
|
||||
"magic": {
|
||||
"description": "邮箱/社交账号登录账户(Magic)",
|
||||
"title": "Magic 代理地址"
|
||||
},
|
||||
"safe": {
|
||||
"description": "MetaMask 等浏览器钱包账户(Safe)",
|
||||
"title": "Safe 代理地址"
|
||||
},
|
||||
"proxyAddress": "代理地址",
|
||||
"proxyAddressHelp": "代理地址是您在 Polymarket 上实际使用的钱包地址:Magic 为邮箱/社交登录账户,Safe 为浏览器钱包(如 MetaMask)账户。请选择与您 Polymarket 登录方式一致的一项。",
|
||||
"availableBalance": "可用余额",
|
||||
"positionBalance": "仓位余额",
|
||||
"totalBalance": "总余额",
|
||||
"positionCount": "持仓数量",
|
||||
"noAssets": "无资产",
|
||||
"hasAssets": "有资产",
|
||||
"error": "获取失败",
|
||||
"select": "选择此代理地址"
|
||||
}
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
@@ -246,6 +265,7 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟单模板",
|
||||
"copyTradingConfig": "跟单配置",
|
||||
"cryptoTailStrategy": "尾盘策略",
|
||||
"positions": "仓位管理",
|
||||
"backtest": "回测",
|
||||
"statistics": "统计信息",
|
||||
@@ -1363,6 +1383,10 @@
|
||||
"fixedAmountRequired": "请输入固定金额",
|
||||
"fixedAmountInvalid": "固定金额必须大于 0",
|
||||
"priceFilters": "价格过滤",
|
||||
"priceRange": "价格区间",
|
||||
"priceRangeTooltip": "仅跟单 Leader 交易价格在指定区间内的订单。不填写表示不限制。示例:填写 0.11 和 0.89 表示仅跟单价格在 0.11 到 0.89 之间的订单;只填写最高价 0.89 表示仅跟单价格在 0.89 以下的订单;只填写最低价 0.11 表示仅跟单价格在 0.11 以上的订单。",
|
||||
"minPricePlaceholder": "最低价(留空不限制)",
|
||||
"maxPricePlaceholder": "最高价(留空不限制)",
|
||||
"keywordsPlaceholder": "请输入关键字,按回车添加",
|
||||
"maxPositionValuePlaceholder": "留空表示不启用最大仓位限制",
|
||||
"delaySecondsHint": "延迟执行模拟真实跟单延迟",
|
||||
@@ -1375,5 +1399,95 @@
|
||||
"rerunTaskNamePlaceholder": "新任务名称(留空使用「原名称 (副本)」)",
|
||||
"rerunSuccess": "已创建新回测任务",
|
||||
"rerunFailed": "重新测试失败"
|
||||
},
|
||||
"cryptoTailStrategy": {
|
||||
"binanceApiAlert": {
|
||||
"title": "无法连接币安 API,策略暂时不能运行",
|
||||
"description": "尾盘策略需要从币安获取行情数据才能工作。当前连接失败,可能是网络问题或币安服务异常,请稍后点击下方按钮重新检测。",
|
||||
"recheck": "重新检测"
|
||||
},
|
||||
"list": {
|
||||
"title": "加密尾盘策略",
|
||||
"walletTip": "请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。",
|
||||
"addStrategy": "新增策略",
|
||||
"strategyName": "策略名称",
|
||||
"account": "账户",
|
||||
"market": "关联市场",
|
||||
"timeWindow": "时间区间",
|
||||
"priceRange": "价格区间",
|
||||
"amountMode": "投入方式",
|
||||
"ratio": "比例",
|
||||
"fixed": "固定金额",
|
||||
"totalRealizedPnl": "总收益",
|
||||
"winRate": "胜率",
|
||||
"actions": "操作",
|
||||
"edit": "编辑",
|
||||
"enable": "启用",
|
||||
"disable": "停用",
|
||||
"delete": "删除",
|
||||
"viewTriggers": "订单",
|
||||
"deleteConfirm": "确定删除该策略?",
|
||||
"fetchFailed": "获取列表失败"
|
||||
},
|
||||
"form": {
|
||||
"walletTip": "建议使用专用钱包,避免手动操作等导致余额或下单异常。",
|
||||
"strategyName": "策略名称",
|
||||
"strategyNamePlaceholder": "选填,留空将自动生成",
|
||||
"selectAccount": "选择账户",
|
||||
"selectMarket": "选择市场",
|
||||
"timeWindowStart": "区间开始",
|
||||
"timeWindowEnd": "区间结束",
|
||||
"minute": "分",
|
||||
"second": "秒",
|
||||
"minPrice": "最低价",
|
||||
"maxPrice": "最高价",
|
||||
"maxPricePlaceholder": "不填默认为 1",
|
||||
"amountMode": "投入方式",
|
||||
"ratioPercent": "比例 %",
|
||||
"fixedUsdc": "固定金额 (USDC)",
|
||||
"enabled": "启用",
|
||||
"create": "创建",
|
||||
"update": "更新",
|
||||
"timeWindowStartLEEnd": "时间区间开始不能大于结束",
|
||||
"timeWindowExceed": "时间区间不能超过周期长度",
|
||||
"minSpreadMode": "最小价差",
|
||||
"minSpreadModeTip": "根据当前周期开盘价与收盘价的价差决定是否下单。自动:系统按历史 20 根 K 线计算建议价差(每周期更新);固定:您输入一个数值(如 30),仅当价差 ≥ 该值时才下单;无:不校验价差,满足价格区间即下单。",
|
||||
"minSpreadModeNone": "无",
|
||||
"minSpreadModeFixed": "固定",
|
||||
"minSpreadModeAuto": "自动",
|
||||
"minSpreadValue": "最小价差数值 (USDC)",
|
||||
"minSpreadValuePlaceholder": "如 30"
|
||||
},
|
||||
"redeemRequiredModal": {
|
||||
"title": "请先配置自动赎回",
|
||||
"description": "尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。",
|
||||
"goToSettings": "去配置",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"triggerRecords": {
|
||||
"title": "触发记录",
|
||||
"timeRange": "时间范围",
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期",
|
||||
"successTab": "成功",
|
||||
"failTab": "失败",
|
||||
"triggerTime": "触发时间",
|
||||
"market": "市场",
|
||||
"direction": "方向",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"triggerPrice": "触发价格",
|
||||
"amount": "投入金额",
|
||||
"orderId": "订单 ID",
|
||||
"status": "状态",
|
||||
"success": "成功",
|
||||
"fail": "失败",
|
||||
"failReason": "失败原因",
|
||||
"realizedPnl": "收益",
|
||||
"resolved": "已结算",
|
||||
"emptySuccess": "暂无成功记录",
|
||||
"emptyFail": "暂无失败记录",
|
||||
"totalCount": "共 {count} 条"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
"accountId": "賬戶ID",
|
||||
"accountName": "賬戶名稱",
|
||||
"walletAddress": "錢包地址",
|
||||
"walletType": "錢包類型",
|
||||
"balance": "賬戶餘額",
|
||||
"refreshBalance": "刷新餘額",
|
||||
"apiCredentials": "API 憑證配置",
|
||||
@@ -122,6 +123,7 @@
|
||||
"importAccount": "導入賬戶",
|
||||
"accountName": "賬戶名稱",
|
||||
"walletAddress": "錢包地址",
|
||||
"walletType": "錢包類型",
|
||||
"proxyAddress": "代理錢包地址",
|
||||
"apiCredentials": "API 憑證",
|
||||
"balance": "餘額",
|
||||
@@ -178,8 +180,6 @@
|
||||
"accountImport": {
|
||||
"title": "導入賬戶",
|
||||
"back": "返回",
|
||||
"securityTip": "安全提示",
|
||||
"securityTipDesc": "私鑰將存儲在後端數據庫中,請確保數據庫訪問安全。建議使用 HTTPS 連接。",
|
||||
"importMethod": "導入方式",
|
||||
"privateKey": "私鑰",
|
||||
"mnemonic": "助記詞",
|
||||
@@ -213,9 +213,29 @@
|
||||
"addressErrorMnemonic": "無法從助記詞推導地址",
|
||||
"walletType": "錢包類型",
|
||||
"walletTypeHelp": "Web3錢包:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶\nMagic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶",
|
||||
"walletTypeMagic": "Magic(郵箱/社群帳號登入)",
|
||||
"walletTypeSafe": "Web3錢包",
|
||||
"magicNotSupported": "(暫不支持)"
|
||||
"loadingProxyOptions": "正在獲取代理地址和資產信息...",
|
||||
"selectProxyOption": "請選擇帳戶類型",
|
||||
"proxyOptionRequired": "請選擇一個代理地址",
|
||||
"proxyOption": {
|
||||
"magic": {
|
||||
"description": "郵箱/社群帳號登入帳戶(Magic)",
|
||||
"title": "Magic 代理地址"
|
||||
},
|
||||
"safe": {
|
||||
"description": "MetaMask 等瀏覽器錢包帳戶(Safe)",
|
||||
"title": "Safe 代理地址"
|
||||
},
|
||||
"proxyAddress": "代理地址",
|
||||
"proxyAddressHelp": "代理地址是您在 Polymarket 上實際使用的錢包地址:Magic 為郵箱/社群登入帳戶,Safe 為瀏覽器錢包(如 MetaMask)帳戶。請選擇與您 Polymarket 登入方式一致的一項。",
|
||||
"availableBalance": "可用餘額",
|
||||
"positionBalance": "倉位餘額",
|
||||
"totalBalance": "總餘額",
|
||||
"positionCount": "持倉數量",
|
||||
"noAssets": "無資產",
|
||||
"hasAssets": "有資產",
|
||||
"error": "獲取失敗",
|
||||
"select": "選擇此代理地址"
|
||||
}
|
||||
},
|
||||
"leader": {
|
||||
"title": "Leader 管理",
|
||||
@@ -246,6 +266,7 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟單模板",
|
||||
"copyTradingConfig": "跟單配置",
|
||||
"cryptoTailStrategy": "尾盤策略",
|
||||
"positions": "倉位管理",
|
||||
"backtest": "回測",
|
||||
"statistics": "統計信息",
|
||||
@@ -1363,6 +1384,10 @@
|
||||
"fixedAmountRequired": "請輸入固定金額",
|
||||
"fixedAmountInvalid": "固定金額必須大於 0",
|
||||
"priceFilters": "價格過濾",
|
||||
"priceRange": "價格區間",
|
||||
"priceRangeTooltip": "僅跟單 Leader 交易價格在指定區間內的訂單。不填寫表示不限制。示例:填寫 0.11 和 0.89 表示僅跟單價格在 0.11 到 0.89 之間的訂單;只填寫最高價 0.89 表示僅跟單價格在 0.89 以下的訂單;只填寫最低價 0.11 表示僅跟單價格在 0.11 以上的訂單。",
|
||||
"minPricePlaceholder": "最低價(留空不限制)",
|
||||
"maxPricePlaceholder": "最高價(留空不限制)",
|
||||
"keywordsPlaceholder": "請輸入關鍵字,按回車添加",
|
||||
"maxPositionValuePlaceholder": "留空表示不啟用最大倉位限制",
|
||||
"delaySecondsHint": "延遲執行模擬真實跟單延遲",
|
||||
@@ -1375,5 +1400,95 @@
|
||||
"rerunTaskNamePlaceholder": "新任務名稱(留空使用「原名稱 (副本)」)",
|
||||
"rerunSuccess": "已創建新回測任務",
|
||||
"rerunFailed": "重新測試失敗"
|
||||
},
|
||||
"cryptoTailStrategy": {
|
||||
"binanceApiAlert": {
|
||||
"title": "無法連接幣安 API,策略暫時不能運行",
|
||||
"description": "尾盤策略需要從幣安取得行情資料才能運作。目前連線失敗,可能是網路問題或幣安服務異常,請稍後點擊下方按鈕重新檢測。",
|
||||
"recheck": "重新檢測"
|
||||
},
|
||||
"list": {
|
||||
"title": "加密尾盤策略",
|
||||
"walletTip": "請使用單獨的錢包運行尾盤策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。",
|
||||
"addStrategy": "新增策略",
|
||||
"strategyName": "策略名稱",
|
||||
"account": "賬戶",
|
||||
"market": "關聯市場",
|
||||
"timeWindow": "時間區間",
|
||||
"priceRange": "價格區間",
|
||||
"amountMode": "投入方式",
|
||||
"ratio": "比例",
|
||||
"fixed": "固定金額",
|
||||
"totalRealizedPnl": "總收益",
|
||||
"winRate": "勝率",
|
||||
"actions": "操作",
|
||||
"edit": "編輯",
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"delete": "刪除",
|
||||
"viewTriggers": "訂單",
|
||||
"deleteConfirm": "確定刪除該策略?",
|
||||
"fetchFailed": "獲取列表失敗"
|
||||
},
|
||||
"form": {
|
||||
"walletTip": "建議使用專用錢包,避免手動操作等導致餘額或下單異常。",
|
||||
"strategyName": "策略名稱",
|
||||
"strategyNamePlaceholder": "選填,留空將自動生成",
|
||||
"selectAccount": "選擇賬戶",
|
||||
"selectMarket": "選擇市場",
|
||||
"timeWindowStart": "區間開始",
|
||||
"timeWindowEnd": "區間結束",
|
||||
"minute": "分",
|
||||
"second": "秒",
|
||||
"minPrice": "最低價",
|
||||
"maxPrice": "最高價",
|
||||
"maxPricePlaceholder": "不填默認為 1",
|
||||
"amountMode": "投入方式",
|
||||
"ratioPercent": "比例 %",
|
||||
"fixedUsdc": "固定金額 (USDC)",
|
||||
"enabled": "啟用",
|
||||
"create": "創建",
|
||||
"update": "更新",
|
||||
"timeWindowStartLEEnd": "時間區間開始不能大於結束",
|
||||
"timeWindowExceed": "時間區間不能超過週期長度",
|
||||
"minSpreadMode": "最小價差",
|
||||
"minSpreadModeTip": "依當前週期開盤價與收盤價的價差決定是否下單。自動:系統依歷史 20 根 K 線計算建議價差(每週期更新);固定:您輸入一個數值(如 30),僅當價差 ≥ 該值時才下單;無:不校驗價差,滿足價格區間即下單。",
|
||||
"minSpreadModeNone": "無",
|
||||
"minSpreadModeFixed": "固定",
|
||||
"minSpreadModeAuto": "自動",
|
||||
"minSpreadValue": "最小價差數值 (USDC)",
|
||||
"minSpreadValuePlaceholder": "如 30"
|
||||
},
|
||||
"redeemRequiredModal": {
|
||||
"title": "請先配置自動贖回",
|
||||
"description": "尾盤策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。",
|
||||
"goToSettings": "去配置",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"triggerRecords": {
|
||||
"title": "觸發記錄",
|
||||
"timeRange": "時間範圍",
|
||||
"startDate": "開始日期",
|
||||
"endDate": "結束日期",
|
||||
"successTab": "成功",
|
||||
"failTab": "失敗",
|
||||
"triggerTime": "觸發時間",
|
||||
"market": "市場",
|
||||
"direction": "方向",
|
||||
"up": "Up",
|
||||
"down": "Down",
|
||||
"triggerPrice": "觸發價格",
|
||||
"amount": "投入金額",
|
||||
"orderId": "訂單 ID",
|
||||
"status": "狀態",
|
||||
"success": "成功",
|
||||
"fail": "失敗",
|
||||
"failReason": "失敗原因",
|
||||
"realizedPnl": "收益",
|
||||
"resolved": "已結算",
|
||||
"emptySuccess": "暫無成功記錄",
|
||||
"emptyFail": "暫無失敗記錄",
|
||||
"totalCount": "共 {count} 條"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,13 @@ const AccountDetail: React.FC = () => {
|
||||
<Descriptions.Item label={t('account.accountName')}>
|
||||
{account.accountName || '-'}
|
||||
</Descriptions.Item>
|
||||
{account.walletType && (
|
||||
<Descriptions.Item label={t('account.walletType')}>
|
||||
<Tag color={account.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
|
||||
{account.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label={t('account.walletAddress')} span={isMobile ? 1 : 2}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
|
||||
@@ -35,8 +35,6 @@ const AccountImport: React.FC = () => {
|
||||
form={form}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={() => navigate('/accounts')}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -303,6 +303,20 @@ const AccountList: React.FC = () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('accountList.walletType'),
|
||||
dataIndex: 'walletType',
|
||||
key: 'walletType',
|
||||
render: (walletType: string) => {
|
||||
if (!walletType) return '-'
|
||||
const type = walletType.toLowerCase()
|
||||
return (
|
||||
<Tag color={type === 'magic' ? 'purple' : 'blue'}>
|
||||
{type === 'magic' ? 'Magic' : 'Safe'}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('accountList.balance'),
|
||||
dataIndex: 'balance',
|
||||
@@ -392,7 +406,7 @@ const AccountList: React.FC = () => {
|
||||
style={{ marginLeft: '4px', padding: '0 4px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<strong>{t('accountList.proxyAddress')}:</strong> {record.proxyAddress ? `${record.proxyAddress.slice(0, 6)}...${record.proxyAddress.slice(-4)}` : '-'}
|
||||
<Button
|
||||
type="text"
|
||||
@@ -405,6 +419,14 @@ const AccountList: React.FC = () => {
|
||||
style={{ marginLeft: '4px', padding: '0 4px' }}
|
||||
/>
|
||||
</div>
|
||||
{record.walletType && (
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<strong>{t('accountList.walletType')}:</strong>{' '}
|
||||
<Tag color={record.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'} style={{ marginLeft: '4px' }}>
|
||||
{record.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
@@ -654,6 +676,13 @@ const AccountList: React.FC = () => {
|
||||
/>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
{detailAccount.walletType && (
|
||||
<Descriptions.Item label={t('accountList.walletType')}>
|
||||
<Tag color={detailAccount.walletType.toLowerCase() === 'magic' ? 'purple' : 'blue'}>
|
||||
{detailAccount.walletType.toLowerCase() === 'magic' ? 'Magic' : 'Safe'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label={t('accountList.totalBalance')} span={isMobile ? 1 : 2}>
|
||||
{detailBalanceLoading ? (
|
||||
<Spin size="small" />
|
||||
@@ -854,9 +883,9 @@ const AccountList: React.FC = () => {
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={isMobile ? '95%' : 600}
|
||||
width={isMobile ? '95%' : 640}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
|
||||
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
|
||||
destroyOnClose
|
||||
maskClosable
|
||||
closable
|
||||
@@ -868,8 +897,6 @@ const AccountList: React.FC = () => {
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -272,7 +272,9 @@ const BacktestList: React.FC = () => {
|
||||
supportSell: values.supportSell,
|
||||
keywordFilterMode: values.keywordFilterMode,
|
||||
keywords: values.keywords,
|
||||
maxPositionValue: values.maxPositionValue
|
||||
maxPositionValue: values.maxPositionValue,
|
||||
minPrice: values.minPrice,
|
||||
maxPrice: values.maxPrice
|
||||
}
|
||||
|
||||
const response = await backtestService.create(request)
|
||||
@@ -324,6 +326,8 @@ const BacktestList: React.FC = () => {
|
||||
keywordFilterMode: taskConfig.keywordFilterMode || 'DISABLED',
|
||||
keywords: taskConfig.keywords || [],
|
||||
maxPositionValue: taskConfig.maxPositionValue,
|
||||
minPrice: taskConfig.minPrice,
|
||||
maxPrice: taskConfig.maxPrice,
|
||||
configName: `回测任务-${taskDetail.taskName}`
|
||||
}
|
||||
|
||||
@@ -992,6 +996,50 @@ const BacktestList: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.priceRange')}
|
||||
tooltip={t('backtest.priceRangeTooltip')}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="minPrice" noStyle>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('backtest.minPricePlaceholder') || '最低价(留空不限制)'}
|
||||
min={0.01}
|
||||
max={0.99}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="maxPrice" noStyle>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('backtest.maxPricePlaceholder') || '最高价(留空不限制)'}
|
||||
min={0.01}
|
||||
max={0.99}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
if (isNaN(num)) return ''
|
||||
return num.toString().replace(/\.0+$/, '')
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.supportSell')}
|
||||
name="supportSell"
|
||||
@@ -1078,6 +1126,8 @@ const BacktestList: React.FC = () => {
|
||||
keywordFilterMode: detailConfig.keywordFilterMode,
|
||||
keywords: detailConfig.keywords || [],
|
||||
maxPositionValue: detailConfig.maxPositionValue,
|
||||
minPrice: detailConfig.minPrice,
|
||||
maxPrice: detailConfig.maxPrice,
|
||||
configName: `回测任务-${detailTask.taskName}`
|
||||
}
|
||||
setPreFilledConfig(preFilled)
|
||||
@@ -1268,6 +1318,20 @@ const BacktestList: React.FC = () => {
|
||||
{formatUSDC(detailConfig.maxPositionValue)} USDC
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{(detailConfig.minPrice || detailConfig.maxPrice) && (
|
||||
<Descriptions.Item label={t('backtest.priceRange')}>
|
||||
{detailConfig.minPrice !== undefined && detailConfig.minPrice !== null && detailConfig.minPrice !== ''
|
||||
? `≥ ${parseFloat(detailConfig.minPrice).toFixed(4)}`
|
||||
: ''}
|
||||
{(detailConfig.minPrice !== undefined && detailConfig.minPrice !== null && detailConfig.minPrice !== '') &&
|
||||
(detailConfig.maxPrice !== undefined && detailConfig.maxPrice !== null && detailConfig.maxPrice !== '')
|
||||
? ' ~ '
|
||||
: ''}
|
||||
{detailConfig.maxPrice !== undefined && detailConfig.maxPrice !== null && detailConfig.maxPrice !== ''
|
||||
? `≤ ${parseFloat(detailConfig.maxPrice).toFixed(4)}`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1106,9 +1106,9 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={isMobile ? '95%' : 600}
|
||||
width={isMobile ? '95%' : 640}
|
||||
style={{ top: isMobile ? 20 : 50 }}
|
||||
bodyStyle={{ padding: '24px', maxHeight: 'calc(100vh - 150px)', overflow: 'auto' }}
|
||||
bodyStyle={{ padding: isMobile ? '16px 20px' : '24px 28px', maxHeight: 'calc(100vh - 140px)', overflow: 'auto' }}
|
||||
destroyOnClose
|
||||
maskClosable
|
||||
closable
|
||||
@@ -1120,8 +1120,6 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
setAccountImportModalVisible(false)
|
||||
accountImportForm.resetFields()
|
||||
}}
|
||||
showAlert={true}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,10 +62,10 @@ const PositionList: React.FC = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 当仓位数据变化时,更新可赎回统计
|
||||
// 当仓位数据变化时,静默更新可赎回统计(不显示loading状态)
|
||||
useEffect(() => {
|
||||
if (currentPositions.length > 0) {
|
||||
fetchRedeemableSummary()
|
||||
fetchRedeemableSummarySilently()
|
||||
}
|
||||
}, [currentPositions, selectedAccountId])
|
||||
|
||||
@@ -74,7 +74,19 @@ const PositionList: React.FC = () => {
|
||||
setCurrentPage(1)
|
||||
}, [positionFilter, selectedAccountId, searchKeyword])
|
||||
|
||||
// 获取可赎回仓位统计
|
||||
// 静默获取可赎回仓位统计(不显示loading状态)
|
||||
const fetchRedeemableSummarySilently = async () => {
|
||||
try {
|
||||
const response = await apiService.accounts.getRedeemableSummary({ accountId: selectedAccountId })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setRedeemableSummary(response.data.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取可赎回统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取可赎回仓位统计(带loading状态,用于用户主动操作)
|
||||
const fetchRedeemableSummary = async () => {
|
||||
setLoadingRedeemableSummary(true)
|
||||
try {
|
||||
@@ -91,8 +103,9 @@ const PositionList: React.FC = () => {
|
||||
|
||||
// 处理赎回按钮点击
|
||||
const handleRedeemClick = async () => {
|
||||
await fetchRedeemableSummary()
|
||||
setRedeemModalVisible(true)
|
||||
// 打开模态框时重新获取最新数据
|
||||
fetchRedeemableSummary()
|
||||
}
|
||||
|
||||
// 提交赎回
|
||||
|
||||
@@ -206,6 +206,12 @@ export const apiService = {
|
||||
* 账户管理 API
|
||||
*/
|
||||
accounts: {
|
||||
/**
|
||||
* 检查代理地址选项(导入前选择代理类型)
|
||||
*/
|
||||
checkProxyOptions: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/accounts/check-proxy-options', data),
|
||||
|
||||
/**
|
||||
* 导入账户
|
||||
*/
|
||||
@@ -423,6 +429,59 @@ export const apiService = {
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/configs/filtered-orders', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 尾盘策略 API
|
||||
*/
|
||||
cryptoTailStrategy: {
|
||||
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
|
||||
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyDto[] }>>('/crypto-tail-strategy/list', data),
|
||||
create: (data: {
|
||||
accountId: number
|
||||
name?: string
|
||||
marketSlugPrefix: string
|
||||
intervalSeconds: number
|
||||
windowStartSeconds: number
|
||||
windowEndSeconds: number
|
||||
minPrice: string
|
||||
maxPrice?: string
|
||||
amountMode: string
|
||||
amountValue: string
|
||||
minSpreadMode?: string
|
||||
minSpreadValue?: string | null
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/create', data),
|
||||
update: (data: {
|
||||
strategyId: number
|
||||
name?: string
|
||||
windowStartSeconds?: number
|
||||
windowEndSeconds?: number
|
||||
minPrice?: string
|
||||
maxPrice?: string
|
||||
amountMode?: string
|
||||
amountValue?: string
|
||||
minSpreadMode?: string
|
||||
minSpreadValue?: string | null
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailStrategyDto>>('/crypto-tail-strategy/update', data),
|
||||
delete: (data: { strategyId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/crypto-tail-strategy/delete', data),
|
||||
triggers: (data: {
|
||||
strategyId: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: string
|
||||
startDate?: number
|
||||
endDate?: number
|
||||
}) =>
|
||||
apiClient.post<ApiResponse<{ list: import('../types').CryptoTailStrategyTriggerDto[]; total: number }>>('/crypto-tail-strategy/triggers', data),
|
||||
marketOptions: () =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
|
||||
autoMinSpread: (data: { intervalSeconds: number }) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 订单管理 API
|
||||
|
||||
@@ -60,8 +60,10 @@ export const useAccountStore = create<AccountStore>((set, get) => ({
|
||||
if (response.data.code === 0) {
|
||||
await get().fetchAccounts()
|
||||
} else {
|
||||
const err = new Error(response.data.msg || '导入账户失败')
|
||||
;(err as Error & { code?: number }).code = response.data.code
|
||||
set({ error: response.data.msg || '导入账户失败', loading: false })
|
||||
throw new Error(response.data.msg || '导入账户失败')
|
||||
throw err
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({ error: error.message || '导入账户失败', loading: false })
|
||||
|
||||
@@ -46,6 +46,37 @@ export interface AccountImportRequest {
|
||||
walletType?: string // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理地址选项请求
|
||||
*/
|
||||
export interface CheckProxyOptionsRequest {
|
||||
walletAddress: string // EOA 地址
|
||||
privateKey?: string // 私钥(加密,私钥导入时提供)
|
||||
mnemonic?: string // 助记词(加密,助记词导入时提供)
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理地址选项信息
|
||||
*/
|
||||
export interface ProxyOption {
|
||||
walletType: string // "magic" 或 "safe"
|
||||
proxyAddress: string // 代理地址
|
||||
descriptionKey: string // 说明文案的多语言 key
|
||||
availableBalance: string // 可用余额
|
||||
positionBalance: string // 仓位余额
|
||||
totalBalance: string // 总余额
|
||||
positionCount: number // 持仓数量
|
||||
hasAssets: boolean // 是否有资产
|
||||
error?: string // 获取失败时的错误信息
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理地址选项响应
|
||||
*/
|
||||
export interface CheckProxyOptionsResponse {
|
||||
options: ProxyOption[] // 代理地址选项列表
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
@@ -1003,3 +1034,74 @@ export interface BacktestTaskDto {
|
||||
executionStartedAt?: number
|
||||
executionFinishedAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 尾盘策略
|
||||
*/
|
||||
export interface CryptoTailStrategyDto {
|
||||
id: number
|
||||
accountId: number
|
||||
name?: string
|
||||
marketSlugPrefix: string
|
||||
marketTitle?: string
|
||||
intervalSeconds: number
|
||||
windowStartSeconds: number
|
||||
windowEndSeconds: number
|
||||
minPrice: string
|
||||
maxPrice: string
|
||||
amountMode: string
|
||||
amountValue: string
|
||||
/** 最小价差模式: NONE, FIXED, AUTO */
|
||||
minSpreadMode?: string
|
||||
/** 最小价差数值(FIXED 时必填;AUTO 时可为计算值) */
|
||||
minSpreadValue?: string | null
|
||||
enabled: boolean
|
||||
lastTriggerAt?: number
|
||||
/** 已实现总收益 USDC */
|
||||
totalRealizedPnl?: string
|
||||
settledCount?: number
|
||||
winCount?: number
|
||||
/** 胜率 0~1 */
|
||||
winRate?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** 自动最小价差计算响应 */
|
||||
export interface CryptoTailAutoMinSpreadResponse {
|
||||
minSpreadUp: string
|
||||
minSpreadDown: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 尾盘策略触发记录
|
||||
*/
|
||||
export interface CryptoTailStrategyTriggerDto {
|
||||
id: number
|
||||
strategyId: number
|
||||
periodStartUnix: number
|
||||
marketTitle?: string
|
||||
outcomeIndex: number
|
||||
triggerPrice: string
|
||||
amountUsdc: string
|
||||
orderId?: string
|
||||
status: string
|
||||
failReason?: string
|
||||
resolved?: boolean
|
||||
/** 已实现盈亏 USDC(结算后有值) */
|
||||
realizedPnl?: string
|
||||
winnerOutcomeIndex?: number
|
||||
settledAt?: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 尾盘策略市场选项
|
||||
*/
|
||||
export interface CryptoTailMarketOptionDto {
|
||||
slug: string
|
||||
title: string
|
||||
intervalSeconds: number
|
||||
periodStartUnix: number
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从币安现货 API 获取 BTC/USDC 15 分钟 K 线数据。
|
||||
|
||||
API: https://api.binance.com/api/v3/klines
|
||||
无需 API Key,公开行情接口。
|
||||
|
||||
使用方法:
|
||||
python3 scripts/fetch_binance_btc_usdc_klines.py
|
||||
python3 scripts/fetch_binance_btc_usdc_klines.py --limit 96
|
||||
python3 scripts/fetch_binance_btc_usdc_klines.py --limit 10 --interval 15m
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BINANCE_BASE = "https://api.binance.com"
|
||||
|
||||
|
||||
def fetch_klines(
|
||||
symbol: str = "BTCUSDC",
|
||||
interval: str = "15m",
|
||||
limit: int = 500,
|
||||
start_time: int | None = None,
|
||||
end_time: int | None = None,
|
||||
) -> list[list] | None:
|
||||
"""
|
||||
获取 K 线数据。
|
||||
返回每根 K 线: [ openTime, open, high, low, close, volume, closeTime, ... ]
|
||||
"""
|
||||
params = {"symbol": symbol, "interval": interval, "limit": limit}
|
||||
if start_time is not None:
|
||||
params["startTime"] = start_time
|
||||
if end_time is not None:
|
||||
params["endTime"] = end_time
|
||||
qs = urllib.parse.urlencode(params)
|
||||
url = f"{BINANCE_BASE}/api/v3/klines?{qs}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "PolymarketBot/1.0 (script)"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.load(resp)
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode() if e.fp else ""
|
||||
try:
|
||||
err = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
err = {"msg": body}
|
||||
print(f"Request failed: {e.code} - {err}", file=sys.stderr)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Request error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fetch Binance BTC/USDC 15m klines")
|
||||
parser.add_argument("--symbol", default="BTCUSDC", help="Trading pair (default: BTCUSDC)")
|
||||
parser.add_argument("--interval", default="15m", help="Kline interval (default: 15m)")
|
||||
parser.add_argument("--limit", type=int, default=20, help="Number of klines (default: 20, max 1000)")
|
||||
parser.add_argument("--start", type=int, default=None, help="Start time (ms)")
|
||||
parser.add_argument("--end", type=int, default=None, help="End time (ms)")
|
||||
args = parser.parse_args()
|
||||
|
||||
limit = max(1, min(1000, args.limit))
|
||||
|
||||
print("=== Binance BTC/USDC K-line (15m) ===\n")
|
||||
print(f"Symbol: {args.symbol} Interval: {args.interval} Limit: {limit}")
|
||||
if args.start:
|
||||
print(f"Start: {args.start} ({time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(args.start // 1000))})")
|
||||
if args.end:
|
||||
print(f"End: {args.end} ({time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(args.end // 1000))})")
|
||||
print()
|
||||
|
||||
klines = fetch_klines(
|
||||
symbol=args.symbol,
|
||||
interval=args.interval,
|
||||
limit=limit,
|
||||
start_time=args.start,
|
||||
end_time=args.end,
|
||||
)
|
||||
|
||||
if not klines:
|
||||
print("No kline data returned")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Got {len(klines)} kline(s)\n")
|
||||
print("Columns: openTime, open, high, low, close, volume, closeTime, ...")
|
||||
print("-" * 72)
|
||||
for k in klines:
|
||||
open_ts_ms = k[0]
|
||||
open_ts = open_ts_ms // 1000
|
||||
ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(open_ts))
|
||||
o, h, l, c, v = k[1], k[2], k[3], k[4], k[5]
|
||||
print(f" {ts_str} O:{o} H:{h} L:{l} C:{c} V:{v}")
|
||||
print("-" * 72)
|
||||
last = klines[-1]
|
||||
print(f"Latest: open={last[1]}, high={last[2]}, low={last[3]}, close={last[4]}, volume={last[5]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
获取 Polymarket 5/15 分钟加密市场数据(开始时间、结束时间、conditionId)。
|
||||
使用 Gamma API: https://gamma-api.polymarket.com
|
||||
验证方式: python3 scripts/fetch_crypto_minute_markets.py
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
GAMMA_BASE = "https://gamma-api.polymarket.com"
|
||||
|
||||
|
||||
def fetch_event_by_slug(slug: str) -> dict | None:
|
||||
url = f"{GAMMA_BASE}/events/slug/{slug}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "PolymarketBot/1.0 (script)"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.load(resp)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Request error {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_iso_to_ms(iso: str | None) -> int | None:
|
||||
if not iso:
|
||||
return None
|
||||
try:
|
||||
# ISO 可能带 Z 或 +00:00
|
||||
if iso.endswith("Z"):
|
||||
iso = iso.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||
return int(dt.timestamp() * 1000)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
now = int(time.time())
|
||||
# 5 分钟周期边界 (300s)
|
||||
period_5m = (now // 300) * 300
|
||||
next_5m = period_5m + 300
|
||||
# 15 分钟周期边界 (900s);slug 可能用结束时间,这里试起点
|
||||
period_15m = (now // 900) * 900
|
||||
next_15m = period_15m + 900
|
||||
|
||||
print("=== 5 minute markets (BTC) ===")
|
||||
for ts, label in [(period_5m, "current"), (next_5m, "next")]:
|
||||
slug = f"btc-updown-5m-{ts}"
|
||||
ev = fetch_event_by_slug(slug)
|
||||
if ev and ev.get("slug"):
|
||||
start = ev.get("startDate")
|
||||
end = ev.get("endDate")
|
||||
print(f" [{label}] slug={slug}")
|
||||
print(f" title: {ev.get('title', '')[:70]}")
|
||||
print(f" startDate: {start} endDate: {end}")
|
||||
markets = ev.get("markets") or []
|
||||
for m in markets[:1]:
|
||||
cid = m.get("conditionId")
|
||||
print(f" conditionId: {cid}")
|
||||
print(f" question: {(m.get('question') or '')[:60]}")
|
||||
# clobTokenIds 用于订单簿
|
||||
tokens = m.get("clobTokenIds")
|
||||
if tokens:
|
||||
try:
|
||||
ids = json.loads(tokens) if isinstance(tokens, str) else tokens
|
||||
print(f" clobTokenIds: {ids[:2]}..." if len(ids) > 2 else f" clobTokenIds: {ids}")
|
||||
except Exception:
|
||||
print(f" clobTokenIds: {tokens[:80]}...")
|
||||
else:
|
||||
print(f" [{label}] slug={slug} -> not found (404 or empty)")
|
||||
|
||||
print("\n=== 15 minute markets (BTC) ===")
|
||||
for ts, label in [(period_15m, "current"), (next_15m, "next")]:
|
||||
slug = f"btc-updown-15m-{ts}"
|
||||
ev = fetch_event_by_slug(slug)
|
||||
if ev and ev.get("slug"):
|
||||
print(f" [{label}] slug={slug}")
|
||||
print(f" title: {ev.get('title', '')[:70]}")
|
||||
print(f" startDate: {ev.get('startDate')} endDate: {ev.get('endDate')}")
|
||||
for m in (ev.get("markets") or [])[:1]:
|
||||
print(f" conditionId: {m.get('conditionId')}")
|
||||
else:
|
||||
print(f" [{label}] slug={slug} -> not found")
|
||||
|
||||
print("\n=== Summary ===")
|
||||
print("5m: slug btc-updown-5m-{periodStartUnix}, periodStartUnix = (now // 300) * 300; period end = endDate.")
|
||||
print("15m: slug btc-updown-15m-{periodStartUnix}, periodStartUnix = (now // 900) * 900; period end = endDate.")
|
||||
print("Period start = slug timestamp; period end = API endDate (do not use startDate as period start).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Generated
+2
-1
@@ -9,7 +9,8 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@ethersproject/wallet": "^5.7.0",
|
||||
"@polymarket/clob-client": "^5.2.1"
|
||||
"@polymarket/clob-client": "^5.2.1",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"get-order-detail": "node get-order-detail.js",
|
||||
"verify-backtest-data": "node verify-backtest-data.js"
|
||||
"verify-backtest-data": "node verify-backtest-data.js",
|
||||
"ws-binance-klines": "node ws_binance_btc_usdc_klines.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ethersproject/wallet": "^5.7.0",
|
||||
"@polymarket/clob-client": "^5.2.1"
|
||||
"@polymarket/clob-client": "^5.2.1",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 通过币安 WebSocket 订阅 BTC/USDC 15 分钟 K 线推送。
|
||||
*
|
||||
* 文档: https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams
|
||||
* - 现货 K 线流: wss://stream.binance.com:9443/ws/btcusdc@kline_15m
|
||||
* - 服务端约每 20 秒发 ping,ws 库会自动回 pong
|
||||
* - K 线推送频率: 15m 约每 2 秒更新一次;x=true 表示该根 K 线已收盘
|
||||
*
|
||||
* 依赖: npm install ws(在 scripts 目录下执行)
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/ws_binance_btc_usdc_klines.js
|
||||
* node scripts/ws_binance_btc_usdc_klines.js --interval 1m
|
||||
* node scripts/ws_binance_btc_usdc_klines.js --url "wss://stream.binance.com:9443/ws/btcusdc@kline_15m"
|
||||
* Ctrl+C 退出
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const BINANCE_WS_BASE = 'wss://stream.binance.com:9443';
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const out = { symbol: 'btcusdc', interval: '15m', url: null };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--symbol' && args[i + 1]) {
|
||||
out.symbol = String(args[i + 1]).toLowerCase();
|
||||
i++;
|
||||
} else if (args[i] === '--interval' && args[i + 1]) {
|
||||
out.interval = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--url' && args[i + 1]) {
|
||||
out.url = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatKline(msg) {
|
||||
if (msg.e !== 'kline') {
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
const k = msg.k || {};
|
||||
const tMs = Number(k.t) || 0;
|
||||
const tsStr = new Date(tMs).toISOString().replace('T', ' ').slice(0, 19);
|
||||
const closed = k.x ? ' [CLOSED]' : '';
|
||||
return ` ${tsStr} O:${k.o} H:${k.h} L:${k.l} C:${k.c} V:${k.v}${closed}`;
|
||||
}
|
||||
|
||||
function run(wsUrl) {
|
||||
console.log(`Connecting: ${wsUrl}`);
|
||||
console.log('(Ctrl+C to exit)\n');
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.on('open', () => {
|
||||
// ws 库收到 ping 会自动回 pong,无需手动处理
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.result !== undefined && msg.id !== undefined) return;
|
||||
if (msg.code !== undefined && msg.code !== 0) {
|
||||
console.error('Error:', msg);
|
||||
return;
|
||||
}
|
||||
console.log(formatKline(msg));
|
||||
} catch {
|
||||
console.log(data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
if (code !== 1000) {
|
||||
console.error(`Connection closed: ${code} ${reason?.toString() || ''}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const args = parseArgs();
|
||||
const wsUrl = args.url || `${BINANCE_WS_BASE}/ws/${args.symbol}@kline_${args.interval}`;
|
||||
run(wsUrl);
|
||||
Reference in New Issue
Block a user