Compare commits

...

7 Commits

Author SHA1 Message Date
WrBug 83a415fb7e 优化代理检查结果展示与地域检测文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 00:54:22 +08:00
WrBug 727fa7ed58 fix: 支持部署时连接宿主机 MySQL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 01:02:55 +08:00
WrBug 8ab8ae5205 style: 全局将 USDC 展示替换为 $ 符号
前端所有金额展示从 "100.00 USDC" 改为 "$100.00" 格式,
后端 Telegram 通知模板和错误提示同步更新。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:23:49 +08:00
WrBug 0769022878 feat: 添加 CLOB 2.0 迁移弹窗提醒与账户页迁移引导
登录后首次进入显示迁移提醒弹窗,引导用户前往账户页;
账户页首次进入时显示 Alert 提示 USDC.e → pUSD 迁移按钮;
移动端账户卡片补充 swap 操作按钮。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:59:15 +08:00
WrBug 06a122dd34 fix: 迁移链路外统一改为仅识别 pUSD
移除链上监听中对 USDC.e 的抵押品匹配,并将解包与审批相关说明统一为 pUSD 语义,避免在非 swap/wrap 流程继续使用 USDC.e。

Made-with: Cursor
2026-04-28 21:40:41 +08:00
WrBug fb6b469e9f fix: 修复 CLOB V2 下代理交易与链上成交解析问题
调整 CLOB API 地址并优化 Magic Proxy 的执行与 gas 限制,避免 wrap 与 relay 调用在 V2 环境下异常回滚。同时补充 pUSD/USDC.e 兼容与 ERC1155-only 成交价格回填逻辑,提升链上成交解析稳定性。

Made-with: Cursor
2026-04-28 21:34:43 +08:00
WrBug 22a6544373 feat: CLOB V1 → V2 完整迁移
- 更新 EIP-712 签名结构: domain version "1"→"2", 移除 taker/expiration/nonce/feeRateBps, 新增 timestamp/metadata/builder
- 更新合约地址为 V2 (CTF_EXCHANGE, NEG_RISK_EXCHANGE)
- CLOB URL 切换到 clob-v2.polymarket.com
- 抵押品代币 USDC.e → pUSD (0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB)
- 新增 USDC.e → pUSD wrap 功能 (CollateralOnramp 合约)
- SignedOrderObject 补充 API payload 必需的 taker/expiration 字段
- NewOrderRequest 补充 deferExec/postOnly 字段
- 前端账户管理新增 pUSD 迁移按钮
- 移除所有 feeRateBps 相关逻辑

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 22:24:50 +08:00
67 changed files with 2826 additions and 547 deletions
+2 -1
View File
@@ -112,4 +112,5 @@ __pycache__/
clob-client/ clob-client/
builder-relayer-client/ builder-relayer-client/
landing-page/ landing-page/
clob-client-v2/
settings.local.json
File diff suppressed because it is too large Load Diff
@@ -73,7 +73,7 @@ interface PolymarketClobApi {
*/ */
@POST("/orders/batch") @POST("/orders/batch")
suspend fun createOrdersBatch( suspend fun createOrdersBatch(
@Body request: CreateOrdersBatchRequest @Body request: List<NewOrderRequest>
): Response<List<OrderResponse>> ): Response<List<OrderResponse>>
/** /**
@@ -174,22 +174,25 @@ interface PolymarketClobApi {
// 请求和响应数据类 // 请求和响应数据类
/** /**
* 签名的订单对象(根据官方文档) * V2 签名的订单对象
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order * EIP-712 签名字段: salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder
* API payload 额外字段: taker, expiration (不在 EIP-712 签名中,但 API 请求需要)
* 参考: clob-client-v2/src/types/ordersV2.ts NewOrderV2
*/ */
data class SignedOrderObject( data class SignedOrderObject(
val salt: Long, // random salt used to create unique order val salt: Long, // random salt used to create unique order
val maker: String, // maker address (funder) val maker: String, // maker address (funder)
val signer: String, // signing address val signer: String, // signing address
val taker: String, // taker address (operator) val taker: String, // taker address (zero address for public orders, NOT in EIP-712 signing)
val tokenId: String, // ERC1155 token ID of conditional token being traded val tokenId: String, // ERC1155 token ID of conditional token being traded
val makerAmount: String, // maximum amount maker is willing to spend val makerAmount: String, // maximum amount maker is willing to spend
val takerAmount: String, // minimum amount taker will pay the maker in return val takerAmount: String, // minimum amount taker will pay the maker in return
val expiration: String, // unix expiration timestamp
val nonce: String, // maker's exchange nonce of the order is associated
val feeRateBps: String, // fee rate basis points as required by the operator
val side: String, // buy or sell enum index ("BUY" or "SELL") val side: String, // buy or sell enum index ("BUY" or "SELL")
val signatureType: Int, // signature type enum index val signatureType: Int, // signature type enum index
val timestamp: String, // order creation time in milliseconds (V2)
val expiration: String, // expiration timestamp unix seconds, "0" = no expiration (NOT in EIP-712 signing)
val metadata: String, // bytes32 metadata (V2)
val builder: String, // bytes32 builder code (V2)
val signature: String // hex encoded signature val signature: String // hex encoded signature
) )
@@ -198,10 +201,11 @@ data class SignedOrderObject(
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order * 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
*/ */
data class NewOrderRequest( data class NewOrderRequest(
val order: SignedOrderObject, // signed object val order: SignedOrderObject, // V2 signed object
val owner: String, // api key of order owner val owner: String, // api key of order owner
val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK") val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK")
val deferExec: Boolean = false // defer execution flag val deferExec: Boolean = false, // defer execution
val postOnly: Boolean = false // post only (maker-only)
) )
/** /**
@@ -235,26 +239,6 @@ data class NewOrderResponse(
} }
} }
/**
* 旧的订单请求格式(已废弃,保留用于兼容)
* @deprecated 使用 NewOrderRequest 代替
*/
@Deprecated("使用 NewOrderRequest 代替,需要签名的订单对象")
data class CreateOrderRequest(
val market: String? = null, // condition ID(可选,如果提供tokenId则不需要)
val token_id: String? = null, // token ID(可选,如果提供market则不需要)
val side: String, // "BUY" or "SELL"
val price: String,
val size: String,
val type: String = "LIMIT",
val expiration: Long? = null
)
@Deprecated("使用 NewOrderRequest 代替")
data class CreateOrdersBatchRequest(
val orders: List<NewOrderRequest>
)
data class CancelOrdersBatchRequest( data class CancelOrdersBatchRequest(
val orderIds: List<String> val orderIds: List<String>
) )
@@ -572,5 +572,53 @@ class AccountController(
} }
} }
/**
* 将 USDC.e wrap 为 pUSDV2 迁移)
*/
@PostMapping("/wrap-to-pusd")
fun wrapToPusd(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String?>>> {
return try {
val accountId = (request["accountId"] as? Number)?.toLong()
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
val result = runBlocking { accountService.wrapUsdcToPusd(accountId) }
result.fold(
onSuccess = { txHash ->
ResponseEntity.ok(ApiResponse.success(mapOf("transactionHash" to txHash)))
},
onFailure = { e ->
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
/**
* 查询 USDC.e 余额(V2 迁移用)
*/
@PostMapping("/usdce-balance")
fun getUsdceBalance(@RequestBody request: Map<String, Any>): ResponseEntity<ApiResponse<Map<String, String>>> {
return try {
val accountId = (request["accountId"] as? Number)?.toLong()
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
val result = runBlocking { accountService.getUsdceBalance(accountId) }
result.fold(
onSuccess = { balance ->
ResponseEntity.ok(ApiResponse.success(mapOf("balance" to balance.toPlainString())))
},
onFailure = { e ->
logger.error("查询 USDC.e 余额失败: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
)
} catch (e: Exception) {
logger.error("查询 USDC.e 余额异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
}
}
} }
@@ -267,7 +267,7 @@ class CryptoTailStrategyController(
"当前周期已下单" -> ErrorCode.PARAM_ERROR "当前周期已下单" -> ErrorCode.PARAM_ERROR
"价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR "价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR
"数量不能少于 1" -> ErrorCode.PARAM_ERROR "数量不能少于 1" -> ErrorCode.PARAM_ERROR
"总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR "总金额不能少于 $1" -> ErrorCode.PARAM_ERROR
"总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR "总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR
else -> ErrorCode.SERVER_ERROR else -> ErrorCode.SERVER_ERROR
} }
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system
import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
import com.wrbug.polymarketbot.service.system.GeoblockService
import com.wrbug.polymarketbot.service.system.ProxyConfigService import com.wrbug.polymarketbot.service.system.ProxyConfigService
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.*
class ProxyConfigController( class ProxyConfigController(
private val proxyConfigService: ProxyConfigService, private val proxyConfigService: ProxyConfigService,
private val apiHealthCheckService: ApiHealthCheckService, private val apiHealthCheckService: ApiHealthCheckService,
private val geoblockService: GeoblockService,
private val messageSource: MessageSource private val messageSource: MessageSource
) { ) {
@@ -123,6 +125,32 @@ class ProxyConfigController(
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}", messageSource)) ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}", messageSource))
} }
} }
/**
* 检查交易服务器出口 IP 的地域限制(Polymarket Geoblock
*/
@PostMapping("/geoblock-check")
fun checkGeoblock(): ResponseEntity<ApiResponse<GeoblockCheckDto>> {
return try {
val result = runBlocking { geoblockService.checkGeoblock() }
if (result.isSuccess) {
ResponseEntity.ok(ApiResponse.success(result.getOrNull()))
} else {
val error = result.exceptionOrNull()
logger.error("Geoblock 检查失败", error)
ResponseEntity.ok(
ApiResponse.error(
ErrorCode.SERVER_ERROR,
error?.message ?: "Geoblock 检查失败",
messageSource
)
)
}
} catch (e: Exception) {
logger.error("Geoblock 检查异常", e)
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "Geoblock 检查失败:${e.message}", messageSource))
}
}
} }
@@ -0,0 +1,23 @@
package com.wrbug.polymarketbot.dto
/**
* Polymarket Geoblock API 原始响应
*/
data class PolymarketGeoblockApiResponse(
val blocked: Boolean = false,
val ip: String = "",
val country: String = "",
val region: String = ""
)
/**
* 地域限制检查结果(返回给前端)
*/
data class GeoblockCheckDto(
val blocked: Boolean,
val ip: String,
val country: String,
val region: String,
val checkedAt: Long,
val source: String = "server"
)
@@ -36,6 +36,18 @@ data class SubscriptionProxyConfigRequest(
val type: String // CLASH 或 SS val type: String // CLASH 或 SS
) )
/**
* 代理检查时的 Polymarket 地域限制检测结果
*/
data class ProxyCheckGeoblockResult(
val checked: Boolean,
val blocked: Boolean? = null,
val ip: String? = null,
val country: String? = null,
val region: String? = null,
val message: String? = null
)
/** /**
* 代理检查响应 * 代理检查响应
*/ */
@@ -43,15 +55,22 @@ data class ProxyCheckResponse(
val success: Boolean, val success: Boolean,
val message: String, val message: String,
val responseTime: Long? = null, // 响应时间(毫秒) val responseTime: Long? = null, // 响应时间(毫秒)
val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示 val latency: Long? = null, // 延迟(毫秒),与 responseTime 相同,用于前端显示
val geoblock: ProxyCheckGeoblockResult? = null
) { ) {
companion object { companion object {
fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse { fun create(
success: Boolean,
message: String,
responseTime: Long? = null,
geoblock: ProxyCheckGeoblockResult? = null
): ProxyCheckResponse {
return ProxyCheckResponse( return ProxyCheckResponse(
success = success, success = success,
message = message, message = message,
responseTime = responseTime, responseTime = responseTime,
latency = responseTime // latency 和 responseTime 相同 latency = responseTime,
geoblock = geoblock
) )
} }
} }
@@ -365,14 +365,14 @@ class AccountService(
} }
/** /**
* Polymarket 代币批准检查:USDC.e 需授权的 spender 合约地址(Polygon 主网) * Polymarket 代币批准检查:pUSD 需授权的 spender 合约地址(Polygon 主网)
* 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals * 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals
* 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137) * 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
*/ */
private val setupApprovalSpenders = mapOf( private val setupApprovalSpenders = mapOf(
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens "CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
"CTF_EXCHANGE" to "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", // 普通市场交易所 "CTF_EXCHANGE" to "0xE111180000d2663C0091e4f400237545B87B996B", // 普通市场交易所
"NEG_RISK_EXCHANGE" to "0xC5d563A36AE78145C45a50134d48A1215220f80a", // 负风险市场交易所 "NEG_RISK_EXCHANGE" to "0xe2222d279d744050d28e00520010520000310F59", // 负风险市场交易所
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址) "NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
) )
@@ -941,7 +941,7 @@ class AccountService(
} }
/** /**
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e * 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包。
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。 * 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
*/ */
suspend fun runWcolUnwrapForAllAccounts() { suspend fun runWcolUnwrapForAllAccounts() {
@@ -1244,22 +1244,9 @@ class AccountService(
else -> "GTC" else -> "GTC"
} }
// GTC 和 FOK 订单的 expiration 必须为 "0"
// 只有 GTD 订单才需要设置具体的过期时间
val expiration = "0"
// 7. 解密私钥 // 7. 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account) val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType // 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType
val signedOrder = try { val signedOrder = try {
orderSigningService.createAndSignOrder( orderSigningService.createAndSignOrder(
@@ -1269,10 +1256,7 @@ class AccountService(
side = "SELL", side = "SELL",
price = sellPrice, price = sellPrice,
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量 size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType), signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = expiration
) )
} catch (e: Exception) { } catch (e: Exception) {
logger.error("创建并签名订单失败", e) logger.error("创建并签名订单失败", e)
@@ -1284,8 +1268,7 @@ class AccountService(
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest( val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
order = signedOrder, order = signedOrder,
owner = account.apiKey, // API Key owner = account.apiKey, // API Key
orderType = orderType, orderType = orderType
deferExec = false
) )
// 13. 解密 API 凭证并使用账户的API凭证创建订单 // 13. 解密 API 凭证并使用账户的API凭证创建订单
@@ -1925,6 +1908,32 @@ class AccountService(
false false
} }
} }
/**
* 将账户的 USDC.e wrap 为 pUSD
*/
suspend fun wrapUsdcToPusd(accountId: Long): Result<String?> {
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
if (account.proxyAddress.isBlank()) {
return Result.failure(IllegalStateException("账户代理地址不存在"))
}
val privateKey = cryptoUtils.decrypt(account.privateKey)
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
return blockchainService.wrapUsdcToPusd(privateKey, account.proxyAddress, walletType)
}
/**
* 查询 USDC.e 余额(用于迁移提示)
*/
suspend fun getUsdceBalance(accountId: Long): Result<BigDecimal> {
val account = accountRepository.findById(accountId).orElse(null)
?: return Result.failure(IllegalArgumentException("账户不存在"))
if (account.proxyAddress.isBlank()) {
return Result.failure(IllegalStateException("账户代理地址不存在"))
}
return blockchainService.queryUsdceBalance(account.proxyAddress)
}
} }
@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service
/** /**
* WCOL 解包轮询任务 * WCOL 解包轮询任务
* 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则解包为 USDC.e * 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则执行解包。
* 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。 * 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。
* 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。 * 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。
*/ */
@@ -39,8 +39,8 @@ class BlockchainService(
private val logger = LoggerFactory.getLogger(BlockchainService::class.java) private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon // pUSD 合约地址(Polygon 主网,Polymarket 使用 Polygon
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
// Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户) // Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户)
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b // 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
@@ -56,7 +56,7 @@ class BlockchainService(
// ConditionalTokens 合约地址(Polygon 主网) // ConditionalTokens 合约地址(Polygon 主网)
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e // Neg Risk WrappedCollateral 合约地址(Polygon
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2" private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
// 空集合ID(用于计算collectionId // 空集合ID(用于计算collectionId
@@ -812,11 +812,11 @@ class BlockchainService(
} }
/** /**
* 将代理钱包内的 WCOL 解包为 USDC.e(解包后转入代理地址) * 将代理钱包内的 WCOL 执行解包(解包后转入代理地址)
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可转为 USDC.e 以便显示/使用 * 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可执行解包后续资产处理
* *
* Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute] * Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute]
* Safe 走 execTransactionMagic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)USDC.e 转入 proxyAddress * Safe 走 execTransactionMagic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)。
* *
* @param privateKey 主钱包私钥 * @param privateKey 主钱包私钥
* @param proxyAddress 代理地址(Safe 或 Magic 代理) * @param proxyAddress 代理地址(Safe 或 Magic 代理)
@@ -855,6 +855,99 @@ class BlockchainService(
} }
} }
// USDC.e 合约地址(仅用于 wrap 查询)
private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
/**
* 查询 USDC.e 余额(用于 wrap 前检查)
*/
suspend fun queryUsdceBalance(walletAddress: String): Result<BigDecimal> {
return try {
val rpcApi = polygonRpcApi
val functionSelector = "0x70a08231"
val paddedAddress = walletAddress.removePrefix("0x").lowercase().padStart(64, '0')
val data = functionSelector + paddedAddress
val rpcRequest = JsonRpcRequest(
method = "eth_call",
params = listOf(mapOf("to" to usdceContractAddress, "data" to data), "latest")
)
val response = rpcApi.call(rpcRequest)
if (!response.isSuccessful || response.body() == null) {
return Result.failure(Exception("RPC 请求失败"))
}
val hexBalance = response.body()!!.result?.asString ?: return Result.failure(Exception("result 为空"))
val balanceWei = BigInteger(hexBalance.removePrefix("0x"), 16)
Result.success(BigDecimal(balanceWei).divide(BigDecimal("1000000")))
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* 将 USDC.e wrap 为 pUSD
* 步骤:1) 检查 USDC.e 余额 → 2) approve CollateralOnramp → 3) wrap
*/
suspend fun wrapUsdcToPusd(
privateKey: String,
proxyAddress: String,
walletType: WalletType
): Result<String?> {
return try {
val balanceResult = queryUsdceBalance(proxyAddress)
val balance = balanceResult.getOrElse {
logger.warn("查询 USDC.e 余额失败: ${it.message}")
return Result.failure(it)
}
if (balance <= BigDecimal.ZERO) {
return Result.success(null)
}
val wrapAmountWei = balance.movePointRight(6).toBigInteger()
logger.info("开始 wrap USDC.e → pUSD: proxy=${proxyAddress.take(10)}..., amount=$balance")
val unlimitedAllowance = BigInteger.valueOf(2).pow(256).minus(BigInteger.ONE)
val approveTx = relayClientService.createUsdceApproveForWrapTx(unlimitedAllowance)
val wrapTx = relayClientService.createWrapToPusdTx(proxyAddress, wrapAmountWei)
if (walletType == WalletType.MAGIC) {
// MAGIC 账户走 PROXY 时,不使用 Safe MultiSend(delegatecall)
// 改为顺序执行两笔 CALL,避免内层 delegatecall 回滚导致“外层成功但业务失败”。
val approveResult = relayClientService.execute(privateKey, proxyAddress, approveTx, walletType)
val approveHash = approveResult.getOrElse {
logger.error("USDC.e approve 失败: ${it.message}", it)
return Result.failure(it)
}
logger.info("USDC.e approve 成功: txHash=$approveHash")
val wrapResult = relayClientService.execute(privateKey, proxyAddress, wrapTx, walletType)
wrapResult.fold(
onSuccess = { txHash ->
logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
Result.failure(e)
}
)
} else {
val safeTx = relayClientService.createMultiSendTx(listOf(approveTx, wrapTx))
val executeResult = relayClientService.execute(privateKey, proxyAddress, safeTx, walletType)
executeResult.fold(
onSuccess = { txHash ->
logger.info("USDC.e → pUSD wrap 成功: txHash=$txHash")
Result.success(txHash)
},
onFailure = { e ->
logger.error("USDC.e → pUSD wrap 失败: ${e.message}", e)
Result.failure(e)
}
)
}
} catch (e: Exception) {
logger.error("USDC.e → pUSD wrap 异常: ${e.message}", e)
Result.failure(e)
}
}
/** /**
* 获取代理钱包的 nonce(用于构建 Safe 交易) * 获取代理钱包的 nonce(用于构建 Safe 交易)
*/ */
@@ -223,15 +223,7 @@ class PolymarketClobService(
} }
} }
/**
* 创建订单已废弃使用 createSignedOrder 代替
* @deprecated 使用 createSignedOrder 代替需要签名的订单对象
*/
@Deprecated("使用 createSignedOrder 代替")
suspend fun createOrder(request: CreateOrderRequest): Result<OrderResponse> {
return Result.failure(UnsupportedOperationException("已废弃,请使用 createSignedOrder 方法"))
}
/** /**
* 创建签名的订单 * 创建签名的订单
* 注意此方法需要完整的订单签名逻辑当前为占位实现 * 注意此方法需要完整的订单签名逻辑当前为占位实现
@@ -45,7 +45,9 @@ object OnChainWsUtils {
} }
// 合约地址 // 合约地址
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" const val PUSD_CONTRACT = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // V2 pUSD
const val USDC_CONTRACT = PUSD_CONTRACT // 默认使用 pUSD
private val COLLATERAL_CONTRACTS = setOf(PUSD_CONTRACT.lowercase())
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045" const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62" const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
@@ -96,8 +98,8 @@ object OnChainWsUtils {
val t0 = topics[0].lowercase() val t0 = topics[0].lowercase()
val data = log.get("data")?.asString ?: "0x" val data = log.get("data")?.asString ?: "0x"
// USDC ERC20 Transfer // 抵押品 ERC20 Transfer(当前仅匹配 pUSD
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) { if (address in COLLATERAL_CONTRACTS && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
val from = topicToAddress(topics[1]) val from = topicToAddress(topics[1])
val to = topicToAddress(topics[2]) val to = topicToAddress(topics[2])
val value = hexToBigInt(data) val value = hexToBigInt(data)
@@ -149,6 +151,42 @@ object OnChainWsUtils {
return Pair(erc20, erc1155) return Pair(erc20, erc1155)
} }
/**
* 通过 CLOB 交易历史获取 Leader 真实成交价用于无 USDC 转账的 ERC1155-only 场景
* 查询该 token 最近的成交记录匹配 side 方向的第一条作为成交价
* 返回 usdcRaw = price × sizeRaw
*/
private suspend fun fetchEstimatedUsdcRaw(
tokenId: String,
side: String,
sizeRaw: BigInteger,
retrofitFactory: RetrofitFactory
): BigInteger? {
return try {
val clobApi = retrofitFactory.createClobApiWithoutAuth()
val response = clobApi.getTrades(asset_id = tokenId)
if (!response.isSuccessful || response.body() == null) {
logger.warn("CLOB 交易历史查询失败: tokenId=$tokenId, code=${response.code()}")
return null
}
val trades = response.body()!!.data
val clobSide = side.lowercase()
val matchedTrade = trades.firstOrNull { it.side.equals(clobSide, ignoreCase = true) }
if (matchedTrade == null) {
logger.warn("CLOB 交易历史中未找到匹配成交: tokenId=$tokenId, side=$clobSide, totalTrades=${trades.size}")
return null
}
val price = matchedTrade.price.toBigDecimal()
val usdcRaw = price.multiply(sizeRaw.toBigDecimal())
.setScale(0, java.math.RoundingMode.DOWN).toBigInteger()
logger.debug("CLOB 交易历史估算: tokenId=$tokenId, side=$clobSide, tradePrice=${matchedTrade.price}, tradeSize=${matchedTrade.size}, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
usdcRaw
} catch (e: Exception) {
logger.warn("获取 CLOB 交易历史失败: tokenId=$tokenId, side=$side, error=${e.message}")
null
}
}
/** /**
* Transfer 日志解析交易信息 * Transfer 日志解析交易信息
*/ */
@@ -205,6 +243,32 @@ object OnChainWsUtils {
asset = bestOutId asset = bestOutId
sizeRaw = bestOutVal sizeRaw = bestOutVal
usdcRaw = usdcIn usdcRaw = usdcIn
} else if (bestInId != null && bestInVal > BigInteger.ZERO && bestOutId == null
&& usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO
) {
// BUY(无 USDC: 只收到 ERC1155 token,无 USDC 流动(CLOB 内部结算等场景)
side = "BUY"
asset = bestInId
sizeRaw = bestInVal
usdcRaw = fetchEstimatedUsdcRaw(bestInId.toString(), "BUY", bestInVal, retrofitFactory)
?: run {
logger.warn("无法获取估算价格(ERC1155-only BUY: txHash=$txHash, tokenId=$bestInId")
return null
}
logger.debug("ERC1155-only BUY: txHash=$txHash, tokenId=$bestInId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
} else if (bestOutId != null && bestOutVal > BigInteger.ZERO && bestInId == null
&& usdcOut == BigInteger.ZERO && usdcIn == BigInteger.ZERO
) {
// SELL(无 USDC: 只发出 ERC1155 token,无 USDC 流动
side = "SELL"
asset = bestOutId
sizeRaw = bestOutVal
usdcRaw = fetchEstimatedUsdcRaw(bestOutId.toString(), "SELL", bestOutVal, retrofitFactory)
?: run {
logger.warn("无法获取估算价格(ERC1155-only SELL: txHash=$txHash, tokenId=$bestOutId")
return null
}
logger.debug("ERC1155-only SELL: txHash=$txHash, tokenId=$bestOutId, sizeRaw=$sizeRaw, usdcRaw=$usdcRaw")
} else { } else {
// 无法判断交易方向 // 无法判断交易方向
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn") logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
@@ -41,10 +41,9 @@ class OrderSigningService {
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2 return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
} }
// Polygon 主网合约地址(标准 CTF Exchange // V2 合约地址
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E" private val EXCHANGE_CONTRACT = "0xE111180000d2663C0091e4f400237545B87B996B"
// Neg Risk CTF Exchangeneg risk 市场需用此合约签约,否则服务端返回 invalid signature private val NEG_RISK_EXCHANGE_CONTRACT = "0xe2222d279d744050d28e00520010520000310F59"
private val NEG_RISK_EXCHANGE_CONTRACT = "0xC5d563A36AE78145C45a50134d48A1215220f80a"
private val CHAIN_ID = 137L private val CHAIN_ID = 137L
// USDC 有 6 位小数 // USDC 有 6 位小数
@@ -156,8 +155,8 @@ class OrderSigningService {
} }
/** /**
* 创建并签名订单 * 创建并签名订单 (V2)
* *
* @param privateKey 私钥十六进制字符串 * @param privateKey 私钥十六进制字符串
* @param makerAddress maker 地址funder通常是 proxyAddress * @param makerAddress maker 地址funder通常是 proxyAddress
* @param tokenId token ID * @param tokenId token ID
@@ -165,9 +164,6 @@ class OrderSigningService {
* @param price 价格 * @param price 价格
* @param size 数量 * @param size 数量
* @param signatureType 签名类型1: Email/Magic, 2: Browser Wallet, 0: EOA * @param signatureType 签名类型1: Email/Magic, 2: Browser Wallet, 0: EOA
* @param nonce nonce默认 "0"
* @param feeRateBps 费率基点默认 "0"
* @param expiration 过期时间戳0 表示永不过期
* @param exchangeContract 签约用 exchange 合约地址null 时用标准 CTF Exchangeneg risk 市场需传 Neg Risk Exchange * @param exchangeContract 签约用 exchange 合约地址null 时用标准 CTF Exchangeneg risk 市场需传 Neg Risk Exchange
* @return 签名的订单对象 * @return 签名的订单对象
*/ */
@@ -178,10 +174,7 @@ class OrderSigningService {
side: String, side: String,
price: String, price: String,
size: String, size: String,
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致) signatureType: Int = 2,
nonce: String = "0",
feeRateBps: String = "0",
expiration: String = "0",
exchangeContract: String? = null exchangeContract: String? = null
): SignedOrderObject { ): SignedOrderObject {
try { try {
@@ -189,33 +182,32 @@ class OrderSigningService {
val cleanPrivateKey = privateKey.removePrefix("0x") val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = Credentials.create(privateKeyBigInt.toString(16)) val credentials = Credentials.create(privateKeyBigInt.toString(16))
// 统一转换为小写,确保与 EIP-712 编码时使用的地址格式一致
// EIP-712 编码时地址会被转换为小写,所以订单对象中的地址也应该是小写
val signerAddress = credentials.address.lowercase() val signerAddress = credentials.address.lowercase()
// 2. 计算订单金额 // 2. 计算订单金额
val amounts = calculateOrderAmounts(side, size, price) val amounts = calculateOrderAmounts(side, size, price)
// 3. 生成 salt(使用时间戳,毫秒 // 3. 生成 salt 和 timestampV2: timestamp 替代 nonce 保证唯一性
val salt = generateSalt() val salt = generateSalt()
val timestamp = System.currentTimeMillis().toString()
// 4. taker 地址(默认使用零地址)
val taker = "0x0000000000000000000000000000000000000000" // 4. V2 字段默认值
val metadata = "0x0000000000000000000000000000000000000000000000000000000000000000"
val builder = "0x0000000000000000000000000000000000000000000000000000000000000000"
// 5. 确保 maker 地址也是小写格式 // 5. 确保 maker 地址也是小写格式
val makerAddressLower = makerAddress.lowercase() val makerAddressLower = makerAddress.lowercase()
// 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露) logger.debug("========== 订单签名前参数 (V2) ==========")
logger.debug("========== 订单签名前参数 ==========")
logger.debug("订单方向: $side, 价格: $price, 数量: $size") logger.debug("订单方向: $side, 价格: $price, 数量: $size")
logger.debug("Token ID: $tokenId") logger.debug("Token ID: $tokenId")
logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}") logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}")
logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}") logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}")
logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}") logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}")
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps") logger.debug("Salt: $salt, Timestamp: $timestamp")
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID") logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
// 6. 构建订单数据并签名neg risk 市场需用 NEG_RISK_EXCHANGE_CONTRACT // 6. 构建订单数据并签名
val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT
val signature = signOrder( val signature = signOrder(
privateKey = privateKey, privateKey = privateKey,
@@ -224,44 +216,41 @@ class OrderSigningService {
salt = salt, salt = salt,
maker = makerAddressLower, maker = makerAddressLower,
signer = signerAddress, signer = signerAddress,
taker = taker,
tokenId = tokenId, tokenId = tokenId,
makerAmount = amounts.makerAmount, makerAmount = amounts.makerAmount,
takerAmount = amounts.takerAmount, takerAmount = amounts.takerAmount,
expiration = expiration,
nonce = nonce,
feeRateBps = feeRateBps,
side = side.uppercase(), side = side.uppercase(),
signatureType = signatureType signatureType = signatureType,
timestamp = timestamp,
metadata = metadata,
builder = builder
) )
// 7. 创建签名订单对象 // 7. 创建 V2 签名订单对象
// 注意:所有地址字段都使用小写格式,确保与签名时使用的地址一致
return SignedOrderObject( return SignedOrderObject(
salt = salt, salt = salt,
maker = makerAddressLower, maker = makerAddressLower,
signer = signerAddress, signer = signerAddress,
taker = taker, taker = "0x0000000000000000000000000000000000000000",
tokenId = tokenId, tokenId = tokenId,
makerAmount = amounts.makerAmount, makerAmount = amounts.makerAmount,
takerAmount = amounts.takerAmount, takerAmount = amounts.takerAmount,
expiration = expiration,
nonce = nonce,
feeRateBps = feeRateBps,
side = side.uppercase(), side = side.uppercase(),
signatureType = signatureType, signatureType = signatureType,
timestamp = timestamp,
expiration = "0",
metadata = metadata,
builder = builder,
signature = signature signature = signature
) )
} catch (e: Exception) { } catch (e: Exception) {
logger.error("创建并签名订单失败", e) logger.error("创建并签名订单失败 (V2)", e)
throw RuntimeException("创建并签名订单失败: ${e.message}", e) throw RuntimeException("创建并签名订单失败 (V2): ${e.message}", e)
} }
} }
/** /**
* 签名订单EIP-712 * 签名订单 V2EIP-712
*
* 参考: @polymarket/order-utils ExchangeOrderBuilder
*/ */
private fun signOrder( private fun signOrder(
privateKey: String, privateKey: String,
@@ -270,56 +259,47 @@ class OrderSigningService {
salt: Long, salt: Long,
maker: String, maker: String,
signer: String, signer: String,
taker: String,
tokenId: String, tokenId: String,
makerAmount: String, makerAmount: String,
takerAmount: String, takerAmount: String,
expiration: String,
nonce: String,
feeRateBps: String,
side: String, side: String,
signatureType: Int signatureType: Int,
timestamp: String,
metadata: String,
builder: String
): String { ): String {
try { try {
// 1. 私钥与密钥对
val cleanPrivateKey = privateKey.removePrefix("0x") val cleanPrivateKey = privateKey.removePrefix("0x")
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16) val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
val credentials = Credentials.create(privateKeyBigInt.toString(16)) val credentials = Credentials.create(privateKeyBigInt.toString(16))
val ecKeyPair = credentials.ecKeyPair val ecKeyPair = credentials.ecKeyPair
// 2. 编码域分隔符(verifyingContract 显式小写,与 EIP-712 约定一致)
val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain( val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain(
chainId = chainId, chainId = chainId,
verifyingContract = exchangeContract.lowercase() verifyingContract = exchangeContract.lowercase()
) )
// 3. 编码订单消息哈希
// signatureType1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder( val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
salt = salt, salt = salt,
maker = maker, maker = maker,
signer = signer, signer = signer,
taker = taker,
tokenId = tokenId, tokenId = tokenId,
makerAmount = makerAmount, makerAmount = makerAmount,
takerAmount = takerAmount, takerAmount = takerAmount,
expiration = expiration,
nonce = nonce,
feeRateBps = feeRateBps,
side = side, side = side,
signatureType = signatureType signatureType = signatureType,
timestamp = timestamp,
metadata = metadata,
builder = builder
) )
// 4. 计算完整 EIP-712 结构化数据哈希
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData( val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
domainSeparator = domainSeparator, domainSeparator = domainSeparator,
messageHash = orderHash messageHash = orderHash
) )
// 5. 使用私钥签名(needToHash=false,对 32 字节 hash 直接签名)
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false) val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
// 6. 组合 r + s + v
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') 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 sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
val vBytes = signature.v val vBytes = signature.v
@@ -328,8 +308,8 @@ class OrderSigningService {
return "0x$rHex$sHex$vHex" return "0x$rHex$sHex$vHex"
} catch (e: Exception) { } catch (e: Exception) {
logger.error("订单签名失败", e) logger.error("订单签名失败 (V2)", e)
throw RuntimeException("订单签名失败: ${e.message}", e) throw RuntimeException("订单签名失败 (V2): ${e.message}", e)
} }
} }
@@ -562,16 +562,7 @@ open class CopyOrderTrackingService(
// 解密私钥 // 解密私钥
val decryptedPrivateKey = decryptPrivateKey(account) val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求) logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity")
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
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 // Neg Risk 市场需用 Neg Risk Exchange 签约,否则服务端返回 invalid signature
val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true
@@ -594,7 +585,6 @@ open class CopyOrderTrackingService(
owner = account.apiKey, owner = account.apiKey,
copyTradingId = copyTrading.id!!, copyTradingId = copyTrading.id!!,
tradeId = trade.id, tradeId = trade.id,
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
) )
@@ -1018,15 +1008,6 @@ open class CopyOrderTrackingService(
// 8. 解密私钥(在方法开始时解密一次,后续复用) // 8. 解密私钥(在方法开始时解密一次,后续复用)
val decryptedPrivateKey = decryptPrivateKey(account) val decryptedPrivateKey = decryptPrivateKey(account)
// 获取费率(根据 Polymarket Maker Rebates Program 要求)
val feeRateResult = clobService.getFeeRate(tokenId)
val feeRateBps = if (feeRateResult.isSuccess) {
feeRateResult.getOrNull()?.toString() ?: "0"
} else {
logger.warn("获取费率失败,使用默认值 0: tokenId=$tokenId, error=${feeRateResult.exceptionOrNull()?.message}")
"0"
}
// 9. Neg Risk 市场需用 Neg Risk Exchange 签约 // 9. Neg Risk 市场需用 Neg Risk Exchange 签约
val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true
val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell) val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell)
@@ -1042,9 +1023,6 @@ open class CopyOrderTrackingService(
price = sellPrice.toString(), price = sellPrice.toString(),
size = totalMatched.toString(), size = totalMatched.toString(),
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType), signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0",
exchangeContract = exchangeContractSell exchangeContract = exchangeContractSell
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -1058,8 +1036,7 @@ open class CopyOrderTrackingService(
val orderRequest = NewOrderRequest( val orderRequest = NewOrderRequest(
order = signedOrder, order = signedOrder,
owner = account.apiKey, owner = account.apiKey,
orderType = "FAK", // Fill-And-Kill orderType = "FAK" // Fill-And-Kill
deferExec = false
) )
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证) // 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
@@ -1084,7 +1061,6 @@ open class CopyOrderTrackingService(
owner = account.apiKey, owner = account.apiKey,
copyTradingId = copyTrading.id, copyTradingId = copyTrading.id,
tradeId = leaderSellTrade.id, tradeId = leaderSellTrade.id,
feeRateBps = feeRateBps,
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType) signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
) )
@@ -1179,7 +1155,6 @@ open class CopyOrderTrackingService(
* @param owner API Key用于owner字段 * @param owner API Key用于owner字段
* @param copyTradingId 跟单配置ID用于日志 * @param copyTradingId 跟单配置ID用于日志
* @param tradeId Leader 交易ID用于日志 * @param tradeId Leader 交易ID用于日志
* @param feeRateBps 费率基点从API动态获取
* @param signatureType 签名类型1=Magic, 2=Safe * @param signatureType 签名类型1=Magic, 2=Safe
* @return 成功返回订单ID失败返回异常 * @return 成功返回订单ID失败返回异常
*/ */
@@ -1196,7 +1171,6 @@ open class CopyOrderTrackingService(
owner: String, owner: String,
copyTradingId: Long, copyTradingId: Long,
tradeId: String, tradeId: String,
feeRateBps: String,
signatureType: Int signatureType: Int
): Result<String> { ): Result<String> {
var lastError: Exception? = null var lastError: Exception? = null
@@ -1213,9 +1187,6 @@ open class CopyOrderTrackingService(
price = price, price = price,
size = size, size = size,
signatureType = signatureType, signatureType = signatureType,
nonce = "0",
feeRateBps = feeRateBps, // 使用动态获取的费率
expiration = "0",
exchangeContract = exchangeContract exchangeContract = exchangeContract
) )
@@ -1232,8 +1203,7 @@ open class CopyOrderTrackingService(
val orderRequest = NewOrderRequest( val orderRequest = NewOrderRequest(
order = signedOrder, order = signedOrder,
owner = owner, owner = owner,
orderType = "FAK", // Fill-And-Kill orderType = "FAK" // Fill-And-Kill
deferExec = false
) )
// 调用 API 创建订单 // 调用 API 创建订单
@@ -815,9 +815,11 @@ class OrderStatusUpdateService(
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity
val actualOutcome = orderDetail.outcome val actualOutcome = orderDetail.outcome
// 使用交易所订单的实际创建时间(API返回秒级,转为毫秒)
val actualCreatedAt = if (orderDetail.createdAt > 0) orderDetail.createdAt * 1000 else order.createdAt
// 更新订单数据(如果实际数据与临时数据不同) // 更新订单数据(如果实际数据与临时数据不同)
val needUpdate = actualPrice != order.price || actualSize != order.quantity val needUpdate = actualPrice != order.price || actualSize != order.quantity || actualCreatedAt != order.createdAt
// 先保存更新后的订单,标记 notificationSent = true // 先保存更新后的订单,标记 notificationSent = true
// 这样可以防止其他并发任务重复发送通知 // 这样可以防止其他并发任务重复发送通知
@@ -838,7 +840,7 @@ class OrderStatusUpdateService(
status = order.status, status = order.status,
notificationSent = true, // 标记为已发送通知 notificationSent = true, // 标记为已发送通知
source = order.source, // 保留原始订单来源 source = order.source, // 保留原始订单来源
createdAt = order.createdAt, createdAt = actualCreatedAt,
updatedAt = System.currentTimeMillis() updatedAt = System.currentTimeMillis()
) )
@@ -61,7 +61,6 @@ private data class PeriodContext(
val apiSecretDecrypted: String, val apiSecretDecrypted: String,
val apiPassphraseDecrypted: String, val apiPassphraseDecrypted: String,
val clobApi: PolymarketClobApi, val clobApi: PolymarketClobApi,
val feeRateByTokenId: Map<String, String>,
val signatureType: Int, val signatureType: Int,
val tokenIds: List<String>, val tokenIds: List<String>,
val marketTitle: String? val marketTitle: String?
@@ -152,9 +151,6 @@ class CryptoTailStrategyExecutionService(
} }
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress) 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) val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < MIN_ORDER_USDC) return null if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < MIN_ORDER_USDC) return null
@@ -167,7 +163,6 @@ class CryptoTailStrategyExecutionService(
apiSecretDecrypted = apiSecret, apiSecretDecrypted = apiSecret,
apiPassphraseDecrypted = apiPassphrase, apiPassphraseDecrypted = apiPassphrase,
clobApi = clobApi, clobApi = clobApi,
feeRateByTokenId = feeRateByTokenId,
signatureType = signatureType, signatureType = signatureType,
tokenIds = tokenIds, tokenIds = tokenIds,
marketTitle = marketTitle marketTitle = marketTitle
@@ -397,7 +392,6 @@ class CryptoTailStrategyExecutionService(
} }
val priceStr = price.toPlainString() val priceStr = price.toPlainString()
val size = computeSize(amountUsdc, price) val size = computeSize(amountUsdc, price)
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
val signedOrder = orderSigningService.createAndSignOrder( val signedOrder = orderSigningService.createAndSignOrder(
privateKey = ctx.decryptedPrivateKey, privateKey = ctx.decryptedPrivateKey,
makerAddress = ctx.account.proxyAddress, makerAddress = ctx.account.proxyAddress,
@@ -405,16 +399,12 @@ class CryptoTailStrategyExecutionService(
side = "BUY", side = "BUY",
price = priceStr, price = priceStr,
size = size, size = size,
signatureType = ctx.signatureType, signatureType = ctx.signatureType
nonce = "0",
feeRateBps = feeRateBps,
expiration = "0"
) )
val orderRequest = NewOrderRequest( val orderRequest = NewOrderRequest(
order = signedOrder, order = signedOrder,
owner = ctx.account.apiKey!!, owner = ctx.account.apiKey!!,
orderType = "FAK", orderType = "FAK"
deferExec = false
) )
submitOrderAndSaveRecord( submitOrderAndSaveRecord(
ctx.clobApi, ctx.clobApi,
@@ -609,7 +599,6 @@ class CryptoTailStrategyExecutionService(
"" ""
} }
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress) 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 signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
val signedOrder = orderSigningService.createAndSignOrder( val signedOrder = orderSigningService.createAndSignOrder(
@@ -619,16 +608,12 @@ class CryptoTailStrategyExecutionService(
side = "BUY", side = "BUY",
price = priceStr, price = priceStr,
size = size, size = size,
signatureType = signatureType, signatureType = signatureType
nonce = "0",
feeRateBps = feeRateBps,
expiration = "0"
) )
val orderRequest = NewOrderRequest( val orderRequest = NewOrderRequest(
order = signedOrder, order = signedOrder,
owner = account.apiKey!!, owner = account.apiKey!!,
orderType = "FAK", orderType = "FAK"
deferExec = false
) )
submitOrderAndSaveRecord( submitOrderAndSaveRecord(
clobApi, clobApi,
@@ -717,7 +702,7 @@ class CryptoTailStrategyExecutionService(
val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP) val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP)
if (amountUsdc < BigDecimal.ONE) { if (amountUsdc < BigDecimal.ONE) {
return Result.failure(IllegalArgumentException("总金额不能少于 1 USDC")) return Result.failure(IllegalArgumentException("总金额不能少于 \$1"))
} }
val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix) val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix)
@@ -745,7 +730,6 @@ class CryptoTailStrategyExecutionService(
val priceStr = priceRounded.toPlainString() val priceStr = priceRounded.toPlainString()
val sizeStr = size.toPlainString() val sizeStr = size.toPlainString()
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
val signedOrder = orderSigningService.createAndSignOrder( val signedOrder = orderSigningService.createAndSignOrder(
privateKey = ctx.decryptedPrivateKey, privateKey = ctx.decryptedPrivateKey,
@@ -754,17 +738,13 @@ class CryptoTailStrategyExecutionService(
side = "BUY", side = "BUY",
price = priceStr, price = priceStr,
size = sizeStr, size = sizeStr,
signatureType = ctx.signatureType, signatureType = ctx.signatureType
nonce = "0",
feeRateBps = feeRateBps,
expiration = "0"
) )
val orderRequest = NewOrderRequest( val orderRequest = NewOrderRequest(
order = signedOrder, order = signedOrder,
owner = ctx.account.apiKey!!, owner = ctx.account.apiKey!!,
orderType = "FAK", orderType = "FAK"
deferExec = false
) )
val orderResult = submitOrderForManualOrder( val orderResult = submitOrderForManualOrder(
@@ -0,0 +1,67 @@
package com.wrbug.polymarketbot.service.system
import com.google.gson.Gson
import com.wrbug.polymarketbot.dto.GeoblockCheckDto
import com.wrbug.polymarketbot.dto.PolymarketGeoblockApiResponse
import com.wrbug.polymarketbot.util.createClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.concurrent.TimeUnit
@Service
class GeoblockService(
private val gson: Gson
) {
private val logger = LoggerFactory.getLogger(GeoblockService::class.java)
companion object {
private const val GEOBLOCK_URL = "https://polymarket.com/api/geoblock"
}
suspend fun checkGeoblock(): Result<GeoblockCheckDto> = withContext(Dispatchers.IO) {
val client = createClient()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
checkGeoblockWithClient(client)
}
fun checkGeoblockWithClient(client: OkHttpClient): Result<GeoblockCheckDto> {
return try {
val request = Request.Builder()
.url(GEOBLOCK_URL)
.get()
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return Result.failure(
IllegalStateException("HTTP ${response.code}: ${response.message}")
)
}
val body = response.body?.string()
?: return Result.failure(IllegalStateException("Empty response body"))
val apiResponse = gson.fromJson(body, PolymarketGeoblockApiResponse::class.java)
Result.success(
GeoblockCheckDto(
blocked = apiResponse.blocked,
ip = apiResponse.ip,
country = apiResponse.country,
region = apiResponse.region,
checkedAt = System.currentTimeMillis()
)
)
}
} catch (e: Exception) {
logger.warn("Geoblock 检查失败", e)
Result.failure(e)
}
}
}
@@ -169,9 +169,9 @@ class NotificationTemplateService(
方向: <b>{{side}}</b> 方向: <b>{{side}}</b>
价格: <code>{{price}}</code> 价格: <code>{{price}}</code>
数量: <code>{{quantity}}</code> shares 数量: <code>{{quantity}}</code> shares
金额: <code>{{amount}}</code> USDC 金额: <code>${'$'}{{amount}}</code>
账户: {{account_name}} 账户: {{account_name}}
可用余额: <code>{{available_balance}}</code> USDC 可用余额: <code>${'$'}{{available_balance}}</code>
时间: <code>{{time}}</code> 时间: <code>{{time}}</code>
""".trimIndent(), """.trimIndent(),
@@ -184,7 +184,7 @@ class NotificationTemplateService(
方向: <b>{{side}}</b> 方向: <b>{{side}}</b>
价格: <code>{{price}}</code> 价格: <code>{{price}}</code>
数量: <code>{{quantity}}</code> shares 数量: <code>{{quantity}}</code> shares
金额: <code>{{amount}}</code> USDC 金额: <code>${'$'}{{amount}}</code>
账户: {{account_name}} 账户: {{account_name}}
<b>错误信息</b> <b>错误信息</b>
@@ -201,7 +201,7 @@ class NotificationTemplateService(
方向: <b>{{side}}</b> 方向: <b>{{side}}</b>
价格: <code>{{price}}</code> 价格: <code>{{price}}</code>
数量: <code>{{quantity}}</code> shares 数量: <code>{{quantity}}</code> shares
金额: <code>{{amount}}</code> USDC 金额: <code>${'$'}{{amount}}</code>
账户: {{account_name}} 账户: {{account_name}}
<b>过滤类型</b> <code>{{filter_type}}</code> <b>过滤类型</b> <code>{{filter_type}}</code>
@@ -222,7 +222,7 @@ class NotificationTemplateService(
方向: <b>{{side}}</b> 方向: <b>{{side}}</b>
价格: <code>{{price}}</code> 价格: <code>{{price}}</code>
数量: <code>{{quantity}}</code> shares 数量: <code>{{quantity}}</code> shares
金额: <code>{{amount}}</code> USDC 金额: <code>${'$'}{{amount}}</code>
账户: {{account_name}} 账户: {{account_name}}
时间: <code>{{time}}</code> 时间: <code>{{time}}</code>
@@ -233,8 +233,8 @@ class NotificationTemplateService(
📊 <b>赎回信息</b> 📊 <b>赎回信息</b>
账户: {{account_name}} 账户: {{account_name}}
交易哈希: <code>{{transaction_hash}}</code> 交易哈希: <code>{{transaction_hash}}</code>
赎回总价值: <code>{{total_value}}</code> USDC 赎回总价值: <code>${'$'}{{total_value}}</code>
可用余额: <code>{{available_balance}}</code> USDC 可用余额: <code>${'$'}{{available_balance}}</code>
时间: <code>{{time}}</code> 时间: <code>{{time}}</code>
""".trimIndent(), """.trimIndent(),
@@ -246,7 +246,7 @@ class NotificationTemplateService(
账户: {{account_name}} 账户: {{account_name}}
交易哈希: <code>{{transaction_hash}}</code> 交易哈希: <code>{{transaction_hash}}</code>
可用余额: <code>{{available_balance}}</code> USDC 可用余额: <code>${'$'}{{available_balance}}</code>
时间: <code>{{time}}</code> 时间: <code>{{time}}</code>
""".trimIndent() """.trimIndent()
@@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit
*/ */
@Service @Service
class ProxyConfigService( class ProxyConfigService(
private val proxyConfigRepository: ProxyConfigRepository private val proxyConfigRepository: ProxyConfigRepository,
private val geoblockService: GeoblockService
) : ApplicationContextAware { ) : ApplicationContextAware {
private var applicationContext: ApplicationContext? = null private var applicationContext: ApplicationContext? = null
@@ -148,100 +149,153 @@ class ProxyConfigService(
} }
/** /**
* 检查代理是否可用 * 检查代理是否可用并检测经该代理访问 Polymarket 时的地域限制
* 使用配置的代理请求 Polymarket 健康检查接口
*/ */
fun checkProxy(): ProxyCheckResponse { fun checkProxy(): ProxyCheckResponse {
return try { return try {
val config = proxyConfigRepository.findByEnabledTrue() val config = proxyConfigRepository.findByEnabledTrue()
?: return ProxyCheckResponse.create( if (config == null) {
return ProxyCheckResponse.create(
success = false, success = false,
message = "未配置代理或代理未启用" message = "未配置代理或代理未启用",
geoblock = checkGeoblockForProxy(null)
) )
}
if (config.type != "HTTP") { if (config.type != "HTTP") {
return ProxyCheckResponse.create( return ProxyCheckResponse.create(
success = false, success = false,
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)" message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)",
geoblock = checkGeoblockForProxy(null)
) )
} }
if (config.host == null || config.port == null) { if (config.host == null || config.port == null) {
return ProxyCheckResponse.create( return ProxyCheckResponse.create(
success = false, success = false,
message = "代理配置不完整:缺少主机或端口" message = "代理配置不完整:缺少主机或端口",
geoblock = checkGeoblockForProxy(null)
) )
} }
// 创建代理 val client = buildProxyHttpClient(config)
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port)) val geoblock = checkGeoblockForProxy(client)
// 创建 OkHttpClient
val clientBuilder = OkHttpClient.Builder()
.proxy(proxy)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
// 配置 SSL:信任所有证书(用于代理连接)
clientBuilder.createSSLSocketFactory()
clientBuilder.hostnameVerifier(TrustAllHostnameVerifier())
// 如果配置了用户名和密码,添加代理认证
if (config.username != null && config.password != null) {
clientBuilder.proxyAuthenticator { _, response ->
val credential = okhttp3.Credentials.basic(config.username, config.password)
response.request.newBuilder()
.header("Proxy-Authorization", credential)
.build()
}
}
val client = clientBuilder.build()
// 请求 Polymarket 健康检查接口
val request = Request.Builder() val request = Request.Builder()
.url("https://data-api.polymarket.com/") .url("https://data-api.polymarket.com/")
.get() .get()
.build() .build()
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
val responseTime = System.currentTimeMillis() - startTime val responseTime = System.currentTimeMillis() - startTime
val responseBody = response.body?.string() val responseBody = response.body?.string()
if (response.isSuccessful && responseBody != null) { if (response.isSuccessful && responseBody != null) {
// 检查响应内容是否为 {"data": "OK"}
if (responseBody.contains("\"data\"") && responseBody.contains("OK")) { if (responseBody.contains("\"data\"") && responseBody.contains("OK")) {
logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms") logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms")
val message = buildProxyCheckMessage("代理连接成功", geoblock)
ProxyCheckResponse.create( ProxyCheckResponse.create(
success = true, success = true,
message = "代理连接成功", message = message,
responseTime = responseTime responseTime = responseTime,
geoblock = geoblock
) )
} else { } else {
ProxyCheckResponse.create( ProxyCheckResponse.create(
success = false, success = false,
message = "代理连接成功,但响应格式不正确:$responseBody", message = buildProxyCheckMessage(
responseTime = responseTime "代理连接成功,但响应格式不正确:$responseBody",
geoblock
),
responseTime = responseTime,
geoblock = geoblock
) )
} }
} else { } else {
ProxyCheckResponse.create( ProxyCheckResponse.create(
success = false, success = false,
message = "代理连接失败:HTTP ${response.code} ${response.message}", message = buildProxyCheckMessage(
responseTime = responseTime "代理连接失败:HTTP ${response.code} ${response.message}",
geoblock
),
responseTime = responseTime,
geoblock = geoblock
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.error("代理检查异常", e) logger.error("代理检查异常", e)
ProxyCheckResponse.create( ProxyCheckResponse.create(
success = false, success = false,
message = "代理检查失败:${e.message}" message = "代理检查失败:${e.message}",
geoblock = checkGeoblockForProxy(null)
) )
} }
} }
private fun buildProxyHttpClient(config: ProxyConfig): OkHttpClient {
val host = requireNotNull(config.host) { "代理主机不能为空" }
val port = requireNotNull(config.port) { "代理端口不能为空" }
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
val clientBuilder = OkHttpClient.Builder()
.proxy(proxy)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
clientBuilder.createSSLSocketFactory()
clientBuilder.hostnameVerifier(TrustAllHostnameVerifier())
if (config.username != null && config.password != null) {
clientBuilder.proxyAuthenticator { _, response ->
val credential = Credentials.basic(config.username, config.password)
response.request.newBuilder()
.header("Proxy-Authorization", credential)
.build()
}
}
return clientBuilder.build()
}
private fun checkGeoblockForProxy(client: OkHttpClient?): ProxyCheckGeoblockResult {
val httpClient = client ?: com.wrbug.polymarketbot.util.createClient()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.build()
return geoblockService.checkGeoblockWithClient(httpClient).fold(
onSuccess = { dto ->
ProxyCheckGeoblockResult(
checked = true,
blocked = dto.blocked,
ip = dto.ip,
country = dto.country,
region = dto.region,
message = if (dto.blocked) {
"当前出口 IP${dto.country}/${dto.region})在 Polymarket 受限,无法下单"
} else {
"当前出口 IP${dto.country}/${dto.region})可向 Polymarket 下单"
}
)
},
onFailure = { e ->
ProxyCheckGeoblockResult(
checked = true,
message = "地域限制检测失败:${e.message}"
)
}
)
}
private fun buildProxyCheckMessage(baseMessage: String, geoblock: ProxyCheckGeoblockResult): String {
if (!geoblock.checked || geoblock.message.isNullOrBlank()) {
return baseMessage
}
return "$baseMessage${geoblock.message}"
}
/** /**
* 删除代理配置 * 删除代理配置
@@ -39,8 +39,14 @@ class RelayClientService(
// ConditionalTokens 合约地址 // ConditionalTokens 合约地址
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
// USDC.e 合约地址(普通市场抵押品) // pUSD 合约地址(普通市场抵押品)
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
// USDC.e 合约地址(仅用于 wrap 到 pUSD)
private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
// CollateralOnramp 合约地址(USDC.e → pUSD
private val collateralOnrampAddress = "0x93070a847efEf7F70739046A929D47a521F5B8ee"
// Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygonneg-risk-ctf-adapter // Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygonneg-risk-ctf-adapter
private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2" private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
@@ -51,7 +57,9 @@ class RelayClientService(
// Polygon PROXYMagic)合约地址,参考 builder-relayer-client config // Polygon PROXYMagic)合约地址,参考 builder-relayer-client config
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052" private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494" private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
private val defaultProxyGasLimit = "10000000" // PROXY relayCall 内层 gasLimit(签名参数)不能给过大值,否则 RelayHub 会因 gasleft 校验失败回滚。
private val defaultProxyGasLimit = "2400000"
private val maxProxyGasLimit = BigInteger.valueOf(2400000)
// Safe MultiSend 合约地址(Polygon 主网) // Safe MultiSend 合约地址(Polygon 主网)
private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761" private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
@@ -282,14 +290,14 @@ class RelayClientService(
} }
/** /**
* 创建 WCOL 解包交易 Wrapped Collateral 解包为 USDC.e * 创建 WCOL 解包交易 Wrapped Collateral 执行解包
* 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2 * 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
* 方法: unwrap(address _to, uint256 _amount)解包后 USDC.e 转到 _to * 方法: unwrap(address _to, uint256 _amount)
* *
* Safe Magic 共用此交易对象Safe [executeViaBuilderRelayer] / [executeManually]execTransaction * Safe Magic 共用此交易对象Safe [executeViaBuilderRelayer] / [executeManually]execTransaction
* Magic [executeViaBuilderRelayerProxy]encodeProxyTransactionData语义一致 * Magic [executeViaBuilderRelayerProxy]encodeProxyTransactionData语义一致
* *
* @param toAddress 接收 USDC.e 的地址通常为 proxy 自身使余额留在代理钱包 * @param toAddress 接收解包资产的地址通常为 proxy 自身使余额留在代理钱包
* @param amountWei WCOL 数量6 位小数对应的 raw balanceOf 返回一致 * @param amountWei WCOL 数量6 位小数对应的 raw balanceOf 返回一致
* @return Safe 交易对象 * @return Safe 交易对象
*/ */
@@ -323,6 +331,41 @@ class RelayClientService(
) )
} }
/**
* 创建 USDC.e approve 交易用于 wrap pUSD
* 授权 CollateralOnramp 合约花费用户的 USDC.e
*/
fun createUsdceApproveForWrapTx(amount: BigInteger): SafeTransaction {
val functionSelector = EthereumUtils.getFunctionSelector("approve(address,uint256)")
val encodedSpender = EthereumUtils.encodeAddress(collateralOnrampAddress)
val encodedAmount = EthereumUtils.encodeUint256(amount)
val callData = "0x" + functionSelector.removePrefix("0x") + encodedSpender + encodedAmount
return SafeTransaction(
to = usdceContractAddress,
operation = 0,
data = callData,
value = "0"
)
}
/**
* 创建 USDC.e pUSD wrap 交易
* CollateralOnramp.wrap(address _asset, address _to, uint256 _amount)
*/
fun createWrapToPusdTx(recipientAddress: String, amount: BigInteger): SafeTransaction {
val functionSelector = EthereumUtils.getFunctionSelector("wrap(address,address,uint256)")
val asset = EthereumUtils.encodeAddress(usdceContractAddress)
val to = EthereumUtils.encodeAddress(recipientAddress)
val amt = EthereumUtils.encodeUint256(amount)
val callData = "0x" + functionSelector.removePrefix("0x") + asset + to + amt
return SafeTransaction(
to = collateralOnrampAddress,
operation = 0,
data = callData,
value = "0"
)
}
/** /**
* 创建 MultiSend 交易合并多个 SafeTransaction 为一笔交易 * 创建 MultiSend 交易合并多个 SafeTransaction 为一笔交易
* 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction * 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction
@@ -489,7 +532,16 @@ class RelayClientService(
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit // 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit
val gasLimit = try { val gasLimit = try {
estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData) val estimatedGasLimit = estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
val estimatedBigInt = BigInteger(estimatedGasLimit)
if (estimatedBigInt > maxProxyGasLimit) {
logger.warn(
"估算 PROXY gas limit 过大,进行截断: estimated=$estimatedGasLimit, capped=$maxProxyGasLimit"
)
maxProxyGasLimit.toString()
} else {
estimatedGasLimit
}
} catch (e: Exception) { } catch (e: Exception) {
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e) logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
defaultProxyGasLimit defaultProxyGasLimit
@@ -691,7 +691,7 @@ class TelegramNotificationService(
$sideLabel: <b>$sideDisplay</b> $sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$priceDisplay</code> $priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares $quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC $amountLabel: <code>${'$'}$amountDisplay</code>
$accountLabel: $escapedAccountInfo $accountLabel: $escapedAccountInfo
<b>$filterTypeLabel</b> <code>$filterTypeDisplay</code> <b>$filterTypeLabel</b> <code>$filterTypeDisplay</code>
@@ -1169,9 +1169,9 @@ class TelegramNotificationService(
} else { } else {
balanceDecimal.stripTrailingZeros() balanceDecimal.stripTrailingZeros()
} }
"\n$availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC" "\n$availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
} catch (e: Exception) { } catch (e: Exception) {
"\n$availableBalanceLabel: <code>$availableBalance</code> USDC" "\n$availableBalanceLabel: <code>${'$'}$availableBalance</code>"
} }
} else { } else {
"" ""
@@ -1185,7 +1185,7 @@ class TelegramNotificationService(
$sideLabel: <b>$sideDisplay</b> $sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$priceDisplay</code> $priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares $quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC $amountLabel: <code>${'$'}$amountDisplay</code>
$accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay
$timeLabel: <code>$time</code>""" $timeLabel: <code>$time</code>"""
@@ -1264,7 +1264,7 @@ class TelegramNotificationService(
$sideLabel: <b>$sideDisplay</b> $sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$priceDisplay</code> $priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares $quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC $amountLabel: <code>${'$'}$amountDisplay</code>
$accountLabel: $escapedAccountInfo $accountLabel: $escapedAccountInfo
$timeLabel: <code>$time</code>""" $timeLabel: <code>$time</code>"""
@@ -1381,7 +1381,7 @@ class TelegramNotificationService(
$sideLabel: <b>$sideDisplay</b> $sideLabel: <b>$sideDisplay</b>
$priceLabel: <code>$priceDisplay</code> $priceLabel: <code>$priceDisplay</code>
$quantityLabel: <code>$sizeDisplay</code> shares $quantityLabel: <code>$sizeDisplay</code> shares
$amountLabel: <code>$amountDisplay</code> USDC $amountLabel: <code>${'$'}$amountDisplay</code>
$accountLabel: $escapedAccountInfo $accountLabel: $escapedAccountInfo
<b>$errorInfo</b> <b>$errorInfo</b>
@@ -1515,7 +1515,7 @@ class TelegramNotificationService(
} catch (e: Exception) { } catch (e: Exception) {
position.value position.value
} }
"${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC" "${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = ${'$'}$valueDisplay"
} }
// 格式化可用余额 // 格式化可用余额
@@ -1527,9 +1527,9 @@ class TelegramNotificationService(
} else { } else {
balanceDecimal.stripTrailingZeros() balanceDecimal.stripTrailingZeros()
} }
"\n$availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC" "\n$availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
} catch (e: Exception) { } catch (e: Exception) {
"\n$availableBalanceLabel: <code>$availableBalance</code> USDC" "\n$availableBalanceLabel: <code>${'$'}$availableBalance</code>"
} }
} else { } else {
"" ""
@@ -1540,7 +1540,7 @@ class TelegramNotificationService(
📊 <b>$redeemInfo</b> 📊 <b>$redeemInfo</b>
$accountLabel: $escapedAccountInfo $accountLabel: $escapedAccountInfo
$transactionHashLabel: <code>$escapedTxHash</code> $transactionHashLabel: <code>$escapedTxHash</code>
$totalValueLabel: <code>$totalValueDisplay</code> USDC$availableBalanceDisplay $totalValueLabel: <code>${'$'}$totalValueDisplay</code>$availableBalanceDisplay
📦 <b>$positionsLabel</b> 📦 <b>$positionsLabel</b>
$positionsText $positionsText
@@ -1642,9 +1642,9 @@ $positionsText
} else { } else {
balanceDecimal.stripTrailingZeros() balanceDecimal.stripTrailingZeros()
} }
"\n$availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC" "\n$availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
} catch (e: Exception) { } catch (e: Exception) {
"\n$availableBalanceLabel: <code>$availableBalance</code> USDC" "\n$availableBalanceLabel: <code>${'$'}$availableBalance</code>"
} }
} else { } else {
"" ""
@@ -167,9 +167,8 @@ object Eip712Encoder {
} }
/** /**
* 编码 ExchangeOrder 域分隔符 * 编码 ExchangeOrder V2 域分隔符
* 参考: @polymarket/order-utils ExchangeOrderBuilder * Domain: { name: "Polymarket CTF Exchange", version: "2", chainId: chainId, verifyingContract: exchangeContract }
* Domain: { name: "Polymarket CTF Exchange", version: "1", chainId: chainId, verifyingContract: exchangeContract }
*/ */
fun encodeExchangeDomain( fun encodeExchangeDomain(
chainId: Long, chainId: Long,
@@ -184,9 +183,9 @@ object Eip712Encoder {
"verifyingContract" to "address" "verifyingContract" to "address"
) )
) )
val nameHash = encodeString("Polymarket CTF Exchange") val nameHash = encodeString("Polymarket CTF Exchange")
val versionHash = encodeString("1") val versionHash = encodeString("2")
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId)) val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
val contractBytes = encodeAddress(verifyingContract) val contractBytes = encodeAddress(verifyingContract)
@@ -201,23 +200,21 @@ object Eip712Encoder {
} }
/** /**
* 编码 ExchangeOrder 消息哈希 * 编码 ExchangeOrder V2 消息哈希
* 参考: @polymarket/order-utils ExchangeOrderBuilder * V2 Order: { salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder }
* Order: { salt, maker, signer, taker, tokenId, makerAmount, takerAmount, expiration, nonce, feeRateBps, side, signatureType }
*/ */
fun encodeExchangeOrder( fun encodeExchangeOrder(
salt: Long, salt: Long,
maker: String, maker: String,
signer: String, signer: String,
taker: String,
tokenId: String, tokenId: String,
makerAmount: String, makerAmount: String,
takerAmount: String, takerAmount: String,
expiration: String,
nonce: String,
feeRateBps: String,
side: String, side: String,
signatureType: Int signatureType: Int,
timestamp: String,
metadata: String,
builder: String
): ByteArray { ): ByteArray {
val orderTypeHash = encodeType( val orderTypeHash = encodeType(
"Order", "Order",
@@ -225,57 +222,51 @@ object Eip712Encoder {
"salt" to "uint256", "salt" to "uint256",
"maker" to "address", "maker" to "address",
"signer" to "address", "signer" to "address",
"taker" to "address",
"tokenId" to "uint256", "tokenId" to "uint256",
"makerAmount" to "uint256", "makerAmount" to "uint256",
"takerAmount" to "uint256", "takerAmount" to "uint256",
"expiration" to "uint256",
"nonce" to "uint256",
"feeRateBps" to "uint256",
"side" to "uint8", "side" to "uint8",
"signatureType" to "uint8" "signatureType" to "uint8",
"timestamp" to "uint256",
"metadata" to "bytes32",
"builder" to "bytes32"
) )
) )
// 编码订单字段
val saltBytes = encodeUint256(BigInteger.valueOf(salt)) val saltBytes = encodeUint256(BigInteger.valueOf(salt))
val makerBytes = encodeAddress(maker) val makerBytes = encodeAddress(maker)
val signerBytes = encodeAddress(signer) val signerBytes = encodeAddress(signer)
val takerBytes = encodeAddress(taker)
val tokenIdBytes = encodeUint256(BigInteger(tokenId)) val tokenIdBytes = encodeUint256(BigInteger(tokenId))
val makerAmountBytes = encodeUint256(BigInteger(makerAmount)) val makerAmountBytes = encodeUint256(BigInteger(makerAmount))
val takerAmountBytes = encodeUint256(BigInteger(takerAmount)) val takerAmountBytes = encodeUint256(BigInteger(takerAmount))
val expirationBytes = encodeUint256(BigInteger(expiration))
val nonceBytes = encodeUint256(BigInteger(nonce))
val feeRateBpsBytes = encodeUint256(BigInteger(feeRateBps))
// side: BUY = 0, SELL = 1 (uint8,但需要编码为 32 字节)
val sideValue = when (side.uppercase()) { val sideValue = when (side.uppercase()) {
"BUY" -> 0 "BUY" -> 0
"SELL" -> 1 "SELL" -> 1
else -> throw IllegalArgumentException("side 必须是 BUY 或 SELL") else -> throw IllegalArgumentException("side 必须是 BUY 或 SELL")
} }
// uint8 类型,但 EIP-712 编码时仍需要 32 字节
val sideBytes = encodeUint256(BigInteger.valueOf(sideValue.toLong())) val sideBytes = encodeUint256(BigInteger.valueOf(sideValue.toLong()))
val signatureTypeBytes = encodeUint256(BigInteger.valueOf(signatureType.toLong())) val signatureTypeBytes = encodeUint256(BigInteger.valueOf(signatureType.toLong()))
// 组合所有字段 val timestampBytes = encodeUint256(BigInteger(timestamp))
val encoded = ByteArray(32 * 13) // 13 个字段,每个 32 字节 val metadataBytes = Numeric.hexStringToByteArray(metadata.removePrefix("0x").padStart(64, '0'))
val builderBytes = Numeric.hexStringToByteArray(builder.removePrefix("0x").padStart(64, '0'))
val encoded = ByteArray(32 * 12) // typeHash + 11 个字段
var offset = 0 var offset = 0
System.arraycopy(orderTypeHash, 0, encoded, offset, 32); offset += 32 System.arraycopy(orderTypeHash, 0, encoded, offset, 32); offset += 32
System.arraycopy(saltBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(saltBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(makerBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(makerBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(signerBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(signerBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(takerBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(tokenIdBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(tokenIdBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(makerAmountBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(makerAmountBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(takerAmountBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(takerAmountBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(expirationBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(nonceBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(feeRateBpsBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(sideBytes, 0, encoded, offset, 32); offset += 32 System.arraycopy(sideBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32) System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(timestampBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(metadataBytes, 0, encoded, offset, 32); offset += 32
System.arraycopy(builderBytes, 0, encoded, offset, 32)
return keccak256(encoded) return keccak256(encoded)
} }
@@ -10,7 +10,7 @@ import java.math.BigInteger
object EthereumUtils { object EthereumUtils {
// Polymarket 合约地址(Polygon 主网) // Polymarket 合约地址(Polygon 主网)
private val COLLATERAL_TOKEN_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" // USDC private val COLLATERAL_TOKEN_ADDRESS = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // pUSD
private val CONDITIONAL_TOKENS_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" // ConditionalTokens private val CONDITIONAL_TOKENS_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" // ConditionalTokens
/** /**
+41 -5
View File
@@ -10,6 +10,7 @@ RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
COMPOSE_FILES=(-f docker-compose.yml)
# 打印信息 # 打印信息
info() { info() {
@@ -24,6 +25,40 @@ error() {
echo -e "${RED}[ERROR]${NC} $1" echo -e "${RED}[ERROR]${NC} $1"
} }
# 从环境变量或 .env 文件读取配置值,避免 source .env 时被特殊字符影响
get_config_value() {
local key="$1"
local value="${!key}"
if [ -n "$value" ]; then
echo "$value"
return
fi
if [ -f ".env" ]; then
grep "^${key}=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r' || true
fi
}
# 根据数据库配置选择 compose 文件
configure_compose_files() {
local db_url
db_url=$(get_config_value "DB_URL")
COMPOSE_FILES=(-f docker-compose.yml)
if [[ "$db_url" == *"host.docker.internal"* ]]; then
COMPOSE_FILES=(-f docker-compose.host-mysql.yml)
info "检测到 DB_URL 指向宿主机 MySQL,将使用 docker-compose.host-mysql.yml"
else
info "使用默认 docker-compose.yml(包含内置 MySQL 服务)"
fi
}
compose() {
docker-compose "${COMPOSE_FILES[@]}" "$@"
}
# 检查 Docker 环境 # 检查 Docker 环境
check_docker() { check_docker() {
if ! command -v docker &> /dev/null; then if ! command -v docker &> /dev/null; then
@@ -148,6 +183,7 @@ check_security_config() {
deploy() { deploy() {
# 检查安全配置 # 检查安全配置
check_security_config check_security_config
configure_compose_files
# 检查是否使用 Docker Hub 镜像 # 检查是否使用 Docker Hub 镜像
USE_DOCKER_HUB="${USE_DOCKER_HUB:-false}" USE_DOCKER_HUB="${USE_DOCKER_HUB:-false}"
@@ -182,20 +218,20 @@ deploy() {
export GIT_TAG=${DOCKER_VERSION} export GIT_TAG=${DOCKER_VERSION}
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
docker-compose build compose build
fi fi
info "启动服务..." info "启动服务..."
docker-compose up -d compose up -d
info "等待服务启动..." info "等待服务启动..."
sleep 5 sleep 5
info "检查服务状态..." info "检查服务状态..."
docker-compose ps compose ps
info "查看日志: docker-compose logs -f" info "查看日志: docker-compose ${COMPOSE_FILES[*]} logs -f"
info "停止服务: docker-compose down" info "停止服务: docker-compose ${COMPOSE_FILES[*]} down"
} }
# 主函数 # 主函数
+50
View File
@@ -0,0 +1,50 @@
version: '3.8'
# 使用宿主机 MySQL 的部署配置。
# 适用于 DB_URL 指向 host.docker.internal 的场景,不会启动内置 MySQL 容器。
services:
app:
# 使用 Docker Hub 镜像(推荐生产环境)
# 取消注释下面一行,并注释掉 build 部分,即可使用 Docker Hub 的镜像
# image: wrbug/polyhermes:latest
build:
context: .
dockerfile: Dockerfile
# 本地构建时可以传递版本号参数(自动使用当前分支名)
args:
VERSION: ${VERSION:-dev}
GIT_TAG: ${GIT_TAG:-${VERSION:-dev}}
GITHUB_REPO_URL: ${GITHUB_REPO_URL:-https://github.com/WrBug/PolyHermes}
container_name: polyhermes
ports:
- "${SERVER_PORT:-80}:80"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- TZ=${TZ:-Asia/Shanghai}
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
- DB_URL=${DB_URL:?DB_URL is required when using host MySQL}
- DB_USERNAME=${DB_USERNAME:-root}
- DB_PASSWORD=${DB_PASSWORD:-}
- SERVER_PORT=8000
# ⚠️ 安全警告:以下两个环境变量不能使用默认值,否则容器启动会失败
# 请在 .env 文件中设置,或通过环境变量传入
# 生成随机密钥:openssl rand -hex 32 (ADMIN_RESET_PASSWORD_KEY) 或 openssl rand -hex 64 (JWT_SECRET)
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
# 日志级别配置(可选,默认值:root=WARN, app=INFO
# 可选值:TRACE, DEBUG, INFO, WARN, ERROR, OFF
- LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT:-WARN}
- LOG_LEVEL_APP=${LOG_LEVEL_APP:-INFO}
# 动态更新配置
- ALLOW_PRERELEASE=${ALLOW_PRERELEASE:-false}
- GITHUB_REPO=${GITHUB_REPO:-WrBug/PolyHermes}
volumes:
- /etc/localtime:/etc/localtime:ro
restart: unless-stopped
networks:
- polyhermes-network
networks:
polyhermes-network:
driver: bridge
+10
View File
@@ -41,6 +41,7 @@ import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types' import type { OrderPushMessage } from './types'
import { apiService } from './services/api' import { apiService } from './services/api'
import { hasToken } from './utils' import { hasToken } from './utils'
import ClobMigrationModal, { STORAGE_KEY as CLOB_MIGRATION_KEY } from './components/ClobMigrationModal'
/** /**
* *
@@ -64,6 +65,7 @@ function App() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const [isFirstUse, setIsFirstUse] = useState<boolean | null>(null) const [isFirstUse, setIsFirstUse] = useState<boolean | null>(null)
const [checking, setChecking] = useState(true) const [checking, setChecking] = useState(true)
const [clobMigrationVisible, setClobMigrationVisible] = useState(false)
// 根据当前语言设置 Ant Design 的 locale // 根据当前语言设置 Ant Design 的 locale
const getAntdLocale = () => { const getAntdLocale = () => {
@@ -199,6 +201,13 @@ function App() {
wsManager.disconnect() wsManager.disconnect()
} }
}, [checking, isFirstUse]) }, [checking, isFirstUse])
// 已登录且未查看过 CLOB V2 迁移提醒时显示弹窗
useEffect(() => {
if (!checking && isFirstUse === false && hasToken() && !localStorage.getItem(CLOB_MIGRATION_KEY)) {
setClobMigrationVisible(true)
}
}, [checking, isFirstUse])
// 订阅订单推送并显示全局通知 // 订阅订单推送并显示全局通知
useEffect(() => { useEffect(() => {
@@ -284,6 +293,7 @@ function App() {
{/* 默认重定向到登录页 */} {/* 默认重定向到登录页 */}
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes> </Routes>
<ClobMigrationModal open={clobMigrationVisible} onClose={() => setClobMigrationVisible(false)} />
</BrowserRouter> </BrowserRouter>
</ConfigProvider> </ConfigProvider>
) )
@@ -525,7 +525,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
</Space> </Space>
{!option.error && ( {!option.error && (
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ant-color-primary)' }}> <span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ant-color-primary)' }}>
{formatUSDC(option.totalBalance)} USDC ${formatUSDC(option.totalBalance)}
</span> </span>
)} )}
</div> </div>
@@ -247,7 +247,7 @@ const AccountSetupStatusBlock: React.FC<AccountSetupStatusBlockProps> = ({
const displayText = isUnlimited const displayText = isUnlimited
? t('accountSetup.approvalDetails.unlimited') ? t('accountSetup.approvalDetails.unlimited')
: isApproved : isApproved
? `${parseFloat(allowance).toFixed(2)} USDC` ? `$${parseFloat(allowance).toFixed(2)}`
: t('accountSetup.approvalDetails.notApproved') : t('accountSetup.approvalDetails.notApproved')
return ( return (
<div <div
@@ -0,0 +1,64 @@
import { Modal, Button, Space } from 'antd'
import { SwapOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
const STORAGE_KEY = 'clob_v2_migration_dismissed'
interface ClobMigrationModalProps {
open: boolean
onClose: () => void
}
const ClobMigrationModal: React.FC<ClobMigrationModalProps> = ({ open, onClose }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const handleClose = (dontRemind = false) => {
if (dontRemind) {
localStorage.setItem(STORAGE_KEY, 'true')
}
onClose()
}
const handleGoToAccounts = () => {
localStorage.setItem(STORAGE_KEY, 'true')
onClose()
navigate('/accounts')
}
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SwapOutlined style={{ color: '#fa8c16' }} />
<span>{t('clobMigration.title')}</span>
</div>
}
open={open}
onCancel={() => handleClose()}
footer={
<Space>
<Button onClick={() => handleClose(true)}>
{t('clobMigration.dontRemind')}
</Button>
<Button onClick={() => handleClose()}>
{t('common.later')}
</Button>
<Button type="primary" onClick={handleGoToAccounts}>
{t('clobMigration.goToAccounts')}
</Button>
</Space>
}
closable
maskClosable={false}
>
<p style={{ fontSize: 14, lineHeight: 1.8, color: 'rgba(0, 0, 0, 0.65)' }}>
{t('clobMigration.description')}
</p>
</Modal>
)
}
export default ClobMigrationModal
export { STORAGE_KEY }
@@ -0,0 +1,139 @@
import { useMemo } from 'react'
import { Button, Popover, Space } from 'antd'
import { GlobalOutlined, LinkOutlined, LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useGeoblockCheck } from '../hooks/useGeoblockCheck'
const GEOBLOCK_DOCS_URL = 'https://docs.polymarket.com/api-reference/geoblock'
type StatusTone = 'loading' | 'ok' | 'blocked' | 'warn'
const TONE_COLOR: Record<StatusTone, string> = {
loading: 'rgba(255, 255, 255, 0.45)',
ok: '#52c41a',
blocked: '#ff7875',
warn: '#faad14'
}
function formatLocation(country: string, region: string): string {
if (country && region) {
return `${country}/${region}`
}
return country || region || '—'
}
interface GeoblockStatusTriggerProps {
iconSize?: number
dotSize?: number
}
const GeoblockStatusTrigger: React.FC<GeoblockStatusTriggerProps> = ({ iconSize = 18, dotSize = 12 }) => {
const { t } = useTranslation()
const { status, data, refresh, loading } = useGeoblockCheck(true)
const locationText = useMemo(() => {
if (!data) return '—'
return formatLocation(data.country, data.region)
}, [data])
let tone: StatusTone = 'loading'
let label = t('geoblock.checkingShort')
if (status === 'loading' || status === 'idle') {
tone = 'loading'
label = t('geoblock.checkingShort')
} else if (status === 'error') {
tone = 'warn'
label = t('geoblock.unknown.short')
} else if (data?.blocked) {
tone = 'blocked'
label = t('geoblock.menu.blocked', { location: locationText })
} else if (status === 'success' && data) {
tone = 'ok'
label = t('geoblock.menu.ok', { location: locationText })
}
const accent = TONE_COLOR[tone]
const popoverContent = (
<div style={{ maxWidth: 240 }}>
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('geoblock.title')}</div>
<div style={{ fontSize: 13, marginBottom: 8, lineHeight: 1.5 }}>{label}</div>
{data && (
<div style={{ fontSize: 12, color: 'rgba(0, 0, 0, 0.45)', marginBottom: 10 }}>
<div>IP: {data.ip || '—'}</div>
<div>{t('geoblock.location')}: {locationText}</div>
</div>
)}
<Space size={8}>
<Button
type="link"
size="small"
icon={loading ? <LoadingOutlined /> : <ReloadOutlined />}
onClick={() => refresh()}
loading={loading}
style={{ padding: 0, height: 'auto' }}
>
{t('geoblock.refresh')}
</Button>
<Button
type="link"
size="small"
icon={<LinkOutlined />}
href={GEOBLOCK_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
style={{ padding: 0, height: 'auto' }}
>
{t('geoblock.viewDocsShort')}
</Button>
</Space>
</div>
)
return (
<Popover content={popoverContent} trigger="click" placement="bottom">
<button
type="button"
title={label}
aria-label={t('geoblock.title')}
style={{
position: 'relative',
color: '#fff',
fontSize: iconSize,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
lineHeight: 1
}}
>
{tone === 'loading' ? (
<LoadingOutlined style={{ fontSize: iconSize, color: accent }} />
) : (
<GlobalOutlined style={{ fontSize: iconSize }} />
)}
{tone !== 'loading' && (
<span
style={{
position: 'absolute',
right: -Math.round(dotSize / 3),
bottom: -Math.round(dotSize / 3),
width: dotSize,
height: dotSize,
borderRadius: '50%',
background: accent,
border: '2px solid #001529',
boxShadow: tone === 'ok' ? `0 0 5px ${accent}` : undefined
}}
/>
)}
</button>
</Popover>
)
}
export default GeoblockStatusTrigger
+8 -2
View File
@@ -31,6 +31,7 @@ import { removeToken, getVersionText, getVersionInfo } from '../utils'
import { wsManager } from '../services/websocket' import { wsManager } from '../services/websocket'
import { apiClient } from '../services/api' import { apiClient } from '../services/api'
import Logo from './Logo' import Logo from './Logo'
import GeoblockStatusTrigger from './GeoblockStatusTrigger'
const { Header, Content, Sider } = AntLayout const { Header, Content, Sider } = AntLayout
@@ -346,6 +347,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
> >
<SendOutlined /> <SendOutlined />
</a> </a>
<GeoblockStatusTrigger iconSize={16} dotSize={10} />
<Button <Button
type="text" type="text"
icon={<MenuOutlined />} icon={<MenuOutlined />}
@@ -393,7 +395,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
position: 'fixed', position: 'fixed',
left: 0, left: 0,
top: 0, top: 0,
overflow: 'hidden' overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}} }}
> >
<div style={{ <div style={{
@@ -473,6 +477,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
> >
<SendOutlined /> <SendOutlined />
</a> </a>
<GeoblockStatusTrigger iconSize={18} dotSize={12} />
</div> </div>
</div> </div>
<Menu <Menu
@@ -483,7 +488,8 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
items={menuItems} items={menuItems}
onClick={handleMenuClick} onClick={handleMenuClick}
style={{ style={{
height: 'calc(100vh - 100px)', flex: 1,
minHeight: 0,
borderRight: 0, borderRight: 0,
overflowY: 'auto' overflowY: 'auto'
}} }}
@@ -0,0 +1,181 @@
import { Alert, Tag, Typography } from 'antd'
import { CloseCircleOutlined, WarningOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
const { Text } = Typography
export interface ProxyCheckGeoblockResult {
checked: boolean
blocked?: boolean | null
ip?: string | null
country?: string | null
region?: string | null
message?: string | null
}
export interface ProxyCheckResponse {
success: boolean
message: string
responseTime?: number
latency?: number
geoblock?: ProxyCheckGeoblockResult | null
}
interface ProxyCheckResultAlertProps {
result: ProxyCheckResponse
style?: React.CSSProperties
}
interface ResultRowProps {
label: string
children: React.ReactNode
fullWidth?: boolean
isMobile: boolean
}
function formatLocation(country?: string | null, region?: string | null): string {
if (country && region) {
return `${country} / ${region}`
}
return country || region || '—'
}
function stripGeoblockSuffix(message: string, geoblockMessage?: string | null): string {
if (!geoblockMessage) {
return message
}
const suffix = `${geoblockMessage}`
if (message.endsWith(suffix)) {
return message.slice(0, -suffix.length)
}
const semi = message.indexOf('')
if (semi > 0) {
return message.slice(0, semi)
}
return message
}
const ResultRow: React.FC<ResultRowProps> = ({ label, children, fullWidth, isMobile }) => (
<div
style={{
gridColumn: fullWidth && !isMobile ? '1 / -1' : undefined,
display: 'flex',
alignItems: 'baseline',
gap: 12,
minWidth: 0,
lineHeight: 1.5
}}
>
<Text type="secondary" style={{ flexShrink: 0, width: isMobile ? 96 : 108, fontSize: 13 }}>
{label}
</Text>
<div style={{ flex: 1, minWidth: 0, fontSize: 13 }}>{children}</div>
</div>
)
const ProxyCheckResultAlert: React.FC<ProxyCheckResultAlertProps> = ({ result, style }) => {
const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const geoblock = result.geoblock
const geoblockChecked = Boolean(geoblock?.checked)
const geoblockBlocked = geoblockChecked && geoblock?.blocked === true
const geoblockUnknown = geoblockChecked && geoblock?.blocked == null
const alertType = !result.success ? 'error' : geoblockBlocked ? 'warning' : 'success'
const alertMessage = !result.success
? t('proxySettings.checkFailed')
: geoblockBlocked
? t('proxySettings.checkSuccessWithGeoblockWarning')
: t('proxySettings.checkSuccess')
const latencyMs = result.latency ?? result.responseTime
const connectionSummary = stripGeoblockSuffix(result.message, geoblock?.message)
const locationText = formatLocation(geoblock?.country, geoblock?.region)
const renderGeoblockValue = () => {
if (!geoblockChecked) {
return null
}
if (geoblockUnknown) {
return (
<Tag icon={<WarningOutlined />} color="warning">
{t('proxySettings.checkResult.geoblockUnknown')}
</Tag>
)
}
if (geoblockBlocked) {
return (
<Tag icon={<CloseCircleOutlined />} color="error">
{t('proxySettings.checkResult.geoblockBlocked')}
</Tag>
)
}
return <Text>{t('proxySettings.checkResult.geoblockOk')}</Text>
}
return (
<Alert
type={alertType}
message={alertMessage}
description={
<div
style={{
marginTop: 8,
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
gap: isMobile ? 10 : '10px 24px'
}}
>
<ResultRow label={t('proxySettings.checkResult.connection')} isMobile={isMobile}>
{result.success ? (
<Text>{t('proxySettings.checkResult.connected')}</Text>
) : (
<Tag icon={<CloseCircleOutlined />} color="error">
{t('proxySettings.checkResult.failed')}
</Tag>
)}
</ResultRow>
{latencyMs !== undefined && (
<ResultRow label={t('proxySettings.checkResult.latency')} isMobile={isMobile}>
<Text type={latencyMs >= 3000 ? 'warning' : undefined}>{latencyMs} ms</Text>
</ResultRow>
)}
{geoblockChecked && (
<>
<ResultRow label={t('proxySettings.geoblockTitle')} isMobile={isMobile}>
{renderGeoblockValue()}
</ResultRow>
<ResultRow label={t('geoblock.location')} isMobile={isMobile}>
<Text>{locationText}</Text>
</ResultRow>
{geoblock?.ip && (
<ResultRow label={t('geoblock.ip')} isMobile={isMobile} fullWidth>
<Text style={{ wordBreak: 'break-all' }}>{geoblock.ip}</Text>
</ResultRow>
)}
{geoblockUnknown && geoblock?.message && (
<ResultRow label={t('proxySettings.checkResult.detail')} isMobile={isMobile} fullWidth>
<Text type="secondary">{geoblock.message}</Text>
</ResultRow>
)}
</>
)}
{!result.success && connectionSummary && (
<ResultRow label={t('proxySettings.checkResult.detail')} isMobile={isMobile} fullWidth>
<Text type="secondary">{connectionSummary}</Text>
</ResultRow>
)}
</div>
}
style={style}
showIcon
/>
)
}
export default ProxyCheckResultAlert
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { apiService } from '../services/api'
export interface GeoblockCheckResult {
blocked: boolean
ip: string
country: string
region: string
checkedAt: number
source: string
}
export type GeoblockCheckStatus = 'idle' | 'loading' | 'success' | 'error'
const CACHE_KEY = 'geoblock_check_cache'
const CACHE_TTL_MS = 5 * 60 * 1000
interface GeoblockCacheEntry {
data: GeoblockCheckResult
cachedAt: number
}
function readCache(): GeoblockCheckResult | null {
try {
const raw = sessionStorage.getItem(CACHE_KEY)
if (!raw) return null
const entry = JSON.parse(raw) as GeoblockCacheEntry
if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
sessionStorage.removeItem(CACHE_KEY)
return null
}
return entry.data
} catch {
return null
}
}
function writeCache(data: GeoblockCheckResult): void {
const entry: GeoblockCacheEntry = { data, cachedAt: Date.now() }
sessionStorage.setItem(CACHE_KEY, JSON.stringify(entry))
}
export function useGeoblockCheck(autoFetch = true) {
const [status, setStatus] = useState<GeoblockCheckStatus>('idle')
const [data, setData] = useState<GeoblockCheckResult | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const fetchingRef = useRef(false)
const fetchGeoblock = useCallback(async (force = false) => {
if (fetchingRef.current) return
if (!force) {
const cached = readCache()
if (cached) {
setData(cached)
setStatus('success')
setErrorMessage(null)
return
}
}
fetchingRef.current = true
setStatus('loading')
setErrorMessage(null)
try {
const response = await apiService.proxyConfig.checkGeoblock()
if (response.data.code === 0 && response.data.data) {
const result: GeoblockCheckResult = {
blocked: response.data.data.blocked,
ip: response.data.data.ip,
country: response.data.data.country,
region: response.data.data.region,
checkedAt: response.data.data.checkedAt,
source: response.data.data.source ?? 'server'
}
writeCache(result)
setData(result)
setStatus('success')
} else {
setStatus('error')
setErrorMessage(response.data.msg ?? 'Geoblock check failed')
setData(null)
}
} catch (err: unknown) {
setStatus('error')
const message = err instanceof Error ? err.message : 'Geoblock check failed'
setErrorMessage(message)
setData(null)
} finally {
fetchingRef.current = false
}
}, [])
useEffect(() => {
if (autoFetch) {
fetchGeoblock()
}
}, [autoFetch, fetchGeoblock])
return {
status,
data,
errorMessage,
refresh: () => fetchGeoblock(true),
loading: status === 'loading'
}
}
+54 -1
View File
@@ -381,7 +381,21 @@
"saveSuccess": "Configuration saved successfully", "saveSuccess": "Configuration saved successfully",
"saveFailed": "Failed to save configuration", "saveFailed": "Failed to save configuration",
"getFailed": "Failed to get proxy configuration", "getFailed": "Failed to get proxy configuration",
"latency": "Latency" "latency": "Latency",
"checkResult": {
"connection": "Proxy connection",
"connected": "OK",
"failed": "Failed",
"latency": "Response time",
"detail": "Details",
"geoblockOk": "Can trade",
"geoblockBlocked": "Region restricted",
"geoblockUnknown": "Check failed"
},
"checkSuccessWithGeoblockWarning": "Proxy is OK, but trading region is restricted",
"geoblockTitle": "Geo check",
"geoblockAvailable": "Egress IP can trade ({{location}})",
"geoblockBlocked": "Egress IP is restricted and orders are blocked ({{location}})"
}, },
"configPage": { "configPage": {
"title": "Global Configuration", "title": "Global Configuration",
@@ -1286,6 +1300,36 @@
"webhookUrlPlaceholder": "Webhook URL (from Slack App settings)", "webhookUrlPlaceholder": "Webhook URL (from Slack App settings)",
"webhookUrlRequired": "Please enter Webhook URL" "webhookUrlRequired": "Please enter Webhook URL"
}, },
"geoblock": {
"title": "Trading Region Check",
"checking": "Checking geographic restrictions for the trading server egress IP…",
"checkingShort": "Checking server region…",
"ip": "Detected IP",
"location": "Region",
"refresh": "Refresh",
"viewDocs": "View official geo restrictions",
"viewDocsShort": "Docs",
"sourceServer": "Source: trading server",
"menu": {
"ok": "Region OK · {{location}}",
"blocked": "Restricted · {{location}}"
},
"available": {
"title": "Trading server can place orders on Polymarket",
"description": "IP: {{ip}}, region: {{location}}",
"short": "Server OK · {{location}} · {{ip}}"
},
"blocked": {
"title": "Trading server IP is in a restricted region",
"description": "Polymarket rejects orders from restricted regions. Configure a compliant network or proxy for your trading server before copy trading.",
"short": "Server IP restricted ({{location}} · {{ip}}) — orders blocked"
},
"unknown": {
"title": "Could not complete region check",
"description": "Check server network, proxy settings, or try again later. You can still read announcements, but verify your network before copy trading.",
"short": "Region check failed"
}
},
"announcements": { "announcements": {
"title": "Announcements", "title": "Announcements",
"noAnnouncements": "No announcements", "noAnnouncements": "No announcements",
@@ -1801,5 +1845,14 @@
"maxSizeUpdated": "Updated to max size", "maxSizeUpdated": "Updated to max size",
"periodChanged": "Period has changed, popup closed" "periodChanged": "Period has changed, popup closed"
} }
},
"clobMigration": {
"title": "CLOB 2.0 Migration Notice",
"description": "Polymarket CLOB has been upgraded to V2. Please go to the Accounts page to complete the USDC migration to ensure trading functions properly.",
"goToAccounts": "Go to Accounts",
"dontRemind": "Don't remind again",
"accountGuide": "CLOB V2 requires pUSD for trading. Click the USDC.e → pUSD button in the account actions to complete the migration.",
"accountGuideButton": "USDC.e → pUSD",
"dismissGuide": "Got it"
} }
} }
+53
View File
@@ -366,11 +366,25 @@
"passwordHelpUpdate": "留空则不更新密码,输入新密码则更新", "passwordHelpUpdate": "留空则不更新密码,输入新密码则更新",
"check": "检查代理", "check": "检查代理",
"checkSuccess": "代理检查成功", "checkSuccess": "代理检查成功",
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
"checkFailed": "代理检查失败", "checkFailed": "代理检查失败",
"geoblockTitle": "地域检测",
"geoblockAvailable": "出口 IP 可下单({{location}}",
"geoblockBlocked": "出口 IP 受限,无法下单({{location}}",
"saveSuccess": "保存配置成功", "saveSuccess": "保存配置成功",
"saveFailed": "保存配置失败", "saveFailed": "保存配置失败",
"getFailed": "获取代理配置失败", "getFailed": "获取代理配置失败",
"latency": "延迟", "latency": "延迟",
"checkResult": {
"connection": "代理连接",
"connected": "正常",
"failed": "失败",
"latency": "响应延迟",
"detail": "详情",
"geoblockOk": "可下单",
"geoblockBlocked": "地域受限",
"geoblockUnknown": "检测异常"
},
"hostInvalid": "请输入有效的主机地址", "hostInvalid": "请输入有效的主机地址",
"portInvalid": "端口必须在 1-65535 之间" "portInvalid": "端口必须在 1-65535 之间"
}, },
@@ -1286,6 +1300,36 @@
"webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)", "webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)",
"webhookUrlRequired": "请输入 Webhook URL" "webhookUrlRequired": "请输入 Webhook URL"
}, },
"geoblock": {
"title": "交易地域检查",
"checking": "正在检测交易服务器出口 IP 的地域限制…",
"checkingShort": "正在检测服务器地域…",
"ip": "检测 IP",
"location": "地区",
"refresh": "刷新",
"viewDocs": "查看官方地域限制说明",
"viewDocsShort": "说明",
"sourceServer": "检测对象:交易服务器",
"menu": {
"ok": "地域可用 · {{location}}",
"blocked": "地域受限 · {{location}}"
},
"available": {
"title": "当前服务器网络可向 Polymarket 提交订单",
"description": "检测 IP{{ip}},地区:{{location}}",
"short": "服务器可交易 · {{location}} · {{ip}}"
},
"blocked": {
"title": "当前服务器 IP 所在地区无法向 Polymarket 下单",
"description": "Polymarket 会拒绝来自受限地区的订单。请为交易服务器配置合规的网络或代理环境后再进行跟单。",
"short": "服务器 IP 受限({{location}} · {{ip}}),无法下单"
},
"unknown": {
"title": "无法完成地域检测",
"description": "请检查服务器网络、代理配置或稍后重试。检测失败不会阻止您查看公告,但跟单前请自行确认网络环境。",
"short": "地域检测失败"
}
},
"announcements": { "announcements": {
"title": "公告", "title": "公告",
"noAnnouncements": "暂无公告", "noAnnouncements": "暂无公告",
@@ -1801,5 +1845,14 @@
"maxSizeUpdated": "已更新为最大数量", "maxSizeUpdated": "已更新为最大数量",
"periodChanged": "周期已切换,弹窗已关闭" "periodChanged": "周期已切换,弹窗已关闭"
} }
},
"clobMigration": {
"title": "CLOB 2.0 迁移提醒",
"description": "Polymarket CLOB 已升级至 V2 版本,您需要前往账户页完成 USDC 迁移操作,以确保交易功能正常使用。",
"goToAccounts": "前往账户页",
"dontRemind": "不再提醒",
"accountGuide": "CLOB V2 需要 pUSD 进行交易,请点击账户操作栏中的 USDC.e → pUSD 按钮完成迁移。",
"accountGuideButton": "USDC.e → pUSD",
"dismissGuide": "知道了"
} }
} }
+54 -1
View File
@@ -381,7 +381,21 @@
"saveSuccess": "保存配置成功", "saveSuccess": "保存配置成功",
"saveFailed": "保存配置失敗", "saveFailed": "保存配置失敗",
"getFailed": "獲取代理配置失敗", "getFailed": "獲取代理配置失敗",
"latency": "延遲" "latency": "延遲",
"checkResult": {
"connection": "代理連線",
"connected": "正常",
"failed": "失敗",
"latency": "回應延遲",
"detail": "詳情",
"geoblockOk": "可下單",
"geoblockBlocked": "地域受限",
"geoblockUnknown": "檢測異常"
},
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
"geoblockTitle": "地域檢測",
"geoblockAvailable": "出口 IP 可下單({{location}}",
"geoblockBlocked": "出口 IP 受限,無法下單({{location}}"
}, },
"configPage": { "configPage": {
"title": "全局配置", "title": "全局配置",
@@ -1286,6 +1300,36 @@
"webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)", "webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)",
"webhookUrlRequired": "請輸入 Webhook URL" "webhookUrlRequired": "請輸入 Webhook URL"
}, },
"geoblock": {
"title": "交易地域檢查",
"checking": "正在檢測交易伺服器出口 IP 的地域限制…",
"checkingShort": "正在檢測伺服器地域…",
"ip": "檢測 IP",
"location": "地區",
"refresh": "刷新",
"viewDocs": "查看官方地域限制說明",
"viewDocsShort": "說明",
"sourceServer": "檢測對象:交易伺服器",
"menu": {
"ok": "地域可用 · {{location}}",
"blocked": "地域受限 · {{location}}"
},
"available": {
"title": "當前伺服器網路可向 Polymarket 提交訂單",
"description": "檢測 IP{{ip}},地區:{{location}}",
"short": "伺服器可交易 · {{location}} · {{ip}}"
},
"blocked": {
"title": "當前伺服器 IP 所在地區無法向 Polymarket 下單",
"description": "Polymarket 會拒絕來自受限地區的訂單。請為交易伺服器配置合規的網路或代理環境後再進行跟單。",
"short": "伺服器 IP 受限({{location}} · {{ip}}),無法下單"
},
"unknown": {
"title": "無法完成地域檢測",
"description": "請檢查伺服器網路、代理配置或稍後重試。檢測失敗不會阻止您查看公告,但跟單前請自行確認網路環境。",
"short": "地域檢測失敗"
}
},
"announcements": { "announcements": {
"title": "公告", "title": "公告",
"noAnnouncements": "暫無公告", "noAnnouncements": "暫無公告",
@@ -1801,5 +1845,14 @@
"maxSizeUpdated": "已更新為最大數量", "maxSizeUpdated": "已更新為最大數量",
"periodChanged": "週期已切換,彈窗已關閉" "periodChanged": "週期已切換,彈窗已關閉"
} }
},
"clobMigration": {
"title": "CLOB 2.0 遷移提醒",
"description": "Polymarket CLOB 已升級至 V2 版本,您需要前往帳戶頁完成 USDC 遷移操作,以確保交易功能正常使用。",
"goToAccounts": "前往帳戶頁",
"dontRemind": "不再提醒",
"accountGuide": "CLOB V2 需要 pUSD 進行交易,請點擊帳戶操作欄中的 USDC.e → pUSD 按鈕完成遷移。",
"accountGuideButton": "USDC.e → pUSD",
"dismissGuide": "知道了"
} }
} }
+2 -2
View File
@@ -202,7 +202,7 @@ const AccountDetail: React.FC = () => {
<Spin size="small" /> <Spin size="small" />
) : balance ? ( ) : balance ? (
<span style={{ fontWeight: 'bold', color: '#1890ff' }}> <span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{formatUSDC(balance)} USDC ${formatUSDC(balance)}
</span> </span>
) : ( ) : (
<span style={{ color: '#999' }}>-</span> <span style={{ color: '#999' }}>-</span>
@@ -274,7 +274,7 @@ const AccountDetail: React.FC = () => {
fontWeight: 'bold', fontWeight: 'bold',
color: account.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a' color: account.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}> }}>
{formatUSDC(account.totalPnl)} USDC ${formatUSDC(account.totalPnl)}
</span> </span>
</Descriptions.Item> </Descriptions.Item>
)} )}
+116 -7
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert, Tooltip, List, Empty } from 'antd' import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Spin, Modal, Descriptions, Divider, Form, Input, Alert, Tooltip, List, Empty } from 'antd'
import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined } from '@ant-design/icons' import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined, SwapOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useAccountStore } from '../store/accountStore' import { useAccountStore } from '../store/accountStore'
import type { Account } from '../types' import type { Account } from '../types'
@@ -8,6 +8,7 @@ import { useMediaQuery } from 'react-responsive'
import { formatUSDC } from '../utils' import { formatUSDC } from '../utils'
import AccountImportForm from '../components/AccountImportForm' import AccountImportForm from '../components/AccountImportForm'
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock' import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
import apiService from '../services/api'
const { Title } = Typography const { Title } = Typography
@@ -27,11 +28,57 @@ const AccountList: React.FC = () => {
const [editLoading, setEditLoading] = useState(false) const [editLoading, setEditLoading] = useState(false)
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false) const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
const [accountImportForm] = Form.useForm() const [accountImportForm] = Form.useForm()
const [wrapLoading, setWrapLoading] = useState<Record<number, boolean>>({})
const [migrationGuideVisible, setMigrationGuideVisible] = useState(false)
const ACCOUNT_GUIDE_KEY = 'clob_v2_account_guide_dismissed'
const handleWrapToPusd = async (account: Account) => {
try {
setWrapLoading(prev => ({ ...prev, [account.id]: true }))
const res = await apiService.accounts.getUsdceBalance(account.id)
if (res.data.code !== 0 || !res.data.data) {
message.error(res.data.msg || '查询 USDC.e 余额失败')
return
}
const balance = parseFloat(res.data.data.balance)
if (balance <= 0) {
message.info('USDC.e 余额为 0,无需迁移')
return
}
Modal.confirm({
title: 'USDC.e → pUSD 迁移',
content: `检测到 ${balance.toFixed(2)} USDC.e,将全部 wrap 为 pUSD。确认继续?`,
okText: '确认迁移',
cancelText: '取消',
onOk: async () => {
const wrapRes = await apiService.accounts.wrapToPusd(account.id)
if (wrapRes.data.code === 0) {
const txHash = wrapRes.data.data?.transactionHash
message.success(txHash ? `迁移成功,交易: ${txHash.slice(0, 10)}...` : '迁移成功(无需操作)')
fetchAccountBalance(account.id)
} else {
message.error(wrapRes.data.msg || '迁移失败')
}
}
})
} catch (e: any) {
message.error(e.message || '迁移失败')
} finally {
setWrapLoading(prev => ({ ...prev, [account.id]: false }))
}
}
useEffect(() => { useEffect(() => {
fetchAccounts() fetchAccounts()
}, [fetchAccounts]) }, [fetchAccounts])
// 首次进入且有账户时显示迁移引导
useEffect(() => {
if (!loading && accounts.length > 0 && !localStorage.getItem(ACCOUNT_GUIDE_KEY)) {
setMigrationGuideVisible(true)
}
}, [loading, accounts.length])
const handleAccountImportSuccess = async () => { const handleAccountImportSuccess = async () => {
message.success(t('accountImport.importSuccess')) message.success(t('accountImport.importSuccess'))
setAccountImportModalVisible(false) setAccountImportModalVisible(false)
@@ -325,7 +372,7 @@ const AccountList: React.FC = () => {
} }
const balanceObj = balanceMap[record.id] const balanceObj = balanceMap[record.id]
const balance = balanceObj?.total || record.balance || '-' const balance = balanceObj?.total || record.balance || '-'
return balance && balance !== '-' && typeof balance === 'string' ? `${formatUSDC(balance)} USDC` : '-' return balance && balance !== '-' && typeof balance === 'string' ? `$${formatUSDC(balance)}` : '-'
} }
}, },
{ {
@@ -374,6 +421,26 @@ const AccountList: React.FC = () => {
</div> </div>
</Tooltip> </Tooltip>
<Tooltip title="USDC.e → pUSD">
<div
onClick={() => handleWrapToPusd(record)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
cursor: wrapLoading[record.id] ? 'wait' : 'pointer',
borderRadius: '6px',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#fff7e6'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<SwapOutlined style={{ fontSize: '16px', color: '#fa8c16' }} spin={wrapLoading[record.id]} />
</div>
</Tooltip>
<Popconfirm <Popconfirm
title={t('accountList.deleteConfirm')} title={t('accountList.deleteConfirm')}
description={ description={
@@ -438,6 +505,38 @@ const AccountList: React.FC = () => {
</Tooltip> </Tooltip>
</div> </div>
{migrationGuideVisible && !loading && accounts.length > 0 && (
<Alert
message={t('clobMigration.accountGuide')}
type="warning"
showIcon
icon={<SwapOutlined />}
closable
onClose={() => {
localStorage.setItem(ACCOUNT_GUIDE_KEY, 'true')
setMigrationGuideVisible(false)
}}
afterClose={() => {
localStorage.setItem(ACCOUNT_GUIDE_KEY, 'true')
setMigrationGuideVisible(false)
}}
style={{ marginBottom: 16, ...(isMobile ? { margin: '0 8px 12px' } : {}) }}
action={
<Button
size="small"
type="primary"
danger
onClick={() => {
localStorage.setItem(ACCOUNT_GUIDE_KEY, 'true')
setMigrationGuideVisible(false)
}}
>
{t('clobMigration.dismissGuide')}
</Button>
}
/>
)}
<Card style={{ <Card style={{
margin: isMobile ? '0 -8px' : '0', margin: isMobile ? '0 -8px' : '0',
borderRadius: isMobile ? '0' : undefined borderRadius: isMobile ? '0' : undefined
@@ -504,7 +603,7 @@ const AccountList: React.FC = () => {
{t('accountList.totalBalance')} {t('accountList.totalBalance')}
</div> </div>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}> <div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
{balance?.total && balance.total !== '-' ? `${formatUSDC(balance.total)} USDC` : '- USDC'} {balance?.total && balance.total !== '-' ? `$${formatUSDC(balance.total)}` : '-'}
</div> </div>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
@@ -568,6 +667,16 @@ const AccountList: React.FC = () => {
</div> </div>
</Tooltip> </Tooltip>
<Tooltip title={t('clobMigration.accountGuideButton')}>
<div
onClick={() => handleWrapToPusd(account)}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', cursor: wrapLoading[account.id] ? 'wait' : 'pointer', padding: '4px 8px' }}
>
<SwapOutlined style={{ fontSize: '18px', color: '#fa8c16' }} spin={wrapLoading[account.id]} />
<span style={{ fontSize: '10px', color: '#8c8c8c', marginTop: '2px' }}>{t('clobMigration.accountGuideButton')}</span>
</div>
</Tooltip>
<Popconfirm <Popconfirm
title={t('accountList.deleteConfirm')} title={t('accountList.deleteConfirm')}
description={ description={
@@ -728,7 +837,7 @@ const AccountList: React.FC = () => {
<Spin size="small" /> <Spin size="small" />
) : detailBalance ? ( ) : detailBalance ? (
<span style={{ fontWeight: 'bold', color: '#1890ff', fontSize: '16px' }}> <span style={{ fontWeight: 'bold', color: '#1890ff', fontSize: '16px' }}>
{formatUSDC(detailBalance.total)} USDC ${formatUSDC(detailBalance.total)}
</span> </span>
) : ( ) : (
<span style={{ color: '#999' }}>-</span> <span style={{ color: '#999' }}>-</span>
@@ -739,7 +848,7 @@ const AccountList: React.FC = () => {
<Spin size="small" /> <Spin size="small" />
) : detailBalance ? ( ) : detailBalance ? (
<span style={{ color: '#52c41a' }}> <span style={{ color: '#52c41a' }}>
{formatUSDC(detailBalance.available)} USDC ${formatUSDC(detailBalance.available)}
</span> </span>
) : ( ) : (
<span style={{ color: '#999' }}>-</span> <span style={{ color: '#999' }}>-</span>
@@ -750,7 +859,7 @@ const AccountList: React.FC = () => {
<Spin size="small" /> <Spin size="small" />
) : detailBalance ? ( ) : detailBalance ? (
<span style={{ color: '#1890ff' }}> <span style={{ color: '#1890ff' }}>
{formatUSDC(detailBalance.position)} USDC ${formatUSDC(detailBalance.position)}
</span> </span>
) : ( ) : (
<span style={{ color: '#999' }}>-</span> <span style={{ color: '#999' }}>-</span>
@@ -806,7 +915,7 @@ const AccountList: React.FC = () => {
fontWeight: 'bold', fontWeight: 'bold',
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a' color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
}}> }}>
{formatUSDC(detailAccount.totalPnl)} USDC ${formatUSDC(detailAccount.totalPnl)}
</span> </span>
</Descriptions.Item> </Descriptions.Item>
)} )}
-1
View File
@@ -6,7 +6,6 @@ import { apiService } from '../services/api'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
const { Title, Text } = Typography const { Title, Text } = Typography
interface Reactions { interface Reactions {
+3 -3
View File
@@ -75,9 +75,9 @@ const BacktestChart: React.FC<BacktestChartProps> = ({ trades }) => {
return ` return `
<div> <div>
<div>${t('backtest.tradeTime')}: ${param.name}</div> <div>${t('backtest.tradeTime')}: ${param.name}</div>
<div>${t('backtest.balanceAfter')}: ${value} USDC</div> <div>${t('backtest.balanceAfter')}: $${value}</div>
<div style="color: ${color}"> <div style="color: ${color}">
${t('backtest.profitLoss')}: ${diff} USDC (${diffPercent}%) ${t('backtest.profitLoss')}: $${diff} (${diffPercent}%)
</div> </div>
</div> </div>
` `
@@ -121,7 +121,7 @@ const BacktestChart: React.FC<BacktestChartProps> = ({ trades }) => {
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
name: 'USDC', name: '$',
nameLocation: 'end', nameLocation: 'end',
nameGap: 10, nameGap: 10,
axisLabel: { axisLabel: {
+5 -5
View File
@@ -279,14 +279,14 @@ const BacktestDetail: React.FC = () => {
render: (value: string) => parseFloat(value).toFixed(4) render: (value: string) => parseFloat(value).toFixed(4)
}, },
{ {
title: t('backtest.amount') + ' (USDC)', title: t('backtest.amount') + ' ($)',
dataIndex: 'amount', dataIndex: 'amount',
key: 'amount', key: 'amount',
width: 120, width: 120,
render: (value: string) => formatUSDC(value) render: (value: string) => formatUSDC(value)
}, },
{ {
title: t('backtest.balanceAfter') + ' (USDC)', title: t('backtest.balanceAfter') + ' ($)',
dataIndex: 'balanceAfter', dataIndex: 'balanceAfter',
key: 'balanceAfter', key: 'balanceAfter',
width: 120, width: 120,
@@ -346,14 +346,14 @@ const BacktestDetail: React.FC = () => {
{task.leaderName || task.leaderAddress} {task.leaderName || task.leaderAddress}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.initialBalance')}> <Descriptions.Item label={t('backtest.initialBalance')}>
{formatUSDC(task.initialBalance)} USDC ${formatUSDC(task.initialBalance)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.finalBalance')}> <Descriptions.Item label={t('backtest.finalBalance')}>
{task.finalBalance ? formatUSDC(task.finalBalance) + ' USDC' : '-'} {task.finalBalance ? '$' + formatUSDC(task.finalBalance) : '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.profitAmount')}> <Descriptions.Item label={t('backtest.profitAmount')}>
<span style={{ color: task.profitAmount && parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}> <span style={{ color: task.profitAmount && parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>
{task.profitAmount ? formatUSDC(task.profitAmount) + ' USDC' : '-'} {task.profitAmount ? '$' + formatUSDC(task.profitAmount) : '-'}
</span> </span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.profitRate')}> <Descriptions.Item label={t('backtest.profitRate')}>
+17 -17
View File
@@ -461,14 +461,14 @@ const BacktestList: React.FC = () => {
render: (value: string) => parseFloat(value).toFixed(4) render: (value: string) => parseFloat(value).toFixed(4)
}, },
{ {
title: t('backtest.amount') + ' (USDC)', title: t('backtest.amount') + ' ($)',
dataIndex: 'amount', dataIndex: 'amount',
key: 'amount', key: 'amount',
width: 120, width: 120,
render: (value: string) => formatUSDC(value) render: (value: string) => formatUSDC(value)
}, },
{ {
title: t('backtest.balanceAfter') + ' (USDC)', title: t('backtest.balanceAfter') + ' ($)',
dataIndex: 'balanceAfter', dataIndex: 'balanceAfter',
key: 'balanceAfter', key: 'balanceAfter',
width: 120, width: 120,
@@ -815,7 +815,7 @@ const BacktestList: React.FC = () => {
<div> <div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.profitAmount')}</div> <div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.profitAmount')}</div>
{task.profitAmount != null ? ( {task.profitAmount != null ? (
<div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>{formatUSDC(task.profitAmount)} USDC</div> <div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>${formatUSDC(task.profitAmount)}</div>
) : ( ) : (
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div> <div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
)} )}
@@ -997,7 +997,7 @@ const BacktestList: React.FC = () => {
<Row gutter={24}> <Row gutter={24}>
<Col xs={24} sm={24} md={12}> <Col xs={24} sm={24} md={12}>
<Form.Item <Form.Item
label={t('backtest.initialBalance') + ' (USDC)'} label={t('backtest.initialBalance') + ' ($)'}
name="initialBalance" name="initialBalance"
rules={[ rules={[
{ required: true, message: t('backtest.initialBalanceRequired') || '请输入初始资金' }, { required: true, message: t('backtest.initialBalanceRequired') || '请输入初始资金' },
@@ -1082,7 +1082,7 @@ const BacktestList: React.FC = () => {
{copyMode === 'FIXED' && ( {copyMode === 'FIXED' && (
<Form.Item <Form.Item
label={t('backtest.fixedAmount') + ' (USDC)'} label={t('backtest.fixedAmount') + ' ($)'}
name="fixedAmount" name="fixedAmount"
rules={[ rules={[
{ required: true, message: t('backtest.fixedAmountRequired') || '请输入固定金额' }, { required: true, message: t('backtest.fixedAmountRequired') || '请输入固定金额' },
@@ -1101,7 +1101,7 @@ const BacktestList: React.FC = () => {
<Row gutter={24}> <Row gutter={24}>
<Col xs={24} sm={24} md={12}> <Col xs={24} sm={24} md={12}>
<Form.Item <Form.Item
label={t('backtest.maxOrderSize') + ' (USDC)'} label={t('backtest.maxOrderSize') + ' ($)'}
name="maxOrderSize" name="maxOrderSize"
rules={[{ required: true }]} rules={[{ required: true }]}
> >
@@ -1110,7 +1110,7 @@ const BacktestList: React.FC = () => {
</Col> </Col>
<Col xs={24} sm={24} md={12}> <Col xs={24} sm={24} md={12}>
<Form.Item <Form.Item
label={t('backtest.minOrderSize') + ' (USDC)'} label={t('backtest.minOrderSize') + ' ($)'}
name="minOrderSize" name="minOrderSize"
rules={[{ required: true }]} rules={[{ required: true }]}
> >
@@ -1122,7 +1122,7 @@ const BacktestList: React.FC = () => {
<Row gutter={24}> <Row gutter={24}>
<Col xs={24} sm={24} md={12}> <Col xs={24} sm={24} md={12}>
<Form.Item <Form.Item
label={t('backtest.maxDailyLoss') + ' (USDC)'} label={t('backtest.maxDailyLoss') + ' ($)'}
name="maxDailyLoss" name="maxDailyLoss"
rules={[{ required: true }]} rules={[{ required: true }]}
> >
@@ -1141,7 +1141,7 @@ const BacktestList: React.FC = () => {
</Row> </Row>
<Form.Item <Form.Item
label={t('backtest.maxPositionValue') + ' (USDC)'} label={t('backtest.maxPositionValue') + ' ($)'}
name="maxPositionValue" name="maxPositionValue"
> >
<InputNumber <InputNumber
@@ -1316,14 +1316,14 @@ const BacktestList: React.FC = () => {
{detailTask.leaderName || `Leader ${detailTask.leaderId}`} {detailTask.leaderName || `Leader ${detailTask.leaderId}`}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.initialBalance')}> <Descriptions.Item label={t('backtest.initialBalance')}>
{formatUSDC(detailTask.initialBalance)} USDC ${formatUSDC(detailTask.initialBalance)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.finalBalance')}> <Descriptions.Item label={t('backtest.finalBalance')}>
{detailTask.finalBalance ? formatUSDC(detailTask.finalBalance) + ' USDC' : '-'} {detailTask.finalBalance ? '$' + formatUSDC(detailTask.finalBalance) : '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.profitAmount')}> <Descriptions.Item label={t('backtest.profitAmount')}>
<span style={{ color: detailTask.profitAmount && parseFloat(detailTask.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}> <span style={{ color: detailTask.profitAmount && parseFloat(detailTask.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>
{detailTask.profitAmount ? formatUSDC(detailTask.profitAmount) + ' USDC' : '-'} {detailTask.profitAmount ? '$' + formatUSDC(detailTask.profitAmount) : '-'}
</span> </span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.profitRate')}> <Descriptions.Item label={t('backtest.profitRate')}>
@@ -1439,17 +1439,17 @@ const BacktestList: React.FC = () => {
<Descriptions.Item label={t('backtest.copyMode')}> <Descriptions.Item label={t('backtest.copyMode')}>
{detailConfig.copyMode === 'RATIO' {detailConfig.copyMode === 'RATIO'
? `${t('backtest.copyModeRatio')} ${parseFloat(detailConfig.copyRatio) * 100}%` ? `${t('backtest.copyModeRatio')} ${parseFloat(detailConfig.copyRatio) * 100}%`
: `${t('backtest.copyModeFixed')} ${formatUSDC(detailConfig.fixedAmount)} USDC` : `${t('backtest.copyModeFixed')} $${formatUSDC(detailConfig.fixedAmount)}`
} }
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.maxOrderSize')}> <Descriptions.Item label={t('backtest.maxOrderSize')}>
{formatUSDC(detailConfig.maxOrderSize)} USDC {'$' + formatUSDC(detailConfig.maxOrderSize)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.minOrderSize')}> <Descriptions.Item label={t('backtest.minOrderSize')}>
{formatUSDC(detailConfig.minOrderSize)} USDC {'$' + formatUSDC(detailConfig.minOrderSize)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.maxDailyLoss')}> <Descriptions.Item label={t('backtest.maxDailyLoss')}>
{formatUSDC(detailConfig.maxDailyLoss)} USDC {'$' + formatUSDC(detailConfig.maxDailyLoss)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label={t('backtest.maxDailyOrders')}> <Descriptions.Item label={t('backtest.maxDailyOrders')}>
{detailConfig.maxDailyOrders} {detailConfig.maxDailyOrders}
@@ -1471,7 +1471,7 @@ const BacktestList: React.FC = () => {
)} )}
{detailConfig.maxPositionValue && ( {detailConfig.maxPositionValue && (
<Descriptions.Item label={t('backtest.maxPositionValue')}> <Descriptions.Item label={t('backtest.maxPositionValue')}>
{formatUSDC(detailConfig.maxPositionValue)} USDC {'$' + formatUSDC(detailConfig.maxPositionValue)}
</Descriptions.Item> </Descriptions.Item>
)} )}
{(detailConfig.minPrice || detailConfig.maxPrice) && ( {(detailConfig.minPrice || detailConfig.maxPrice) && (
+2 -2
View File
@@ -150,7 +150,7 @@ const CopyTradingBuyOrdersPage: React.FC = () => {
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString() const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
return ( return (
<span style={{ fontSize: isMobile ? 12 : 14 }}> <span style={{ fontSize: isMobile ? 12 : 14 }}>
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`} {isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`}
</span> </span>
) )
} }
@@ -305,7 +305,7 @@ const CopyTradingBuyOrdersPage: React.FC = () => {
: {formatUSDC(order.quantity)} | : {formatUSDC(order.price)} : {formatUSDC(order.quantity)} | : {formatUSDC(order.price)}
</div> </div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}> <div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
: {formatUSDC(amount)} USDC 金额: ${formatUSDC(amount)}
</div> </div>
</div> </div>
+3 -3
View File
@@ -279,7 +279,7 @@ const CopyTradingList: React.FC = () => {
fontSize: isMobile ? 12 : 14 fontSize: isMobile ? 12 : 14
}}> }}>
{getPnlIcon(stats.totalPnl)} {getPnlIcon(stats.totalPnl)}
{isMobile ? formatUSDC(stats.totalPnl) : `${formatUSDC(stats.totalPnl)} USDC`} {isMobile ? formatUSDC(stats.totalPnl) : `$${formatUSDC(stats.totalPnl)}`}
</div> </div>
{!isMobile && ( {!isMobile && (
<div style={{ <div style={{
@@ -520,7 +520,7 @@ const CopyTradingList: React.FC = () => {
<div style={{ fontSize: '12px', opacity: '0.9' }}> <div style={{ fontSize: '12px', opacity: '0.9' }}>
{record.copyMode === 'RATIO' {record.copyMode === 'RATIO'
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%` ? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC` : `${t('copyTradingList.fixedAmountMode') || '固定'} $${formatUSDC(record.fixedAmount || '0')}`
} }
</div> </div>
</div> </div>
@@ -549,7 +549,7 @@ const CopyTradingList: React.FC = () => {
gap: '4px' gap: '4px'
}}> }}>
{getPnlIcon(stats.totalPnl)} {getPnlIcon(stats.totalPnl)}
{formatUSDC(stats.totalPnl)} USDC ${formatUSDC(stats.totalPnl)}
</div> </div>
) : loadingStatistics.has(record.id) ? ( ) : loadingStatistics.has(record.id) ? (
<Spin size="small" /> <Spin size="small" />
@@ -130,7 +130,7 @@ const CopyTradingMatchedOrdersPage: React.FC = () => {
fontWeight: 500, fontWeight: 500,
fontSize: isMobile ? 12 : 14 fontSize: isMobile ? 12 : 14
}}> }}>
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} {isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`}
</span> </span>
) )
}, },
@@ -262,7 +262,7 @@ const CopyTradingMatchedOrdersPage: React.FC = () => {
fontWeight: 'bold', fontWeight: 'bold',
color: getPnlColor(order.realizedPnl) color: getPnlColor(order.realizedPnl)
}}> }}>
{formatUSDC(order.realizedPnl)} USDC ${formatUSDC(order.realizedPnl)}
</div> </div>
</div> </div>
@@ -518,7 +518,7 @@ const AddModal: React.FC<AddModalProps> = ({
value={parseFloat(leaderAssetInfo.total)} value={parseFloat(leaderAssetInfo.total)}
precision={4} precision={4}
valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }} valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }}
suffix="USDC" prefix="$"
formatter={(value) => formatUSDC(value?.toString() || '0')} formatter={(value) => formatUSDC(value?.toString() || '0')}
/> />
</Col> </Col>
@@ -528,7 +528,7 @@ const AddModal: React.FC<AddModalProps> = ({
value={parseFloat(leaderAssetInfo.available)} value={parseFloat(leaderAssetInfo.available)}
precision={4} precision={4}
valueStyle={{ color: '#1890ff', fontSize: '14px' }} valueStyle={{ color: '#1890ff', fontSize: '14px' }}
suffix="USDC" prefix="$"
formatter={(value) => formatUSDC(value?.toString() || '0')} formatter={(value) => formatUSDC(value?.toString() || '0')}
/> />
</Col> </Col>
@@ -538,7 +538,7 @@ const AddModal: React.FC<AddModalProps> = ({
value={parseFloat(leaderAssetInfo.position)} value={parseFloat(leaderAssetInfo.position)}
precision={4} precision={4}
valueStyle={{ color: '#722ed1', fontSize: '14px' }} valueStyle={{ color: '#722ed1', fontSize: '14px' }}
suffix="USDC" prefix="$"
formatter={(value) => formatUSDC(value?.toString() || '0')} formatter={(value) => formatUSDC(value?.toString() || '0')}
/> />
</Col> </Col>
@@ -606,7 +606,7 @@ const AddModal: React.FC<AddModalProps> = ({
{copyMode === 'FIXED' && ( {copyMode === 'FIXED' && (
<Form.Item <Form.Item
label={t('copyTradingAdd.fixedAmount') || '固定跟单金额 (USDC)'} label={t('copyTradingAdd.fixedAmount') || '固定跟单金额 ($)'}
name="fixedAmount" name="fixedAmount"
rules={[ rules={[
{ required: true, message: t('copyTradingAdd.fixedAmountRequired') || '请输入固定跟单金额' }, { required: true, message: t('copyTradingAdd.fixedAmountRequired') || '请输入固定跟单金额' },
@@ -645,7 +645,7 @@ const AddModal: React.FC<AddModalProps> = ({
{copyMode === 'RATIO' && ( {copyMode === 'RATIO' && (
<> <>
<Form.Item <Form.Item
label={t('copyTradingAdd.maxOrderSize') || '单笔订单最大金额 (USDC)'} label={t('copyTradingAdd.maxOrderSize') || '单笔订单最大金额 ($)'}
name="maxOrderSize" name="maxOrderSize"
tooltip={t('copyTradingAdd.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'} tooltip={t('copyTradingAdd.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
> >
@@ -665,7 +665,7 @@ const AddModal: React.FC<AddModalProps> = ({
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('copyTradingAdd.minOrderSize') || '单笔订单最小金额 (USDC)'} label={t('copyTradingAdd.minOrderSize') || '单笔订单最小金额 ($)'}
name="minOrderSize" name="minOrderSize"
tooltip={t('copyTradingAdd.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'} tooltip={t('copyTradingAdd.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'}
rules={[ rules={[
@@ -700,7 +700,7 @@ const AddModal: React.FC<AddModalProps> = ({
)} )}
<Form.Item <Form.Item
label={t('copyTradingAdd.maxDailyLoss') || '每日最大亏损限制 (USDC)'} label={t('copyTradingAdd.maxDailyLoss') || '每日最大亏损限制 ($)'}
name="maxDailyLoss" name="maxDailyLoss"
tooltip={t('copyTradingAdd.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'} tooltip={t('copyTradingAdd.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
> >
@@ -709,7 +709,7 @@ const AddModal: React.FC<AddModalProps> = ({
step={0.0001} step={0.0001}
precision={4} precision={4}
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={t('copyTradingAdd.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'} placeholder={t('copyTradingAdd.maxDailyLossPlaceholder') || '默认 10000 $(可选)'}
formatter={(value) => { formatter={(value) => {
if (!value && value !== 0) return '' if (!value && value !== 0) return ''
const num = parseFloat(value.toString()) const num = parseFloat(value.toString())
@@ -767,7 +767,7 @@ const AddModal: React.FC<AddModalProps> = ({
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('copyTradingAdd.minOrderDepth') || '最小订单深度 (USDC)'} label={t('copyTradingAdd.minOrderDepth') || '最小订单深度 ($)'}
name="minOrderDepth" name="minOrderDepth"
tooltip={t('copyTradingAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'} tooltip={t('copyTradingAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
> >
@@ -853,7 +853,7 @@ const AddModal: React.FC<AddModalProps> = ({
<Divider>{t('copyTradingAdd.positionLimitFilter') || '最大仓位限制'}</Divider> <Divider>{t('copyTradingAdd.positionLimitFilter') || '最大仓位限制'}</Divider>
<Form.Item <Form.Item
label={t('copyTradingAdd.maxPositionValue') || '最大仓位金额 (USDC)'} label={t('copyTradingAdd.maxPositionValue') || '最大仓位金额 ($)'}
name="maxPositionValue" name="maxPositionValue"
tooltip={t('copyTradingAdd.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'} tooltip={t('copyTradingAdd.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
> >
@@ -1082,7 +1082,7 @@ const AddModal: React.FC<AddModalProps> = ({
<span> <span>
{record.copyMode === 'RATIO' {record.copyMode === 'RATIO'
? `${t('copyTradingAdd.ratioMode') || '比例'} ${record.copyRatio}x` ? `${t('copyTradingAdd.ratioMode') || '比例'} ${record.copyRatio}x`
: `${t('copyTradingAdd.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC` : `${t('copyTradingAdd.fixedAmountMode') || '固定'} $${formatUSDC(record.fixedAmount || '0')}`
} }
</span> </span>
) )
@@ -261,7 +261,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString() const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
return ( return (
<span style={{ fontSize: isMobile ? 12 : 14 }}> <span style={{ fontSize: isMobile ? 12 : 14 }}>
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`} {isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`}
</span> </span>
) )
} }
@@ -384,7 +384,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
</div> </div>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}> <div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span> <span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span> <span>{t('copyTradingOrders.totalAmount') || '总金额'}: ${formatUSDC(group.stats.totalAmount)}</span>
<span> <span>
{t('copyTradingOrders.statusBreakdown') || '状态'}: {t('copyTradingOrders.statusBreakdown') || '状态'}:
{group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.allFullySold') || '全部卖出'} ${group.stats.fullyMatchedCount}`} {group.stats.fullyMatchedCount > 0 && ` ${t('copyTradingOrders.allFullySold') || '全部卖出'} ${group.stats.fullyMatchedCount}`}
@@ -460,7 +460,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
<div style={{ fontSize: '12px', color: '#666' }}> <div style={{ fontSize: '12px', color: '#666' }}>
<div>{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}</div> <div>{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}</div>
<div>{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC</div> <div>{t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}</div>
<div>{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}</div> <div>{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}</div>
<div style={{ color: '#999', marginTop: '4px' }}>{formattedDate}</div> <div style={{ color: '#999', marginTop: '4px' }}>{formattedDate}</div>
</div> </div>
@@ -557,7 +557,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)} {t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
</div> </div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}> <div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC {t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}
</div> </div>
</div> </div>
@@ -356,7 +356,7 @@ const EditModal: React.FC<EditModalProps> = ({
value={parseFloat(leaderAssetInfo.total)} value={parseFloat(leaderAssetInfo.total)}
precision={4} precision={4}
valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }} valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }}
suffix="USDC" prefix="$"
formatter={(value) => formatUSDC(value?.toString() || '0')} formatter={(value) => formatUSDC(value?.toString() || '0')}
/> />
</Col> </Col>
@@ -366,7 +366,7 @@ const EditModal: React.FC<EditModalProps> = ({
value={parseFloat(leaderAssetInfo.available)} value={parseFloat(leaderAssetInfo.available)}
precision={4} precision={4}
valueStyle={{ color: '#1890ff', fontSize: '14px' }} valueStyle={{ color: '#1890ff', fontSize: '14px' }}
suffix="USDC" prefix="$"
formatter={(value) => formatUSDC(value?.toString() || '0')} formatter={(value) => formatUSDC(value?.toString() || '0')}
/> />
</Col> </Col>
@@ -376,7 +376,7 @@ const EditModal: React.FC<EditModalProps> = ({
value={parseFloat(leaderAssetInfo.position)} value={parseFloat(leaderAssetInfo.position)}
precision={4} precision={4}
valueStyle={{ color: '#722ed1', fontSize: '14px' }} valueStyle={{ color: '#722ed1', fontSize: '14px' }}
suffix="USDC" prefix="$"
formatter={(value) => formatUSDC(value?.toString() || '0')} formatter={(value) => formatUSDC(value?.toString() || '0')}
/> />
</Col> </Col>
@@ -456,7 +456,7 @@ const EditModal: React.FC<EditModalProps> = ({
{copyMode === 'FIXED' && ( {copyMode === 'FIXED' && (
<Form.Item <Form.Item
label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 (USDC)'} label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 ($)'}
name="fixedAmount" name="fixedAmount"
rules={[ rules={[
{ required: true, message: t('copyTradingEdit.fixedAmountRequired') || '请输入固定跟单金额' }, { required: true, message: t('copyTradingEdit.fixedAmountRequired') || '请输入固定跟单金额' },
@@ -495,7 +495,7 @@ const EditModal: React.FC<EditModalProps> = ({
{copyMode === 'RATIO' && ( {copyMode === 'RATIO' && (
<> <>
<Form.Item <Form.Item
label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'} label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 ($)'}
name="maxOrderSize" name="maxOrderSize"
tooltip={t('copyTradingEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'} tooltip={t('copyTradingEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
> >
@@ -515,7 +515,7 @@ const EditModal: React.FC<EditModalProps> = ({
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 (USDC)'} label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 ($)'}
name="minOrderSize" name="minOrderSize"
tooltip={t('copyTradingEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'} tooltip={t('copyTradingEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'}
rules={[ rules={[
@@ -550,7 +550,7 @@ const EditModal: React.FC<EditModalProps> = ({
)} )}
<Form.Item <Form.Item
label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 (USDC)'} label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 ($)'}
name="maxDailyLoss" name="maxDailyLoss"
tooltip={t('copyTradingEdit.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'} tooltip={t('copyTradingEdit.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
> >
@@ -559,7 +559,7 @@ const EditModal: React.FC<EditModalProps> = ({
step={0.0001} step={0.0001}
precision={4} precision={4}
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'} placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 $(可选)'}
formatter={(value) => { formatter={(value) => {
if (!value && value !== 0) return '' if (!value && value !== 0) return ''
const num = parseFloat(value.toString()) const num = parseFloat(value.toString())
@@ -617,7 +617,7 @@ const EditModal: React.FC<EditModalProps> = ({
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'} label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 ($)'}
name="minOrderDepth" name="minOrderDepth"
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'} tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
> >
@@ -703,7 +703,7 @@ const EditModal: React.FC<EditModalProps> = ({
<Divider>{t('copyTradingEdit.positionLimitFilter') || '最大仓位限制'}</Divider> <Divider>{t('copyTradingEdit.positionLimitFilter') || '最大仓位限制'}</Divider>
<Form.Item <Form.Item
label={t('copyTradingEdit.maxPositionValue') || '最大仓位金额 (USDC)'} label={t('copyTradingEdit.maxPositionValue') || '最大仓位金额 ($)'}
name="maxPositionValue" name="maxPositionValue"
tooltip={t('copyTradingEdit.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'} tooltip={t('copyTradingEdit.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
> >
@@ -228,7 +228,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
fontWeight: 500, fontWeight: 500,
fontSize: isMobile ? 12 : 14 fontSize: isMobile ? 12 : 14
}}> }}>
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} {isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`}
</span> </span>
) )
}, },
@@ -408,7 +408,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
fontWeight: 'bold', fontWeight: 'bold',
color: getPnlColor(order.realizedPnl) color: getPnlColor(order.realizedPnl)
}}> }}>
{formatUSDC(order.realizedPnl)} USDC ${formatUSDC(order.realizedPnl)}
</div> </div>
</div> </div>
@@ -258,7 +258,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString() const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
return ( return (
<span style={{ fontSize: isMobile ? 12 : 14 }}> <span style={{ fontSize: isMobile ? 12 : 14 }}>
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`} {isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`}
</span> </span>
) )
} }
@@ -274,7 +274,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
fontWeight: 500, fontWeight: 500,
fontSize: isMobile ? 12 : 14 fontSize: isMobile ? 12 : 14
}}> }}>
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} {isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`}
</span> </span>
) )
}, },
@@ -362,10 +362,10 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
</div> </div>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}> <div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', fontSize: isMobile ? '12px' : '13px', color: '#666' }}>
<span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span> <span>{t('copyTradingOrders.orderCount') || '订单数'}: {group.stats.count}</span>
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span> <span>{t('copyTradingOrders.totalAmount') || '总金额'}: ${formatUSDC(group.stats.totalAmount)}</span>
{group.stats.totalPnl && ( {group.stats.totalPnl && (
<span style={{ color: pnlColor, fontWeight: 500 }}> <span style={{ color: pnlColor, fontWeight: 500 }}>
{t('copyTradingOrders.totalPnl') || '总盈亏'}: {formatUSDC(group.stats.totalPnl)} USDC {t('copyTradingOrders.totalPnl') || '总盈亏'}: ${formatUSDC(group.stats.totalPnl)}
</span> </span>
)} )}
</div> </div>
@@ -434,9 +434,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
<div style={{ fontSize: '12px', color: '#666' }}> <div style={{ fontSize: '12px', color: '#666' }}>
<div>{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}</div> <div>{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}</div>
<div>{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC</div> <div>{t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}</div>
<div style={{ color: getPnlColor(order.realizedPnl), fontWeight: 500 }}> <div style={{ color: getPnlColor(order.realizedPnl), fontWeight: 500 }}>
{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}: {formatUSDC(order.realizedPnl)} USDC {t('copyTradingOrders.realizedPnl') || '已实现盈亏'}: ${formatUSDC(order.realizedPnl)}
</div> </div>
<div style={{ color: '#999', marginTop: '4px' }}>{formattedDate}</div> <div style={{ color: '#999', marginTop: '4px' }}>{formattedDate}</div>
</div> </div>
@@ -530,7 +530,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)} {t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}
</div> </div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}> <div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC {t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}
</div> </div>
</div> </div>
@@ -541,7 +541,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
fontWeight: 'bold', fontWeight: 'bold',
color: getPnlColor(order.realizedPnl) color: getPnlColor(order.realizedPnl)
}}> }}>
{formatUSDC(order.realizedPnl)} USDC ${formatUSDC(order.realizedPnl)}
</div> </div>
</div> </div>
@@ -104,7 +104,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
</div> </div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}> <div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowUpOutlined style={{ color: '#1890ff', fontSize: '14px' }} /> <ArrowUpOutlined style={{ color: '#1890ff', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalBuyAmount)} USDC</span> <span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalBuyAmount)}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
@@ -113,7 +113,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
</div> </div>
<div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}> <div style={{ fontSize: '16px', fontWeight: '500', color: '#333', flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
<ArrowDownOutlined style={{ color: '#ff4d4f', fontSize: '14px' }} /> <ArrowDownOutlined style={{ color: '#ff4d4f', fontSize: '14px' }} />
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalSellAmount)} USDC</span> <span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalSellAmount)}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
@@ -122,7 +122,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
</div> </div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: getPnlColor(statistics.totalPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}> <div style={{ fontSize: '16px', fontWeight: 'bold', color: getPnlColor(statistics.totalPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalPnl)} {getPnlIcon(statistics.totalPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalPnl)} USDC</span> <span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalPnl)}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
@@ -131,7 +131,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
</div> </div>
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalRealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}> <div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalRealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalRealizedPnl)} {getPnlIcon(statistics.totalRealizedPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalRealizedPnl)} USDC</span> <span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalRealizedPnl)}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px', borderBottom: '1px solid #f0f0f0' }}>
@@ -140,7 +140,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
</div> </div>
<div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalUnrealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}> <div style={{ fontSize: '16px', fontWeight: '500', color: getPnlColor(statistics.totalUnrealizedPnl), flex: '1', textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '4px' }}>
{getPnlIcon(statistics.totalUnrealizedPnl)} {getPnlIcon(statistics.totalUnrealizedPnl)}
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalUnrealizedPnl)} USDC</span> <span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalUnrealizedPnl)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -165,43 +165,38 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
<Statistic <Statistic
title={t('copyTradingOrders.totalBuyAmount') || '总买入金额'} title={t('copyTradingOrders.totalBuyAmount') || '总买入金额'}
value={formatUSDC(statistics.totalBuyAmount)} value={formatUSDC(statistics.totalBuyAmount)}
suffix="USDC" prefix={<><ArrowUpOutlined style={{ color: '#1890ff' }} /> $</>}
prefix={<ArrowUpOutlined style={{ color: '#1890ff' }} />}
/> />
</Col> </Col>
<Col xs={24} sm={12} md={8}> <Col xs={24} sm={12} md={8}>
<Statistic <Statistic
title={t('copyTradingOrders.totalSellAmount') || '总卖出金额'} title={t('copyTradingOrders.totalSellAmount') || '总卖出金额'}
value={formatUSDC(statistics.totalSellAmount)} value={formatUSDC(statistics.totalSellAmount)}
suffix="USDC" prefix={<><ArrowDownOutlined style={{ color: '#ff4d4f' }} /> $</>}
prefix={<ArrowDownOutlined style={{ color: '#ff4d4f' }} />}
/> />
</Col> </Col>
<Col xs={24} sm={12} md={8}> <Col xs={24} sm={12} md={8}>
<Statistic <Statistic
title={t('copyTradingOrders.totalPnl') || '总盈亏'} title={t('copyTradingOrders.totalPnl') || '总盈亏'}
value={formatUSDC(statistics.totalPnl)} value={formatUSDC(statistics.totalPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalPnl) }} valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
prefix={getPnlIcon(statistics.totalPnl)} prefix={<>{getPnlIcon(statistics.totalPnl)} $</>}
/> />
</Col> </Col>
<Col xs={24} sm={12} md={8}> <Col xs={24} sm={12} md={8}>
<Statistic <Statistic
title={t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'} title={t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'}
value={formatUSDC(statistics.totalRealizedPnl)} value={formatUSDC(statistics.totalRealizedPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }} valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }}
prefix={getPnlIcon(statistics.totalRealizedPnl)} prefix={<>{getPnlIcon(statistics.totalRealizedPnl)} $</>}
/> />
</Col> </Col>
<Col xs={24} sm={12} md={8}> <Col xs={24} sm={12} md={8}>
<Statistic <Statistic
title={t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'} title={t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'}
value={formatUSDC(statistics.totalUnrealizedPnl)} value={formatUSDC(statistics.totalUnrealizedPnl)}
suffix="USDC"
valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }} valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }}
prefix={getPnlIcon(statistics.totalUnrealizedPnl)} prefix={<>{getPnlIcon(statistics.totalUnrealizedPnl)} $</>}
/> />
</Col> </Col>
</Row> </Row>
+4 -4
View File
@@ -145,7 +145,7 @@ const CopyTradingSellOrdersPage: React.FC = () => {
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString() const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
return ( return (
<span style={{ fontSize: isMobile ? 12 : 14 }}> <span style={{ fontSize: isMobile ? 12 : 14 }}>
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`} {isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`}
</span> </span>
) )
} }
@@ -161,7 +161,7 @@ const CopyTradingSellOrdersPage: React.FC = () => {
fontWeight: 500, fontWeight: 500,
fontSize: isMobile ? 12 : 14 fontSize: isMobile ? 12 : 14
}}> }}>
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`} {isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`}
</span> </span>
) )
}, },
@@ -277,7 +277,7 @@ const CopyTradingSellOrdersPage: React.FC = () => {
: {formatUSDC(order.quantity)} | : {formatUSDC(order.price)} : {formatUSDC(order.quantity)} | : {formatUSDC(order.price)}
</div> </div>
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}> <div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
: {formatUSDC(amount)} USDC 金额: ${formatUSDC(amount)}
</div> </div>
</div> </div>
@@ -289,7 +289,7 @@ const CopyTradingSellOrdersPage: React.FC = () => {
fontWeight: 'bold', fontWeight: 'bold',
color: getPnlColor(order.realizedPnl) color: getPnlColor(order.realizedPnl)
}}> }}>
{formatUSDC(order.realizedPnl)} USDC ${formatUSDC(order.realizedPnl)}
</div> </div>
</div> </div>
+5 -8
View File
@@ -145,7 +145,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
<Statistic <Statistic
title="总买入金额" title="总买入金额"
value={formatUSDC(statistics.totalBuyAmount)} value={formatUSDC(statistics.totalBuyAmount)}
suffix="USDC" prefix="$"
/> />
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
@@ -179,7 +179,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
<Statistic <Statistic
title="总卖出金额" title="总卖出金额"
value={formatUSDC(statistics.totalSellAmount)} value={formatUSDC(statistics.totalSellAmount)}
suffix="USDC" prefix="$"
/> />
</Col> </Col>
<Col xs={24} sm={12} md={8}> <Col xs={24} sm={12} md={8}>
@@ -220,8 +220,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
title="总已实现盈亏" title="总已实现盈亏"
value={formatUSDC(statistics.totalRealizedPnl)} value={formatUSDC(statistics.totalRealizedPnl)}
valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }} valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }}
prefix={getPnlIcon(statistics.totalRealizedPnl)} prefix={<>{getPnlIcon(statistics.totalRealizedPnl)} $</>}
suffix="USDC"
/> />
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
@@ -229,8 +228,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
title="总未实现盈亏" title="总未实现盈亏"
value={formatUSDC(statistics.totalUnrealizedPnl)} value={formatUSDC(statistics.totalUnrealizedPnl)}
valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }} valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }}
prefix={getPnlIcon(statistics.totalUnrealizedPnl)} prefix={<>{getPnlIcon(statistics.totalUnrealizedPnl)} $</>}
suffix="USDC"
/> />
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
@@ -238,8 +236,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
title="总盈亏" title="总盈亏"
value={formatUSDC(statistics.totalPnl)} value={formatUSDC(statistics.totalPnl)}
valueStyle={{ color: getPnlColor(statistics.totalPnl) }} valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
prefix={getPnlIcon(statistics.totalPnl)} prefix={<>{getPnlIcon(statistics.totalPnl)} $</>}
suffix="USDC"
/> />
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
+1 -1
View File
@@ -493,7 +493,7 @@ const CryptoTailMonitor: React.FC = () => {
} else { } else {
timeStr = '--' timeStr = '--'
} }
return `<span style="font-size:12px">${timeStr} &nbsp; ${Number(val).toFixed(2)} USDC</span>` return `<span style="font-size:12px">${timeStr} &nbsp; $${Number(val).toFixed(2)}</span>`
} }
}, },
legend: { legend: {
@@ -45,12 +45,12 @@ const CryptoTailPnlCurveModal: React.FC<CryptoTailPnlCurveModalProps> = (props)
if (!v) return '' if (!v) return ''
const d = data.curveData.find((p) => p.timestamp === v[0]) const d = data.curveData.find((p) => p.timestamp === v[0])
if (!d) return '' if (!d) return ''
return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '<br/>' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': ' + formatUSDC(d.cumulativePnl) + ' USDC' return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '<br/>' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': $' + formatUSDC(d.cumulativePnl)
} }
}, },
grid: { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true }, grid: { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true },
xAxis: { type: 'time' }, xAxis: { type: 'time' },
yAxis: { type: 'value', axisLabel: { formatter: (val: number) => String(val) + ' USDC' } }, yAxis: { type: 'value', axisLabel: { formatter: (val: number) => '$' + String(val) } },
series: [{ series: [{
name: t('cryptoTailStrategy.pnlCurve.totalPnl'), name: t('cryptoTailStrategy.pnlCurve.totalPnl'),
type: 'line', type: 'line',
@@ -108,7 +108,7 @@ const CryptoTailPnlCurveModal: React.FC<CryptoTailPnlCurveModalProps> = (props)
<Statistic <Statistic
title={t('cryptoTailStrategy.pnlCurve.totalPnl')} title={t('cryptoTailStrategy.pnlCurve.totalPnl')}
value={data?.totalRealizedPnl != null ? formatUSDC(data.totalRealizedPnl) : '-'} value={data?.totalRealizedPnl != null ? formatUSDC(data.totalRealizedPnl) : '-'}
suffix="USDC" prefix="$"
valueStyle={{ color: pnlColor(data?.totalRealizedPnl ?? null) }} valueStyle={{ color: pnlColor(data?.totalRealizedPnl ?? null) }}
/> />
</Col> </Col>
@@ -125,7 +125,7 @@ const CryptoTailPnlCurveModal: React.FC<CryptoTailPnlCurveModalProps> = (props)
<Statistic <Statistic
title={t('cryptoTailStrategy.pnlCurve.maxDrawdown')} title={t('cryptoTailStrategy.pnlCurve.maxDrawdown')}
value={data?.maxDrawdown != null ? '-' + formatUSDC(data.maxDrawdown) : '-'} value={data?.maxDrawdown != null ? '-' + formatUSDC(data.maxDrawdown) : '-'}
suffix="USDC" prefix="$"
valueStyle={{ color: data?.maxDrawdown ? '#ff4d4f' : undefined }} valueStyle={{ color: data?.maxDrawdown ? '#ff4d4f' : undefined }}
/> />
</Col> </Col>
@@ -773,7 +773,7 @@ const CryptoTailStrategyList: React.FC = () => {
<div> <div>
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('cryptoTailStrategy.list.totalRealizedPnl')}</div> <div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('cryptoTailStrategy.list.totalRealizedPnl')}</div>
{item.totalRealizedPnl != null ? ( {item.totalRealizedPnl != null ? (
<div style={{ fontSize: '14px', fontWeight: '600', color: pnlColor(item.totalRealizedPnl) }}>{formatUSDC(item.totalRealizedPnl)} USDC</div> <div style={{ fontSize: '14px', fontWeight: '600', color: pnlColor(item.totalRealizedPnl) }}>${formatUSDC(item.totalRealizedPnl)}</div>
) : ( ) : (
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div> <div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
)} )}
@@ -966,7 +966,7 @@ const CryptoTailStrategyList: React.FC = () => {
</Form.Item> </Form.Item>
) : ( ) : (
<Form.Item name="amountValue" label={t('cryptoTailStrategy.form.fixedUsdc')} rules={[{ required: true }]}> <Form.Item name="amountValue" label={t('cryptoTailStrategy.form.fixedUsdc')} rules={[{ required: true }]}>
<InputNumber min={1} style={{ width: '100%' }} addonAfter="USDC" stringMode /> <InputNumber min={1} style={{ width: '100%' }} addonBefore="$" stringMode />
</Form.Item> </Form.Item>
) )
} }
@@ -1111,7 +1111,7 @@ const CryptoTailStrategyList: React.FC = () => {
dataIndex: 'amountUsdc', dataIndex: 'amountUsdc',
key: 'amountUsdc', key: 'amountUsdc',
width: 110, width: 110,
render: (v: string) => `${formatUSDC(v)} USDC` render: (v: string) => `$${formatUSDC(v)}`
}, },
{ {
title: t('cryptoTailStrategy.triggerRecords.realizedPnl'), title: t('cryptoTailStrategy.triggerRecords.realizedPnl'),
@@ -1186,7 +1186,7 @@ const CryptoTailStrategyList: React.FC = () => {
dataIndex: 'amountUsdc', dataIndex: 'amountUsdc',
key: 'amountUsdc', key: 'amountUsdc',
width: 110, width: 110,
render: (v: string) => `${formatUSDC(v)} USDC` render: (v: string) => `$${formatUSDC(v)}`
}, },
{ {
title: t('cryptoTailStrategy.triggerRecords.failReason'), title: t('cryptoTailStrategy.triggerRecords.failReason'),
+5 -5
View File
@@ -254,7 +254,7 @@ const LeaderList: React.FC = () => {
return ( return (
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Text style={{ color: '#52c41a', fontSize: '14px', fontWeight: '500' }}> <Text style={{ color: '#52c41a', fontSize: '14px', fontWeight: '500' }}>
{balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`} {balance.available === '-' ? '-' : `$${formatUSDC(balance.available)}`}
</Text> </Text>
<Text type="secondary" style={{ fontSize: '12px' }}> <Text type="secondary" style={{ fontSize: '12px' }}>
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)} {t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
@@ -488,7 +488,7 @@ const LeaderList: React.FC = () => {
{t('leaderDetail.availableBalance')} {t('leaderDetail.availableBalance')}
</div> </div>
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}> <div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
{balance?.available && balance.available !== '-' ? `${formatUSDC(balance.available)} USDC` : '- USDC'} {balance?.available && balance.available !== '-' ? `$${formatUSDC(balance.available)}` : '-'}
</div> </div>
</div> </div>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
@@ -700,7 +700,7 @@ const LeaderList: React.FC = () => {
value={parseFloat(detailBalance.availableBalance)} value={parseFloat(detailBalance.availableBalance)}
precision={4} precision={4}
valueStyle={{ color: '#1890ff' }} valueStyle={{ color: '#1890ff' }}
suffix="USDC" prefix="$"
formatter={(value) => formatUSDC(value?.toString() || '0')} formatter={(value) => formatUSDC(value?.toString() || '0')}
/> />
</Card> </Card>
@@ -712,7 +712,7 @@ const LeaderList: React.FC = () => {
value={parseFloat(detailBalance.positionBalance)} value={parseFloat(detailBalance.positionBalance)}
precision={4} precision={4}
valueStyle={{ color: '#722ed1' }} valueStyle={{ color: '#722ed1' }}
suffix="USDC" prefix="$"
formatter={(value) => formatUSDC(value?.toString() || '0')} formatter={(value) => formatUSDC(value?.toString() || '0')}
/> />
</Card> </Card>
@@ -724,7 +724,7 @@ const LeaderList: React.FC = () => {
value={parseFloat(detailBalance.totalBalance)} value={parseFloat(detailBalance.totalBalance)}
precision={4} precision={4}
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }} valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
suffix="USDC" prefix="$"
formatter={(value) => formatUSDC(value?.toString() || '0')} formatter={(value) => formatUSDC(value?.toString() || '0')}
/> />
</Card> </Card>
+1 -1
View File
@@ -118,7 +118,7 @@ const OrderList: React.FC = () => {
key: 'pnl', key: 'pnl',
render: (pnl: string | undefined) => pnl ? ( render: (pnl: string | undefined) => pnl ? (
<span style={{ color: pnl.startsWith('-') ? 'red' : 'green' }}> <span style={{ color: pnl.startsWith('-') ? 'red' : 'green' }}>
{formatUSDC(pnl)} USDC ${formatUSDC(pnl)}
</span> </span>
) : '-' ) : '-'
}, },
+15 -15
View File
@@ -695,7 +695,7 @@ const PositionList: React.FC = () => {
fontWeight: '500', fontWeight: '500',
color: isProfit ? '#52c41a' : '#f5222d' color: isProfit ? '#52c41a' : '#f5222d'
}}> }}>
{pnlNum >= 0 ? '+' : ''}{formatUSDC(position.pnl)} USDC {pnlNum >= 0 ? '+' : ''}${formatUSDC(position.pnl)}
</span> </span>
</div> </div>
)} )}
@@ -718,7 +718,7 @@ const PositionList: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontSize: '13px', color: '#666' }}></span> <span style={{ fontSize: '13px', color: '#666' }}></span>
<span style={{ fontSize: '13px', fontWeight: '500' }}> <span style={{ fontSize: '13px', fontWeight: '500' }}>
{formatUSDC(position.initialValue)} USDC ${formatUSDC(position.initialValue)}
</span> </span>
</div> </div>
{positionFilter === 'current' && position.currentPrice && ( {positionFilter === 'current' && position.currentPrice && (
@@ -732,7 +732,7 @@ const PositionList: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontSize: '13px', color: '#666' }}></span> <span style={{ fontSize: '13px', color: '#666' }}></span>
<span style={{ fontSize: '13px', fontWeight: '600' }}> <span style={{ fontSize: '13px', fontWeight: '600' }}>
{formatUSDC(position.currentValue)} USDC ${formatUSDC(position.currentValue)}
</span> </span>
</div> </div>
</> </>
@@ -775,7 +775,7 @@ const PositionList: React.FC = () => {
fontWeight: 'bold', fontWeight: 'bold',
color: isProfit ? '#52c41a' : '#f5222d' color: isProfit ? '#52c41a' : '#f5222d'
}}> }}>
{pnlNum >= 0 ? '+' : ''}{formatUSDC(position.pnl)} USDC {pnlNum >= 0 ? '+' : ''}${formatUSDC(position.pnl)}
</span> </span>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
@@ -802,7 +802,7 @@ const PositionList: React.FC = () => {
color: parseFloat(position.realizedPnl) >= 0 ? '#52c41a' : '#f5222d', color: parseFloat(position.realizedPnl) >= 0 ? '#52c41a' : '#f5222d',
fontWeight: '500' fontWeight: '500'
}}> }}>
{parseFloat(position.realizedPnl) >= 0 ? '+' : ''}{formatUSDC(position.realizedPnl)} USDC {parseFloat(position.realizedPnl) >= 0 ? '+' : ''}${formatUSDC(position.realizedPnl)}
</span> </span>
</div> </div>
)} )}
@@ -951,7 +951,7 @@ const PositionList: React.FC = () => {
dataIndex: 'initialValue', dataIndex: 'initialValue',
key: 'initialValue', key: 'initialValue',
render: (value: string) => ( render: (value: string) => (
<span>{formatUSDC(value)} USDC</span> <span>${formatUSDC(value)}</span>
), ),
align: 'right' as const, align: 'right' as const,
width: 110 width: 110
@@ -971,7 +971,7 @@ const PositionList: React.FC = () => {
return ( return (
<div> <div>
<div style={{ fontWeight: '600', marginBottom: '2px' }}> <div style={{ fontWeight: '600', marginBottom: '2px' }}>
{formatUSDC(record.currentValue)} USDC ${formatUSDC(record.currentValue)}
</div> </div>
<div style={{ <div style={{
fontSize: '13px', fontSize: '13px',
@@ -1215,7 +1215,7 @@ const PositionList: React.FC = () => {
borderColor: '#52c41a' borderColor: '#52c41a'
}} }}
> >
({redeemableSummary.totalCount}, {formatUSDC(redeemableSummary.totalValue)} USDC) ({redeemableSummary.totalCount}, ${formatUSDC(redeemableSummary.totalValue)})
</Button> </Button>
)} )}
</div> </div>
@@ -1238,13 +1238,13 @@ const PositionList: React.FC = () => {
<span> <span>
{' '} {' '}
<span style={{ fontWeight: 600 }}> <span style={{ fontWeight: 600 }}>
{formatUSDC(positionTotals.totalInitialValue.toString())} USDC ${formatUSDC(positionTotals.totalInitialValue.toString())}
</span> </span>
</span> </span>
<span> <span>
{' '} {' '}
<span style={{ fontWeight: 600 }}> <span style={{ fontWeight: 600 }}>
{formatUSDC(positionTotals.totalCurrentValue.toString())} USDC ${formatUSDC(positionTotals.totalCurrentValue.toString())}
</span> </span>
</span> </span>
<span> <span>
@@ -1256,7 +1256,7 @@ const PositionList: React.FC = () => {
}} }}
> >
{positionTotals.totalPnl >= 0 ? '+' : ''} {positionTotals.totalPnl >= 0 ? '+' : ''}
{formatUSDC(positionTotals.totalPnl.toString())} USDC ${formatUSDC(positionTotals.totalPnl.toString())}
</span> </span>
</span> </span>
<span> <span>
@@ -1268,7 +1268,7 @@ const PositionList: React.FC = () => {
}} }}
> >
{positionTotals.totalRealizedPnl >= 0 ? '+' : ''} {positionTotals.totalRealizedPnl >= 0 ? '+' : ''}
{formatUSDC(positionTotals.totalRealizedPnl.toString())} USDC ${formatUSDC(positionTotals.totalRealizedPnl.toString())}
</span> </span>
</span> </span>
</div> </div>
@@ -1532,7 +1532,7 @@ const PositionList: React.FC = () => {
color: currentPnl.pnl >= 0 ? '#52c41a' : '#f5222d', color: currentPnl.pnl >= 0 ? '#52c41a' : '#f5222d',
marginBottom: '4px' marginBottom: '4px'
}}> }}>
{currentPnl.pnl >= 0 ? '+' : ''}{formatUSDC(currentPnl.pnl)} USDC {currentPnl.pnl >= 0 ? '+' : ''}${formatUSDC(currentPnl.pnl)}
</div> </div>
<div style={{ <div style={{
fontSize: '14px', fontSize: '14px',
@@ -1572,7 +1572,7 @@ const PositionList: React.FC = () => {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="可赎回总价值"> <Descriptions.Item label="可赎回总价值">
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#52c41a' }}> <span style={{ fontSize: '18px', fontWeight: 'bold', color: '#52c41a' }}>
{formatUSDC(redeemableSummary.totalValue)} USDC ${formatUSDC(redeemableSummary.totalValue)}
</span> </span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="涉及账户"> <Descriptions.Item label="涉及账户">
@@ -1625,7 +1625,7 @@ const PositionList: React.FC = () => {
width: 120 width: 120
}, },
{ {
title: '价值 (USDC)', title: '价值 ($)',
dataIndex: 'value', dataIndex: 'value',
key: 'value', key: 'value',
align: 'right' as const, align: 'right' as const,
+4 -27
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert } from 'antd' import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space } from 'antd'
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined } from '@ant-design/icons' import { SaveOutlined, CheckCircleOutlined, ReloadOutlined } from '@ant-design/icons'
import { apiService } from '../services/api' import { apiService } from '../services/api'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import ProxyCheckResultAlert, { type ProxyCheckResponse } from '../components/ProxyCheckResultAlert'
const { Title, Text } = Typography const { Title } = Typography
interface ProxyConfig { interface ProxyConfig {
id?: number id?: number
@@ -20,13 +21,6 @@ interface ProxyConfig {
updatedAt: number updatedAt: number
} }
interface ProxyCheckResponse {
success: boolean
message: string
responseTime?: number
latency?: number
}
const ProxySettings: React.FC = () => { const ProxySettings: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
@@ -211,24 +205,7 @@ const ProxySettings: React.FC = () => {
</Form> </Form>
{checkResult && ( {checkResult && (
<Alert <ProxyCheckResultAlert result={checkResult} style={{ marginTop: '16px' }} />
type={checkResult.success ? 'success' : 'error'}
message={checkResult.success ? (t('proxySettings.checkSuccess') || '代理检查成功') : (t('proxySettings.checkFailed') || '代理检查失败')}
description={
<div>
<Text>{checkResult.message}</Text>
{(checkResult.responseTime !== undefined || checkResult.latency !== undefined) && (
<div style={{ marginTop: '8px' }}>
<Text type="secondary">
{t('proxySettings.latency') || '延迟'}: {(checkResult.latency ?? checkResult.responseTime) ?? 0}ms
</Text>
</div>
)}
</div>
}
style={{ marginTop: '16px' }}
showIcon
/>
)} )}
</Card> </Card>
</div> </div>
+4 -8
View File
@@ -101,9 +101,8 @@ const Statistics: React.FC = () => {
<Statistic <Statistic
title={t('statistics.totalPnl') || '总盈亏'} title={t('statistics.totalPnl') || '总盈亏'}
value={formatUSDC(stats?.totalPnl || '0')} value={formatUSDC(stats?.totalPnl || '0')}
prefix={stats?.totalPnl && parseFloat(stats.totalPnl) >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />} prefix={<>{stats?.totalPnl && parseFloat(stats.totalPnl) >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />} $</>}
valueStyle={{ color: stats?.totalPnl && parseFloat(stats.totalPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }} valueStyle={{ color: stats?.totalPnl && parseFloat(stats.totalPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }}
suffix="USDC"
loading={loading} loading={loading}
/> />
</Card> </Card>
@@ -124,9 +123,8 @@ const Statistics: React.FC = () => {
<Statistic <Statistic
title={t('statistics.avgPnl') || '平均盈亏'} title={t('statistics.avgPnl') || '平均盈亏'}
value={formatUSDC(stats?.avgPnl || '0')} value={formatUSDC(stats?.avgPnl || '0')}
prefix={stats?.avgPnl && parseFloat(stats.avgPnl || '0') >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />} prefix={<>{stats?.avgPnl && parseFloat(stats.avgPnl || '0') >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />} $</>}
valueStyle={{ color: stats?.avgPnl && parseFloat(stats.avgPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }} valueStyle={{ color: stats?.avgPnl && parseFloat(stats.avgPnl || '0') >= 0 ? '#3f8600' : '#cf1322' }}
suffix="USDC"
loading={loading} loading={loading}
/> />
</Card> </Card>
@@ -136,9 +134,8 @@ const Statistics: React.FC = () => {
<Statistic <Statistic
title={t('statistics.maxProfit') || '最大盈利'} title={t('statistics.maxProfit') || '最大盈利'}
value={formatUSDC(stats?.maxProfit || '0')} value={formatUSDC(stats?.maxProfit || '0')}
prefix={<ArrowUpOutlined />} prefix={<><ArrowUpOutlined /> $</>}
valueStyle={{ color: '#3f8600' }} valueStyle={{ color: '#3f8600' }}
suffix="USDC"
loading={loading} loading={loading}
/> />
</Card> </Card>
@@ -148,9 +145,8 @@ const Statistics: React.FC = () => {
<Statistic <Statistic
title={t('statistics.maxLoss') || '最大亏损'} title={t('statistics.maxLoss') || '最大亏损'}
value={formatUSDC(stats?.maxLoss || '0')} value={formatUSDC(stats?.maxLoss || '0')}
prefix={<ArrowDownOutlined />} prefix={<><ArrowDownOutlined /> $</>}
valueStyle={{ color: '#cf1322' }} valueStyle={{ color: '#cf1322' }}
suffix="USDC"
loading={loading} loading={loading}
/> />
</Card> </Card>
+8 -27
View File
@@ -7,7 +7,7 @@ import { useMediaQuery } from 'react-responsive'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types' import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types'
import SystemUpdate from './SystemUpdate' import SystemUpdate from './SystemUpdate'
import ProxyCheckResultAlert, { type ProxyCheckResponse } from '../components/ProxyCheckResultAlert'
const { Title, Text, Paragraph } = Typography const { Title, Text, Paragraph } = Typography
interface ProxyConfig { interface ProxyConfig {
@@ -23,13 +23,6 @@ interface ProxyConfig {
updatedAt: number updatedAt: number
} }
interface ProxyCheckResponse {
success: boolean
message: string
responseTime?: number
latency?: number
}
const SystemSettings: React.FC = () => { const SystemSettings: React.FC = () => {
const { t, i18n: i18nInstance } = useTranslation() const { t, i18n: i18nInstance } = useTranslation()
const isMobile = useMediaQuery({ maxWidth: 768 }) const isMobile = useMediaQuery({ maxWidth: 768 })
@@ -228,7 +221,12 @@ const SystemSettings: React.FC = () => {
const result = response.data.data const result = response.data.data
setProxyCheckResult(result) setProxyCheckResult(result)
if (result.success) { if (result.success) {
message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}`) const geoblockHint = result.geoblock?.blocked
? `${result.geoblock.message ?? t('proxySettings.geoblockBlocked', { location: `${result.geoblock.country}/${result.geoblock.region}` })}`
: result.geoblock?.message
? `${result.geoblock.message}`
: ''
message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}${geoblockHint}`)
} else { } else {
message.warning(`代理检查失败:${result.message}`) message.warning(`代理检查失败:${result.message}`)
} }
@@ -576,24 +574,7 @@ const SystemSettings: React.FC = () => {
</Form> </Form>
{proxyCheckResult && ( {proxyCheckResult && (
<Alert <ProxyCheckResultAlert result={proxyCheckResult} style={{ marginTop: '16px' }} />
type={proxyCheckResult.success ? 'success' : 'error'}
message={proxyCheckResult.success ? (t('proxySettings.checkSuccess') || '代理检查成功') : (t('proxySettings.checkFailed') || '代理检查失败')}
description={
<div>
<Text>{proxyCheckResult.message}</Text>
{(proxyCheckResult.responseTime !== undefined || proxyCheckResult.latency !== undefined) && (
<div style={{ marginTop: '8px' }}>
<Text type="secondary">
{t('proxySettings.latency') || '延迟'}: {(proxyCheckResult.latency ?? proxyCheckResult.responseTime) ?? 0}ms
</Text>
</div>
)}
</div>
}
style={{ marginTop: '16px' }}
showIcon
/>
)} )}
</Card> </Card>
+6 -6
View File
@@ -154,7 +154,7 @@ const TemplateAdd: React.FC = () => {
{copyMode === 'FIXED' && ( {copyMode === 'FIXED' && (
<Form.Item <Form.Item
label={t('templateAdd.fixedAmount') || '固定跟单金额 (USDC)'} label={t('templateAdd.fixedAmount') || '固定跟单金额 ($)'}
name="fixedAmount" name="fixedAmount"
rules={[ rules={[
{ required: true, message: t('templateAdd.fixedAmountRequired') || '请输入固定跟单金额' }, { required: true, message: t('templateAdd.fixedAmountRequired') || '请输入固定跟单金额' },
@@ -193,9 +193,9 @@ const TemplateAdd: React.FC = () => {
{copyMode === 'RATIO' && ( {copyMode === 'RATIO' && (
<> <>
<Form.Item <Form.Item
label={t('templateAdd.maxOrderSize') || '单笔订单最大金额 (USDC)'} label={t('templateAdd.maxOrderSize') || '单笔订单最大金额 ($)'}
name="maxOrderSize" name="maxOrderSize"
tooltip={t('templateAdd.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 1000 USDC。'} tooltip={t('templateAdd.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 $1000。'}
> >
<InputNumber <InputNumber
min={0.0001} min={0.0001}
@@ -213,9 +213,9 @@ const TemplateAdd: React.FC = () => {
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('templateAdd.minOrderSize') || '单笔订单最小金额 (USDC)'} label={t('templateAdd.minOrderSize') || '单笔订单最小金额 ($)'}
name="minOrderSize" name="minOrderSize"
tooltip={t('templateAdd.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'} tooltip={t('templateAdd.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= $1。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'}
rules={[ rules={[
{ {
validator: (_, value) => { validator: (_, value) => {
@@ -282,7 +282,7 @@ const TemplateAdd: React.FC = () => {
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('templateAdd.minOrderDepth') || '最小订单深度 (USDC)'} label={t('templateAdd.minOrderDepth') || '最小订单深度 ($)'}
name="minOrderDepth" name="minOrderDepth"
tooltip={t('templateAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'} tooltip={t('templateAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
> >
+7 -7
View File
@@ -189,9 +189,9 @@ const TemplateEdit: React.FC = () => {
{copyMode === 'FIXED' && ( {copyMode === 'FIXED' && (
<Form.Item <Form.Item
label={t('templateEdit.fixedAmount') || '固定跟单金额 (USDC)'} label={t('templateEdit.fixedAmount') || '固定跟单金额 ($)'}
name="fixedAmount" name="fixedAmount"
tooltip={t('templateEdit.fixedAmountTooltip') || '固定金额模式下,每次跟单的固定金额,不随 Leader 订单大小变化。必须 >= 1 USDC。例如:设置为 10,则无论 Leader 买入多少,跟单金额始终为 10 USDC。'} tooltip={t('templateEdit.fixedAmountTooltip') || '固定金额模式下,每次跟单的固定金额,不随 Leader 订单大小变化。必须 >= $1。例如:设置为 10,则无论 Leader 买入多少,跟单金额始终为 $10。'}
rules={[ rules={[
{ required: true, message: t('templateEdit.fixedAmountRequired') || '请输入固定跟单金额' }, { required: true, message: t('templateEdit.fixedAmountRequired') || '请输入固定跟单金额' },
{ {
@@ -229,9 +229,9 @@ const TemplateEdit: React.FC = () => {
{copyMode === 'RATIO' && ( {copyMode === 'RATIO' && (
<> <>
<Form.Item <Form.Item
label={t('templateEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'} label={t('templateEdit.maxOrderSize') || '单笔订单最大金额 ($)'}
name="maxOrderSize" name="maxOrderSize"
tooltip={t('templateEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 1000 USDC。'} tooltip={t('templateEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 $1000。'}
> >
<InputNumber <InputNumber
min={0.0001} min={0.0001}
@@ -249,9 +249,9 @@ const TemplateEdit: React.FC = () => {
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('templateEdit.minOrderSize') || '单笔订单最小金额 (USDC)'} label={t('templateEdit.minOrderSize') || '单笔订单最小金额 ($)'}
name="minOrderSize" name="minOrderSize"
tooltip={t('templateEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'} tooltip={t('templateEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= $1。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'}
rules={[ rules={[
{ {
validator: (_, value) => { validator: (_, value) => {
@@ -318,7 +318,7 @@ const TemplateEdit: React.FC = () => {
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('templateEdit.minOrderDepth') || '最小订单深度 (USDC)'} label={t('templateEdit.minOrderDepth') || '最小订单深度 ($)'}
name="minOrderDepth" name="minOrderDepth"
tooltip={t('templateEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'} tooltip={t('templateEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
> >
+10 -10
View File
@@ -176,7 +176,7 @@ const TemplateList: React.FC = () => {
if (record.copyMode === 'RATIO') { if (record.copyMode === 'RATIO') {
return `${t('templateList.ratio') || '比例'} ${record.copyRatio}x` return `${t('templateList.ratio') || '比例'} ${record.copyRatio}x`
} else if (record.copyMode === 'FIXED' && record.fixedAmount) { } else if (record.copyMode === 'FIXED' && record.fixedAmount) {
return `${t('templateList.fixedAmount') || '固定'} ${formatUSDC(record.fixedAmount)} USDC` return `$${formatUSDC(record.fixedAmount)}`
} }
return '-' return '-'
} }
@@ -350,7 +350,7 @@ const TemplateList: React.FC = () => {
<div style={{ fontSize: '12px', opacity: '0.9' }}> <div style={{ fontSize: '12px', opacity: '0.9' }}>
{template.copyMode === 'RATIO' {template.copyMode === 'RATIO'
? `${t('templateList.ratioMode') || '比例模式'} ${(parseFloat(template.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%` ? `${t('templateList.ratioMode') || '比例模式'} ${(parseFloat(template.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
: `${t('templateList.fixedAmountMode') || '固定金额'} ${formatUSDC(template.fixedAmount || '0')} USDC` : `$${formatUSDC(template.fixedAmount || '0')}`
} }
</div> </div>
</div> </div>
@@ -396,11 +396,11 @@ const TemplateList: React.FC = () => {
}}> }}>
<span style={{ color: '#d48806' }}>{t('templateList.amountLimit') || '金额限制'}: </span> <span style={{ color: '#d48806' }}>{t('templateList.amountLimit') || '金额限制'}: </span>
{template.maxOrderSize && ( {template.maxOrderSize && (
<span>{t('templateList.max') || '最大'} {formatUSDC(template.maxOrderSize)} USDC</span> <span>{t('templateList.max') || '最大'} ${formatUSDC(template.maxOrderSize)}</span>
)} )}
{template.maxOrderSize && template.minOrderSize && <span> | </span>} {template.maxOrderSize && template.minOrderSize && <span> | </span>}
{template.minOrderSize && ( {template.minOrderSize && (
<span>{t('templateList.min') || '最小'} {formatUSDC(template.minOrderSize)} USDC</span> <span>{t('templateList.min') || '最小'} ${formatUSDC(template.minOrderSize)}</span>
)} )}
{!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#bfbfbf' }}>{t('templateList.notSet') || '未设置'}</span>} {!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#bfbfbf' }}>{t('templateList.notSet') || '未设置'}</span>}
</div> </div>
@@ -551,7 +551,7 @@ const TemplateList: React.FC = () => {
{copyMode === 'FIXED' && ( {copyMode === 'FIXED' && (
<Form.Item <Form.Item
label="固定跟单金额 (USDC)" label="固定跟单金额 ($)"
name="fixedAmount" name="fixedAmount"
rules={[ rules={[
{ required: true, message: '请输入固定跟单金额' }, { required: true, message: '请输入固定跟单金额' },
@@ -589,9 +589,9 @@ const TemplateList: React.FC = () => {
{copyMode === 'RATIO' && ( {copyMode === 'RATIO' && (
<> <>
<Form.Item <Form.Item
label="单笔订单最大金额 (USDC)" label="单笔订单最大金额 ($)"
name="maxOrderSize" name="maxOrderSize"
tooltip="比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 1000 USDC。" tooltip="比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 $1000。"
> >
<InputNumber <InputNumber
min={0.01} min={0.01}
@@ -609,9 +609,9 @@ const TemplateList: React.FC = () => {
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label="单笔订单最小金额 (USDC)" label="单笔订单最小金额 ($)"
name="minOrderSize" name="minOrderSize"
tooltip="比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。" tooltip="比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= $1。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。"
rules={[ rules={[
{ {
validator: (_, value) => { validator: (_, value) => {
@@ -698,7 +698,7 @@ const TemplateList: React.FC = () => {
<Divider></Divider> <Divider></Divider>
<Form.Item <Form.Item
label="最小订单深度 (USDC)" label="最小订单深度 ($)"
name="minOrderDepth" name="minOrderDepth"
tooltip="检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤" tooltip="检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤"
> >
+37 -3
View File
@@ -284,9 +284,21 @@ export const apiService = {
/** /**
* *
*/ */
redeemPositions: (data: any) => redeemPositions: (data: any) =>
apiClient.post<ApiResponse<any>>('/accounts/positions/redeem', data), apiClient.post<ApiResponse<any>>('/accounts/positions/redeem', data),
/**
* USDC.e wrap pUSDV2
*/
wrapToPusd: (accountId: number) =>
apiClient.post<ApiResponse<{ transactionHash: string | null }>>('/accounts/wrap-to-pusd', { accountId }),
/**
* USDC.e V2
*/
getUsdceBalance: (accountId: number) =>
apiClient.post<ApiResponse<{ balance: string }>>('/accounts/usdce-balance', { accountId }),
}, },
/** /**
@@ -613,6 +625,15 @@ export const apiService = {
success: boolean success: boolean
message: string message: string
responseTime?: number responseTime?: number
latency?: number
geoblock?: {
checked: boolean
blocked?: boolean | null
ip?: string | null
country?: string | null
region?: string | null
message?: string | null
} | null
}>>('/system/proxy/check', {}), }>>('/system/proxy/check', {}),
/** /**
@@ -633,7 +654,20 @@ export const apiService = {
message: string message: string
responseTime?: number responseTime?: number
}> }>
}>>('/system/proxy/api-health-check', {}) }>>('/system/proxy/api-health-check', {}),
/**
* IP Polymarket Geoblock
*/
checkGeoblock: () =>
apiClient.post<ApiResponse<{
blocked: boolean
ip: string
country: string
region: string
checkedAt: number
source: string
}>>('/system/proxy/geoblock-check', {})
}, },
/** /**