Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dee65900e | |||
| e20d47ba4c |
@@ -1,201 +0,0 @@
|
||||
---
|
||||
name: 🤖 Bug Report for AI Fix / AI Bug 报告
|
||||
description: Bug 报告模板(提交后请手动添加 'fix via ai' 标签触发自动修复)
|
||||
title: '[Bug] / '
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Bug Report Template 🐛
|
||||
|
||||
本模板用于报告 PolyHermes 项目的 bug。
|
||||
|
||||
⚠️ **Important / 重要提示**:
|
||||
- After submission, if you need AI auto-fix, please manually add the `fix via ai` label
|
||||
/ 提交后,如需 AI 自动修复,请手动添加 `fix via ai` 标签
|
||||
- AI fixes will be on the `fix_issues_by_ai` branch / AI 修复将在 `fix_issues_by_ai` 分支上进行
|
||||
- All AI fixes require human review before merging / 所有 AI 修复需要人工审核后才能合并
|
||||
- Security vulnerabilities, database migrations, major changes are not recommended for AI auto-fix
|
||||
/ 涉及安全漏洞、数据库迁移、重大变更等问题不建议使用 AI 自动修复
|
||||
|
||||
---
|
||||
|
||||
本模板用于报告 PolyHermes 项目的 bug。
|
||||
|
||||
This template is for reporting bugs in the PolyHermes project.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 📝 Bug Description / Bug 描述
|
||||
description: Clearly and concisely describe the bug / 清晰简洁地描述这个 bug
|
||||
placeholder: Describe the bug you encountered / 描述你遇到的问题...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
label: 🎯 Bug Type / 问题类型
|
||||
description: Select the type of bug / 选择问题类型
|
||||
options:
|
||||
- Frontend bug (UI/UX/interaction) / 前端 bug (UI/UX/交互问题)
|
||||
- Backend bug (API/logic/data) / 后端 bug (API/逻辑/数据处理)
|
||||
- Database issue / 数据库问题
|
||||
- Performance issue / 性能问题
|
||||
- Configuration/Deployment / 配置/部署问题
|
||||
- Documentation / 文档问题
|
||||
- Other / 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: 📍 Affected Scope / 影响范围
|
||||
description: Select the scope of impact / 选择问题影响范围
|
||||
options:
|
||||
- Specific page/function only / 仅影响特定页面/功能
|
||||
- Entire system / 影响整个系统
|
||||
- Specific user role / 影响特定用户角色
|
||||
- Only in specific environment / 仅在特定环境下重现
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 🔍 Steps to Reproduce / 复现步骤
|
||||
description: Provide clear, detailed steps to reproduce the bug / 提供清晰、详细的步骤来重现这个 bug
|
||||
placeholder: |
|
||||
1. Visit page: `...` / 访问页面:`...`
|
||||
2. Click button: `...` / 点击按钮:`...`
|
||||
3. Input data: `...` / 输入数据:`...`
|
||||
4. Submit form: `...` / 提交表单:`...`
|
||||
5. Observe error: `...` / 观察到错误:`...`
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: frequency
|
||||
attributes:
|
||||
label: Reproduction Frequency / 复现频率
|
||||
options:
|
||||
- Always reproducible (100%) / 总是能复现 (100%)
|
||||
- Frequently reproducible (50%+) / 经常能复现 (50%+)
|
||||
- Occasionally reproducible (<50%) / 偶尔能复现 (<50%)
|
||||
- Hard to reproduce / 很难复现
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 💻 Expected Behavior / 预期行为
|
||||
description: Describe what you expected to happen / 描述你期望发生什么
|
||||
placeholder: What should happen / 应该发生什么...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: ❌ Actual Behavior / 实际行为
|
||||
description: Describe what actually happened / 描述实际发生了什么
|
||||
placeholder: What actually happened / 实际发生了什么...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 📸 Screenshots / Recordings / 截图/录屏
|
||||
description: If applicable, add screenshots or recordings to illustrate the problem (drag and drop files here)
|
||||
/ 如果适用,添加截图或录屏来说明问题(可以拖拽文件到这里)
|
||||
placeholder: Add screenshots or recordings / 添加截图或录屏...
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 🌐 Environment / 环境
|
||||
description: Provide relevant environment information / 提供相关环境信息
|
||||
value: |
|
||||
**Browser (for frontend issues) / 浏览器(前端问题):**
|
||||
- Browser: ______ / 浏览器:______
|
||||
- Browser version: ______ / 浏览器版本:______
|
||||
- Operating System: ______ / 操作系统:______
|
||||
|
||||
**Backend Environment (for backend issues) / 后端环境(后端问题):**
|
||||
- Node.js version: ______ / Node.js 版本:______
|
||||
- Database version: ______ / 数据库版本:______
|
||||
- Docker version (if used): ______ / Docker 版本(如果使用):______
|
||||
- Other relevant dependencies: ______ / 其他相关依赖版本:______
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: related-files
|
||||
attributes:
|
||||
label: 📁 Related Files / Code / 相关文件/代码
|
||||
description: Provide relevant file paths or code snippets / 提供可能涉及的文件路径或相关代码片段
|
||||
placeholder: |
|
||||
Possibly related files / 可能涉及的文件:
|
||||
- frontend/src/components/...
|
||||
- backend/src/main/kotlin/...
|
||||
|
||||
Error logs / 错误日志:
|
||||
```
|
||||
Paste error logs here / 粘贴错误日志
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: suggestions
|
||||
attributes:
|
||||
label: 🎯 Fix Suggestions (Optional) / 修复建议(可选)
|
||||
description: If you have fix ideas, describe them briefly / 如果你有修复思路,可以在这里简单描述
|
||||
placeholder: |
|
||||
Suggest adding ZZZ check in the YYY method of file XXX
|
||||
/ 建议在 XXX 文件的 YYY 方法中,添加 ZZZ 检查
|
||||
...
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 🚨 Priority / 优先级
|
||||
options:
|
||||
- 🔴 High - Blocking core functionality, affects user experience / 高 - 阻塞核心功能,影响用户体验
|
||||
- 🟡 Medium - Limited functionality but not blocking / 中 - 功能受限但不阻塞
|
||||
- 🟢 Low - Minor issue, doesn't affect usage / 低 - 小问题,不影响使用
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 📝 Additional Information / 补充说明
|
||||
description: Any other information that helps AI understand and fix the issue / 任何其他有助于 AI 理解和修复问题的信息
|
||||
placeholder: |
|
||||
- Was this bug introduced recently? / 这个 bug 是最近引入的吗?
|
||||
- Is it related to a specific PR or commit? / 是否与某个特定的 PR 或 commit 相关?
|
||||
- Does it only occur with specific datasets or users? / 是否只在特定数据集或特定用户情况下出现?
|
||||
/ ...
|
||||
Any other context / 其他任何上下文信息...
|
||||
|
||||
- type: checkboxes
|
||||
id: ai-fix-approval
|
||||
attributes:
|
||||
label: 🤖 AI Auto-Fix Confirmation / AI 自动修复确认
|
||||
description: If you need AI to auto-fix this issue, check the options below (remember to add 'fix via ai' label after submission)
|
||||
/ 如需 AI 自动修复此 issue,请勾选下方选项(提交后记得添加 'fix via ai' 标签)
|
||||
options:
|
||||
- label: |
|
||||
I understand the AI auto-fix workflow and agree to manually review the AI-created PR
|
||||
/ 我已了解 AI 自动修复的工作流程,并同意在 AI 创建 PR 后进行人工审核
|
||||
required: false
|
||||
- label: |
|
||||
This issue is suitable for AI auto-fix (not a security vulnerability, not a database migration, not a major change)
|
||||
/ 此问题适合 AI 自动修复(非安全漏洞、非数据库迁移、非重大变更)
|
||||
required: false
|
||||
@@ -1,127 +0,0 @@
|
||||
# PR 合并后自动关闭关联的 Issue
|
||||
# 当 PR 从 ai_fix/N_xxx 分支合并到 main 时,关闭 #N 对应的 Issue(若 PR 描述中未含 Closes #N 则通过分支名解析)
|
||||
# 关闭后发送 Telegram 通知(复用 TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID)
|
||||
|
||||
name: Close issue on PR merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
close-issue:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Get PR info
|
||||
id: pr
|
||||
run: |
|
||||
# 从 PR body 查找 Closes #N / Fixes #N(若已有则 GitHub 已自动关 issue,本 step 仅做解析)
|
||||
BODY="${{ github.event.pull_request.body }}"
|
||||
HEAD_REF="${{ github.event.pull_request.head.ref }}"
|
||||
|
||||
# 优先从 PR 描述解析(无匹配时 grep 会 exit 1,需 || true 避免 set -e 导致脚本退出)
|
||||
ISSUE_NUM=$(echo "$BODY" | grep -oE '(Closes|Fixes|Resolves) #([0-9]+)' | head -1 | grep -oE '[0-9]+' || true)
|
||||
if [ -z "$ISSUE_NUM" ]; then
|
||||
# 从分支名解析 ai_fix/N_xxx
|
||||
ISSUE_NUM=$(echo "$HEAD_REF" | sed -n 's|^ai_fix/\([0-9]*\)_.*|\1|p')
|
||||
fi
|
||||
|
||||
if [ -z "$ISSUE_NUM" ]; then
|
||||
echo "ISSUE_NUMBER=" >> $GITHUB_OUTPUT
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "未从 PR 描述或分支名解析到 Issue 编号,跳过关闭"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "ISSUE_NUMBER=$ISSUE_NUM" >> $GITHUB_OUTPUT
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "解析到 Issue #$ISSUE_NUM"
|
||||
|
||||
- name: Get issue details
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
id: issue
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
|
||||
# 获取 issue 标题与 URL(用于 TG 消息)
|
||||
JSON=$(gh issue view "$ISSUE_NUM" --json title,url 2>/dev/null || echo '{"title":"","url":""}')
|
||||
TITLE=$(echo "$JSON" | jq -r '.title')
|
||||
ISSUE_URL=$(echo "$JSON" | jq -r '.url')
|
||||
echo "title<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$TITLE" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
echo "url=$ISSUE_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Close issue
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issueNumber = parseInt('${{ steps.pr.outputs.ISSUE_NUMBER }}', 10);
|
||||
if (!issueNumber || isNaN(issueNumber)) {
|
||||
console.log('No valid issue number, skip');
|
||||
return;
|
||||
}
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
if (issue.state === 'open') {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed'
|
||||
});
|
||||
console.log(`Issue #${issueNumber} closed.`);
|
||||
} else {
|
||||
console.log(`Issue #${issueNumber} already closed.`);
|
||||
}
|
||||
|
||||
- name: Send Telegram notification
|
||||
if: steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
# 与 docker-build 一致:未配置则跳过
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ISSUE_NUM="${{ steps.pr.outputs.ISSUE_NUMBER }}"
|
||||
ISSUE_TITLE="${{ steps.issue.outputs.title }}"
|
||||
ISSUE_URL="${{ steps.issue.outputs.url }}"
|
||||
PR_URL="${{ github.event.pull_request.html_url }}"
|
||||
|
||||
# 与 docker-build 相同的 HTML 消息格式
|
||||
MESSAGE="✅ <b>AI 修复的 Issue 已关闭</b>"$'\n'$'\n'"🔢 <b>Issue:</b> #${ISSUE_NUM} ${ISSUE_TITLE}"$'\n'"📎 <a href=\"${ISSUE_URL}\">查看 Issue</a>"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
|
||||
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$MESSAGE" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
exit 0
|
||||
fi
|
||||
+1
-2
@@ -112,5 +112,4 @@ __pycache__/
|
||||
clob-client/
|
||||
builder-relayer-client/
|
||||
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")
|
||||
suspend fun createOrdersBatch(
|
||||
@Body request: List<NewOrderRequest>
|
||||
@Body request: CreateOrdersBatchRequest
|
||||
): Response<List<OrderResponse>>
|
||||
|
||||
/**
|
||||
@@ -174,25 +174,22 @@ interface PolymarketClobApi {
|
||||
// 请求和响应数据类
|
||||
|
||||
/**
|
||||
* V2 签名的订单对象
|
||||
* 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
|
||||
* 签名的订单对象(根据官方文档)
|
||||
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
|
||||
*/
|
||||
data class SignedOrderObject(
|
||||
val salt: Long, // random salt used to create unique order
|
||||
val maker: String, // maker address (funder)
|
||||
val signer: String, // signing address
|
||||
val taker: String, // taker address (zero address for public orders, NOT in EIP-712 signing)
|
||||
val taker: String, // taker address (operator)
|
||||
val tokenId: String, // ERC1155 token ID of conditional token being traded
|
||||
val makerAmount: String, // maximum amount maker is willing to spend
|
||||
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 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
|
||||
)
|
||||
|
||||
@@ -201,11 +198,10 @@ data class SignedOrderObject(
|
||||
* 参考: https://docs.polymarket.com/developers/CLOB/orders/create-order
|
||||
*/
|
||||
data class NewOrderRequest(
|
||||
val order: SignedOrderObject, // V2 signed object
|
||||
val order: SignedOrderObject, // signed object
|
||||
val owner: String, // api key of order owner
|
||||
val orderType: String, // order type ("FOK", "GTC", "GTD", "FAK")
|
||||
val deferExec: Boolean = false, // defer execution
|
||||
val postOnly: Boolean = false // post only (maker-only)
|
||||
val deferExec: Boolean = false // defer execution flag
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -239,6 +235,26 @@ 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(
|
||||
val orderIds: List<String>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* Polymarket Gamma API 体育市场接口
|
||||
* Base URL: https://gamma-api.polymarket.com
|
||||
*/
|
||||
interface PolymarketGammaSportsApi {
|
||||
|
||||
/**
|
||||
* 获取体育类别列表
|
||||
* GET /sports
|
||||
*/
|
||||
@GET("/sports")
|
||||
suspend fun getSports(): Response<List<SportsCategoryResponse>>
|
||||
|
||||
/**
|
||||
* 按条件搜索市场
|
||||
* GET /markets
|
||||
* @param tagId 标签ID(体育类别)
|
||||
* @param active 是否活跃
|
||||
* @param closed 是否已关闭
|
||||
* @param limit 返回数量
|
||||
* @param order 排序字段
|
||||
* @param ascending 是否升序
|
||||
* @param slug 搜索关键词
|
||||
*/
|
||||
@GET("/markets")
|
||||
suspend fun searchMarkets(
|
||||
@Query("tag_id") tagId: Long? = null,
|
||||
@Query("active") active: Boolean? = null,
|
||||
@Query("closed") closed: Boolean? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("order") order: String? = null,
|
||||
@Query("ascending") ascending: Boolean? = null,
|
||||
@Query("slug") slug: String? = null,
|
||||
@Query("condition_ids") conditionIds: String? = null
|
||||
): Response<List<SportsMarketResponse>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 体育类别响应
|
||||
*/
|
||||
data class SportsCategoryResponse(
|
||||
val sport: String? = null,
|
||||
val image: String? = null,
|
||||
val tags: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 体育市场响应
|
||||
*/
|
||||
data class SportsMarketResponse(
|
||||
val id: String? = null,
|
||||
val question: String? = null,
|
||||
val conditionId: String? = null,
|
||||
val slug: String? = null,
|
||||
val outcomes: String? = null,
|
||||
val outcomePrices: String? = null,
|
||||
val endDate: String? = null,
|
||||
val startDate: String? = null,
|
||||
val bestBid: Double? = null,
|
||||
val bestAsk: Double? = null,
|
||||
val clobTokenIds: String? = null,
|
||||
val liquidity: String? = null,
|
||||
val liquidityNum: Double? = null,
|
||||
val volume: String? = null,
|
||||
val volumeNum: Double? = null,
|
||||
val active: Boolean? = null,
|
||||
val closed: Boolean? = null,
|
||||
val events: List<SportsEventResponse>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 体育事件响应
|
||||
*/
|
||||
data class SportsEventResponse(
|
||||
val id: String? = null,
|
||||
val slug: String? = null,
|
||||
val title: String? = null,
|
||||
val ticker: String? = null
|
||||
)
|
||||
-48
@@ -572,53 +572,5 @@ class AccountController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 USDC.e wrap 为 pUSD(V2 迁移)
|
||||
*/
|
||||
@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))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -267,7 +267,7 @@ class CryptoTailStrategyController(
|
||||
"当前周期已下单" -> ErrorCode.PARAM_ERROR
|
||||
"价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR
|
||||
"数量不能少于 1" -> ErrorCode.PARAM_ERROR
|
||||
"总金额不能少于 $1" -> ErrorCode.PARAM_ERROR
|
||||
"总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR
|
||||
"总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR
|
||||
else -> ErrorCode.SERVER_ERROR
|
||||
}
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package com.wrbug.polymarketbot.controller.sportstail
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||
import com.wrbug.polymarketbot.dto.SportsCategoryListResponse
|
||||
import com.wrbug.polymarketbot.dto.SportsMarketDetailRequest
|
||||
import com.wrbug.polymarketbot.dto.SportsMarketDetailResponse
|
||||
import com.wrbug.polymarketbot.dto.SportsMarketSearchRequest
|
||||
import com.wrbug.polymarketbot.dto.SportsMarketSearchResponse
|
||||
import com.wrbug.polymarketbot.dto.SportsTailStrategyCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.SportsTailStrategyCreateResponse
|
||||
import com.wrbug.polymarketbot.dto.SportsTailStrategyDeleteRequest
|
||||
import com.wrbug.polymarketbot.dto.SportsTailStrategyListRequest
|
||||
import com.wrbug.polymarketbot.dto.SportsTailStrategyListResponse
|
||||
import com.wrbug.polymarketbot.dto.SportsTailTriggerListRequest
|
||||
import com.wrbug.polymarketbot.dto.SportsTailTriggerListResponse
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.sportstail.SportsTailStrategyService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sports-tail-strategy")
|
||||
class SportsTailStrategyController(
|
||||
private val sportsTailStrategyService: SportsTailStrategyService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(SportsTailStrategyController::class.java)
|
||||
|
||||
@PostMapping("/list")
|
||||
fun list(@RequestBody request: SportsTailStrategyListRequest): ResponseEntity<ApiResponse<SportsTailStrategyListResponse>> {
|
||||
return try {
|
||||
val result = sportsTailStrategyService.list(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询体育尾盘策略列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_LIST_FETCH_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询体育尾盘策略列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_LIST_FETCH_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
fun create(@RequestBody request: SportsTailStrategyCreateRequest): ResponseEntity<ApiResponse<SportsTailStrategyCreateResponse>> {
|
||||
return try {
|
||||
val result = sportsTailStrategyService.create(request)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.success(SportsTailStrategyCreateResponse(id = it.id))
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("创建体育尾盘策略失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
ErrorCode.ACCOUNT_NOT_FOUND.messageKey -> ErrorCode.ACCOUNT_NOT_FOUND
|
||||
ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY
|
||||
ErrorCode.SPORTS_TAIL_STRATEGY_PRICE_INVALID.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_PRICE_INVALID
|
||||
ErrorCode.SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||
"该市场已存在策略" -> ErrorCode.PARAM_ERROR
|
||||
else -> ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_CREATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建体育尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_CREATE_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
fun delete(@RequestBody request: SportsTailStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val id = request.id
|
||||
if (id <= 0) {
|
||||
return ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)
|
||||
)
|
||||
}
|
||||
val result = sportsTailStrategyService.delete(id)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
||||
onFailure = { e ->
|
||||
logger.error("删除体育尾盘策略失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND
|
||||
"已成交未卖出的策略不能删除" -> ErrorCode.PARAM_ERROR
|
||||
else -> ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_DELETE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除体育尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_DELETE_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/triggers")
|
||||
fun triggers(@RequestBody request: SportsTailTriggerListRequest): ResponseEntity<ApiResponse<SportsTailTriggerListResponse>> {
|
||||
return try {
|
||||
val result = sportsTailStrategyService.getTriggers(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录异常: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/sports-list")
|
||||
fun sportsList(): ResponseEntity<ApiResponse<SportsCategoryListResponse>> {
|
||||
return runBlocking {
|
||||
try {
|
||||
val result = sportsTailStrategyService.getSportsCategories()
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询体育类别失败: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_SPORTS_FETCH_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询体育类别异常: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_SPORTS_FETCH_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/market-search")
|
||||
fun marketSearch(@RequestBody request: SportsMarketSearchRequest): ResponseEntity<ApiResponse<SportsMarketSearchResponse>> {
|
||||
return runBlocking {
|
||||
try {
|
||||
val result = sportsTailStrategyService.searchMarkets(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("搜索市场失败: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_SEARCH_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("搜索市场异常: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_SEARCH_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/market-detail")
|
||||
fun marketDetail(@RequestBody request: SportsMarketDetailRequest): ResponseEntity<ApiResponse<SportsMarketDetailResponse>> {
|
||||
return runBlocking {
|
||||
try {
|
||||
if (request.conditionId.isBlank()) {
|
||||
return@runBlocking ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY, messageSource = messageSource)
|
||||
)
|
||||
}
|
||||
val result = sportsTailStrategyService.getMarketDetail(request.conditionId)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("获取市场详情失败: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_DETAIL_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取市场详情异常: ${e.message}", e)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_SPORTS_TAIL_STRATEGY_MARKET_DETAIL_FAILED,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-28
@@ -3,7 +3,6 @@ package com.wrbug.polymarketbot.controller.system
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
|
||||
import com.wrbug.polymarketbot.service.system.GeoblockService
|
||||
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -20,7 +19,6 @@ import org.springframework.web.bind.annotation.*
|
||||
class ProxyConfigController(
|
||||
private val proxyConfigService: ProxyConfigService,
|
||||
private val apiHealthCheckService: ApiHealthCheckService,
|
||||
private val geoblockService: GeoblockService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
@@ -125,32 +123,6 @@ class ProxyConfigController(
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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,18 +36,6 @@ data class SubscriptionProxyConfigRequest(
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理检查响应
|
||||
*/
|
||||
@@ -55,22 +43,15 @@ data class ProxyCheckResponse(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val responseTime: Long? = null, // 响应时间(毫秒)
|
||||
val latency: Long? = null, // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
||||
val geoblock: ProxyCheckGeoblockResult? = null
|
||||
val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
success: Boolean,
|
||||
message: String,
|
||||
responseTime: Long? = null,
|
||||
geoblock: ProxyCheckGeoblockResult? = null
|
||||
): ProxyCheckResponse {
|
||||
fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse {
|
||||
return ProxyCheckResponse(
|
||||
success = success,
|
||||
message = message,
|
||||
responseTime = responseTime,
|
||||
latency = responseTime,
|
||||
geoblock = geoblock
|
||||
latency = responseTime // latency 和 responseTime 相同
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 体育尾盘策略 DTO
|
||||
*/
|
||||
data class SportsTailStrategyDto(
|
||||
val id: Long = 0L,
|
||||
val accountId: Long = 0L,
|
||||
val accountName: String? = null,
|
||||
val conditionId: String = "",
|
||||
val marketTitle: String? = null,
|
||||
val eventSlug: String? = null,
|
||||
val triggerPrice: String = "",
|
||||
val amountMode: String = "FIXED",
|
||||
val amountValue: String = "",
|
||||
val takeProfitPrice: String? = null,
|
||||
val stopLossPrice: String? = null,
|
||||
|
||||
/** 成交信息 */
|
||||
val filled: Boolean = false,
|
||||
val filledPrice: String? = null,
|
||||
val filledOutcomeIndex: Int? = null,
|
||||
val filledOutcomeName: String? = null,
|
||||
val filledAmount: String? = null,
|
||||
val filledShares: String? = null,
|
||||
val filledAt: Long? = null,
|
||||
|
||||
/** 卖出信息 */
|
||||
val sold: Boolean = false,
|
||||
val sellPrice: String? = null,
|
||||
val sellType: String? = null,
|
||||
val sellAmount: String? = null,
|
||||
val realizedPnl: String? = null,
|
||||
val soldAt: Long? = null,
|
||||
|
||||
/** 实时价格(未成交时返回) */
|
||||
val realtimeYesPrice: String? = null,
|
||||
val realtimeNoPrice: String? = null,
|
||||
|
||||
val createdAt: Long = 0L,
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 策略列表请求
|
||||
*/
|
||||
data class SportsTailStrategyListRequest(
|
||||
val accountId: Long? = null,
|
||||
val sport: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 策略列表响应
|
||||
*/
|
||||
data class SportsTailStrategyListResponse(
|
||||
val list: List<SportsTailStrategyDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 策略创建请求
|
||||
*/
|
||||
data class SportsTailStrategyCreateRequest(
|
||||
val accountId: Long = 0L,
|
||||
val conditionId: String = "",
|
||||
val marketTitle: String = "",
|
||||
val eventSlug: String? = null,
|
||||
val triggerPrice: String = "",
|
||||
val amountMode: String = "FIXED",
|
||||
val amountValue: String = "",
|
||||
val takeProfitPrice: String? = null,
|
||||
val stopLossPrice: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 策略创建响应
|
||||
*/
|
||||
data class SportsTailStrategyCreateResponse(
|
||||
val id: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 策略删除请求
|
||||
*/
|
||||
data class SportsTailStrategyDeleteRequest(
|
||||
val id: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 策略触发记录 DTO
|
||||
*/
|
||||
data class SportsTailTriggerDto(
|
||||
val id: Long = 0L,
|
||||
val strategyId: Long = 0L,
|
||||
|
||||
/** 市场信息 */
|
||||
val marketTitle: String? = null,
|
||||
val conditionId: String = "",
|
||||
|
||||
/** 买入信息 */
|
||||
val buyPrice: String = "",
|
||||
val outcomeIndex: Int = 0,
|
||||
val outcomeName: String? = null,
|
||||
val buyAmount: String = "",
|
||||
val buyShares: String? = null,
|
||||
val buyStatus: String = "PENDING",
|
||||
|
||||
/** 卖出信息 */
|
||||
val sellPrice: String? = null,
|
||||
val sellType: String? = null,
|
||||
val sellAmount: String? = null,
|
||||
val sellStatus: String? = null,
|
||||
|
||||
/** 盈亏 */
|
||||
val realizedPnl: String? = null,
|
||||
|
||||
/** 时间 */
|
||||
val triggeredAt: Long = 0L,
|
||||
val soldAt: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录列表请求
|
||||
*/
|
||||
data class SportsTailTriggerListRequest(
|
||||
val accountId: Long? = null,
|
||||
val status: String? = null,
|
||||
val startTime: Long? = null,
|
||||
val endTime: Long? = null,
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 20
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录列表响应
|
||||
*/
|
||||
data class SportsTailTriggerListResponse(
|
||||
val total: Long = 0L,
|
||||
val list: List<SportsTailTriggerDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 体育类别 DTO
|
||||
*/
|
||||
data class SportsCategoryDto(
|
||||
val sport: String = "",
|
||||
val image: String? = null,
|
||||
val tagId: Long = 0L,
|
||||
val name: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* 体育类别列表响应
|
||||
*/
|
||||
data class SportsCategoryListResponse(
|
||||
val list: List<SportsCategoryDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 体育市场 DTO
|
||||
*/
|
||||
data class SportsMarketDto(
|
||||
val conditionId: String = "",
|
||||
val question: String = "",
|
||||
val outcomes: List<String> = emptyList(),
|
||||
val outcomePrices: List<String> = emptyList(),
|
||||
val endDate: String? = null,
|
||||
val liquidity: String? = null,
|
||||
val bestBid: Double? = null,
|
||||
val bestAsk: Double? = null,
|
||||
val yesTokenId: String? = null,
|
||||
val noTokenId: String? = null,
|
||||
val eventSlug: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 市场搜索请求
|
||||
*/
|
||||
data class SportsMarketSearchRequest(
|
||||
val sport: String? = null,
|
||||
val endDateMin: String? = null,
|
||||
val endDateMax: String? = null,
|
||||
val minLiquidity: String? = null,
|
||||
val keyword: String? = null,
|
||||
val limit: Int = 50
|
||||
)
|
||||
|
||||
/**
|
||||
* 市场搜索响应
|
||||
*/
|
||||
data class SportsMarketSearchResponse(
|
||||
val list: List<SportsMarketDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 市场详情请求
|
||||
*/
|
||||
data class SportsMarketDetailRequest(
|
||||
val conditionId: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* 市场详情响应
|
||||
*/
|
||||
data class SportsMarketDetailResponse(
|
||||
val conditionId: String = "",
|
||||
val question: String = "",
|
||||
val outcomes: List<String> = emptyList(),
|
||||
val outcomePrices: List<String> = emptyList(),
|
||||
val endDate: String? = null,
|
||||
val liquidity: String? = null,
|
||||
val bestBid: Double? = null,
|
||||
val bestAsk: Double? = null,
|
||||
val yesTokenId: String? = null,
|
||||
val noTokenId: String? = null,
|
||||
val eventSlug: String? = null
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 体育尾盘策略实体
|
||||
* 在价格达到设定值时自动买入,支持止盈止损
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sports_tail_strategy")
|
||||
data class SportsTailStrategy(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
/** 账户ID */
|
||||
@Column(name = "account_id", nullable = false)
|
||||
val accountId: Long = 0L,
|
||||
|
||||
/** 市场 conditionId */
|
||||
@Column(name = "condition_id", nullable = false, length = 100)
|
||||
val conditionId: String = "",
|
||||
|
||||
/** 市场标题 */
|
||||
@Column(name = "market_title", length = 500)
|
||||
val marketTitle: String? = null,
|
||||
|
||||
/** 事件 slug */
|
||||
@Column(name = "event_slug", length = 255)
|
||||
val eventSlug: String? = null,
|
||||
|
||||
/** YES Token ID */
|
||||
@Column(name = "yes_token_id", length = 100)
|
||||
val yesTokenId: String? = null,
|
||||
|
||||
/** NO Token ID */
|
||||
@Column(name = "no_token_id", length = 100)
|
||||
val noTokenId: String? = null,
|
||||
|
||||
/** 触发价格 */
|
||||
@Column(name = "trigger_price", nullable = false, precision = 20, scale = 8)
|
||||
val triggerPrice: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
/** 金额模式: FIXED=固定金额, RATIO=余额比例 */
|
||||
@Column(name = "amount_mode", nullable = false, length = 10)
|
||||
val amountMode: String = "FIXED",
|
||||
|
||||
/** 金额值 */
|
||||
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
|
||||
val amountValue: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
/** 止盈价格 */
|
||||
@Column(name = "take_profit_price", precision = 20, scale = 8)
|
||||
val takeProfitPrice: BigDecimal? = null,
|
||||
|
||||
/** 止损价格 */
|
||||
@Column(name = "stop_loss_price", precision = 20, scale = 8)
|
||||
val stopLossPrice: BigDecimal? = null,
|
||||
|
||||
/** 是否已成交 */
|
||||
@Column(name = "filled", nullable = false)
|
||||
val filled: Boolean = false,
|
||||
|
||||
/** 成交价格 */
|
||||
@Column(name = "filled_price", precision = 20, scale = 8)
|
||||
val filledPrice: BigDecimal? = null,
|
||||
|
||||
/** 成交方向索引: 0=YES, 1=NO */
|
||||
@Column(name = "filled_outcome_index")
|
||||
val filledOutcomeIndex: Int? = null,
|
||||
|
||||
/** 成交方向名称 */
|
||||
@Column(name = "filled_outcome_name", length = 50)
|
||||
val filledOutcomeName: String? = null,
|
||||
|
||||
/** 成交金额 */
|
||||
@Column(name = "filled_amount", precision = 20, scale = 8)
|
||||
val filledAmount: BigDecimal? = null,
|
||||
|
||||
/** 成交份额 */
|
||||
@Column(name = "filled_shares", precision = 20, scale = 8)
|
||||
val filledShares: BigDecimal? = null,
|
||||
|
||||
/** 成交时间 */
|
||||
@Column(name = "filled_at")
|
||||
val filledAt: Long? = null,
|
||||
|
||||
/** 是否已卖出 */
|
||||
@Column(name = "sold", nullable = false)
|
||||
val sold: Boolean = false,
|
||||
|
||||
/** 卖出价格 */
|
||||
@Column(name = "sell_price", precision = 20, scale = 8)
|
||||
val sellPrice: BigDecimal? = null,
|
||||
|
||||
/** 卖出类型: TAKE_PROFIT, STOP_LOSS, MANUAL */
|
||||
@Column(name = "sell_type", length = 20)
|
||||
val sellType: String? = null,
|
||||
|
||||
/** 卖出金额 */
|
||||
@Column(name = "sell_amount", precision = 20, scale = 8)
|
||||
val sellAmount: BigDecimal? = null,
|
||||
|
||||
/** 已实现盈亏 */
|
||||
@Column(name = "realized_pnl", precision = 20, scale = 8)
|
||||
val realizedPnl: BigDecimal? = null,
|
||||
|
||||
/** 卖出时间 */
|
||||
@Column(name = "sold_at")
|
||||
val soldAt: Long? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 体育尾盘策略触发记录
|
||||
* 记录每次买入/卖出的详细信息
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "sports_tail_strategy_trigger")
|
||||
data class SportsTailStrategyTrigger(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
/** 策略ID */
|
||||
@Column(name = "strategy_id", nullable = false)
|
||||
val strategyId: Long = 0L,
|
||||
|
||||
/** 账户ID */
|
||||
@Column(name = "account_id", nullable = false)
|
||||
val accountId: Long = 0L,
|
||||
|
||||
/** 市场 conditionId */
|
||||
@Column(name = "condition_id", nullable = false, length = 100)
|
||||
val conditionId: String = "",
|
||||
|
||||
/** 市场标题 */
|
||||
@Column(name = "market_title", length = 500)
|
||||
val marketTitle: String? = null,
|
||||
|
||||
/** 买入价格 */
|
||||
@Column(name = "buy_price", nullable = false, precision = 20, scale = 8)
|
||||
val buyPrice: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
/** 买入方向索引: 0=YES, 1=NO */
|
||||
@Column(name = "outcome_index", nullable = false)
|
||||
val outcomeIndex: Int = 0,
|
||||
|
||||
/** 买入方向名称 */
|
||||
@Column(name = "outcome_name", length = 50)
|
||||
val outcomeName: String? = null,
|
||||
|
||||
/** 买入金额 */
|
||||
@Column(name = "buy_amount", nullable = false, precision = 20, scale = 8)
|
||||
val buyAmount: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
/** 买入份额 */
|
||||
@Column(name = "buy_shares", precision = 20, scale = 8)
|
||||
val buyShares: BigDecimal? = null,
|
||||
|
||||
/** 买入订单ID */
|
||||
@Column(name = "buy_order_id", length = 100)
|
||||
val buyOrderId: String? = null,
|
||||
|
||||
/** 买入状态: PENDING, SUCCESS, FAIL */
|
||||
@Column(name = "buy_status", nullable = false, length = 20)
|
||||
val buyStatus: String = "PENDING",
|
||||
|
||||
/** 买入失败原因 */
|
||||
@Column(name = "buy_fail_reason", length = 500)
|
||||
val buyFailReason: String? = null,
|
||||
|
||||
/** 卖出价格 */
|
||||
@Column(name = "sell_price", precision = 20, scale = 8)
|
||||
val sellPrice: BigDecimal? = null,
|
||||
|
||||
/** 卖出类型: TAKE_PROFIT, STOP_LOSS, MANUAL */
|
||||
@Column(name = "sell_type", length = 20)
|
||||
val sellType: String? = null,
|
||||
|
||||
/** 卖出金额 */
|
||||
@Column(name = "sell_amount", precision = 20, scale = 8)
|
||||
val sellAmount: BigDecimal? = null,
|
||||
|
||||
/** 卖出订单ID */
|
||||
@Column(name = "sell_order_id", length = 100)
|
||||
val sellOrderId: String? = null,
|
||||
|
||||
/** 卖出状态: PENDING, SUCCESS, FAIL */
|
||||
@Column(name = "sell_status", length = 20)
|
||||
val sellStatus: String? = null,
|
||||
|
||||
/** 卖出失败原因 */
|
||||
@Column(name = "sell_fail_reason", length = 500)
|
||||
val sellFailReason: String? = null,
|
||||
|
||||
/** 已实现盈亏 */
|
||||
@Column(name = "realized_pnl", precision = 20, scale = 8)
|
||||
val realizedPnl: BigDecimal? = null,
|
||||
|
||||
/** 触发时间 */
|
||||
@Column(name = "triggered_at", nullable = false)
|
||||
val triggeredAt: Long = System.currentTimeMillis(),
|
||||
|
||||
/** 卖出时间 */
|
||||
@Column(name = "sold_at")
|
||||
val soldAt: Long? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -264,8 +264,27 @@ enum class ErrorCode(
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新加密价差策略失败", "error.server.crypto_tail_strategy_update_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除加密价差策略失败", "error.server.crypto_tail_strategy_delete_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询加密价差策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
|
||||
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed"),
|
||||
|
||||
// 体育尾盘策略 (4730-4749)
|
||||
SPORTS_TAIL_STRATEGY_NOT_FOUND(4730, "体育尾盘策略不存在", "error.sports_tail_strategy_not_found"),
|
||||
SPORTS_TAIL_STRATEGY_ALREADY_FILLED(4731, "策略已成交", "error.sports_tail_strategy_already_filled"),
|
||||
SPORTS_TAIL_STRATEGY_ALREADY_SOLD(4732, "策略已卖出", "error.sports_tail_strategy_already_sold"),
|
||||
SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID(4733, "金额模式仅支持 FIXED 或 RATIO", "error.sports_tail_strategy_amount_mode_invalid"),
|
||||
SPORTS_TAIL_STRATEGY_PRICE_INVALID(4734, "触发价格无效", "error.sports_tail_strategy_price_invalid"),
|
||||
SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY(4735, "市场ID不能为空", "error.sports_tail_strategy_condition_id_empty"),
|
||||
|
||||
// 体育尾盘策略服务 (5630-5649)
|
||||
SERVER_SPORTS_TAIL_STRATEGY_CREATE_FAILED(5630, "创建体育尾盘策略失败", "error.server.sports_tail_strategy_create_failed"),
|
||||
SERVER_SPORTS_TAIL_STRATEGY_DELETE_FAILED(5631, "删除体育尾盘策略失败", "error.server.sports_tail_strategy_delete_failed"),
|
||||
SERVER_SPORTS_TAIL_STRATEGY_LIST_FETCH_FAILED(5632, "查询体育尾盘策略列表失败", "error.server.sports_tail_strategy_list_fetch_failed"),
|
||||
SERVER_SPORTS_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5633, "查询触发记录失败", "error.server.sports_tail_strategy_triggers_fetch_failed"),
|
||||
SERVER_SPORTS_TAIL_STRATEGY_SPORTS_FETCH_FAILED(5634, "查询体育类别失败", "error.server.sports_tail_strategy_sports_fetch_failed"),
|
||||
SERVER_SPORTS_TAIL_STRATEGY_MARKET_SEARCH_FAILED(5635, "搜索市场失败", "error.server.sports_tail_strategy_market_search_failed"),
|
||||
SERVER_SPORTS_TAIL_STRATEGY_MARKET_DETAIL_FAILED(5636, "查询市场详情失败", "error.server.sports_tail_strategy_market_detail_failed"),
|
||||
SERVER_SPORTS_TAIL_STRATEGY_BUY_FAILED(5637, "买入执行失败", "error.server.sports_tail_strategy_buy_failed"),
|
||||
SERVER_SPORTS_TAIL_STRATEGY_SELL_FAILED(5638, "卖出执行失败", "error.server.sports_tail_strategy_sell_failed");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 根据错误码查找枚举
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.wrbug.polymarketbot.event
|
||||
|
||||
import org.springframework.context.ApplicationEvent
|
||||
|
||||
/**
|
||||
* 体育尾盘策略变更事件
|
||||
* 当策略创建、删除、成交、卖出时发布此事件
|
||||
*/
|
||||
class SportsTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.SportsTailStrategy
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Repository
|
||||
interface SportsTailStrategyRepository : JpaRepository<SportsTailStrategy, Long> {
|
||||
|
||||
/** 查询所有策略 */
|
||||
fun findAllByOrderByCreatedAtDesc(): List<SportsTailStrategy>
|
||||
|
||||
/** 按账户查询 */
|
||||
fun findAllByAccountIdOrderByCreatedAtDesc(accountId: Long): List<SportsTailStrategy>
|
||||
|
||||
/** 按账户和 conditionId 查询 */
|
||||
fun findByAccountIdAndConditionId(accountId: Long, conditionId: String): SportsTailStrategy?
|
||||
|
||||
/** 按条件查询(用于列表筛选) */
|
||||
fun findAllByAccountId(accountId: Long): List<SportsTailStrategy>
|
||||
|
||||
/** 查询未成交的策略 */
|
||||
fun findAllByFilledFalse(): List<SportsTailStrategy>
|
||||
|
||||
/** 查询已成交但未卖出的策略 */
|
||||
fun findAllByFilledTrueAndSoldFalse(): List<SportsTailStrategy>
|
||||
|
||||
/** 按 conditionId 查询未完成的策略(未成交或已成交未卖出) */
|
||||
@Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND (s.filled = false OR s.sold = false)")
|
||||
fun findActiveByConditionId(@Param("conditionId") conditionId: String): List<SportsTailStrategy>
|
||||
|
||||
/** 按 conditionId 查询未成交的策略 */
|
||||
@Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND s.filled = false")
|
||||
fun findPendingByConditionId(@Param("conditionId") conditionId: String): List<SportsTailStrategy>
|
||||
|
||||
/** 按 conditionId 查询已成交但未卖出的策略(用于止盈止损监控) */
|
||||
@Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND s.filled = true AND s.sold = false")
|
||||
fun findFilledByConditionId(@Param("conditionId") conditionId: String): List<SportsTailStrategy>
|
||||
|
||||
/** 按 conditionId 查询已成交但未卖出且有止盈止损的策略 */
|
||||
@Query("SELECT s FROM SportsTailStrategy s WHERE s.conditionId = :conditionId AND s.filled = true AND s.sold = false AND (s.takeProfitPrice IS NOT NULL OR s.stopLossPrice IS NOT NULL)")
|
||||
fun findFilledWithStopByConditionId(@Param("conditionId") conditionId: String): List<SportsTailStrategy>
|
||||
|
||||
/** 按账户统计总盈亏 */
|
||||
@Query("SELECT SUM(s.realizedPnl) FROM SportsTailStrategy s WHERE s.accountId = :accountId AND s.sold = true")
|
||||
fun sumRealizedPnlByAccountId(@Param("accountId") accountId: Long): BigDecimal?
|
||||
|
||||
/** 按策略统计总盈亏 */
|
||||
@Query("SELECT SUM(t.realizedPnl) FROM SportsTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.sellStatus = 'SUCCESS'")
|
||||
fun sumRealizedPnlByStrategyId(@Param("strategyId") strategyId: Long): BigDecimal?
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.SportsTailStrategyTrigger
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface SportsTailStrategyTriggerRepository : JpaRepository<SportsTailStrategyTrigger, Long> {
|
||||
|
||||
/** 按策略ID查询(分页) */
|
||||
fun findAllByStrategyIdOrderByTriggeredAtDesc(strategyId: Long, pageable: Pageable): Page<SportsTailStrategyTrigger>
|
||||
|
||||
/** 按账户ID查询(分页) */
|
||||
fun findAllByAccountIdOrderByTriggeredAtDesc(accountId: Long, pageable: Pageable): Page<SportsTailStrategyTrigger>
|
||||
|
||||
/** 按账户ID和时间范围查询(分页) */
|
||||
fun findAllByAccountIdAndTriggeredAtBetweenOrderByTriggeredAtDesc(
|
||||
accountId: Long,
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
pageable: Pageable
|
||||
): Page<SportsTailStrategyTrigger>
|
||||
|
||||
/** 全局查询(分页) */
|
||||
fun findAllByOrderByTriggeredAtDesc(pageable: Pageable): Page<SportsTailStrategyTrigger>
|
||||
|
||||
/** 全局按时间范围查询(分页) */
|
||||
fun findAllByTriggeredAtBetweenOrderByTriggeredAtDesc(
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
pageable: Pageable
|
||||
): Page<SportsTailStrategyTrigger>
|
||||
|
||||
/** 按账户ID和买入状态查询 */
|
||||
fun findAllByAccountIdAndBuyStatusOrderByTriggeredAtDesc(
|
||||
accountId: Long,
|
||||
buyStatus: String,
|
||||
pageable: Pageable
|
||||
): Page<SportsTailStrategyTrigger>
|
||||
|
||||
/** 按账户ID和时间范围和买入状态查询 */
|
||||
fun findAllByAccountIdAndBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc(
|
||||
accountId: Long,
|
||||
buyStatus: String,
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
pageable: Pageable
|
||||
): Page<SportsTailStrategyTrigger>
|
||||
|
||||
/** 统计总数 */
|
||||
fun countByAccountId(accountId: Long): Long
|
||||
|
||||
fun countByAccountIdAndBuyStatus(accountId: Long, buyStatus: String): Long
|
||||
|
||||
fun countByAccountIdAndTriggeredAtBetween(accountId: Long, startTime: Long, endTime: Long): Long
|
||||
|
||||
fun countByAccountIdAndBuyStatusAndTriggeredAtBetween(
|
||||
accountId: Long,
|
||||
buyStatus: String,
|
||||
startTime: Long,
|
||||
endTime: Long
|
||||
): Long
|
||||
|
||||
fun countByTriggeredAtBetween(startTime: Long, endTime: Long): Long
|
||||
|
||||
fun countByBuyStatusAndTriggeredAtBetween(buyStatus: String, startTime: Long, endTime: Long): Long
|
||||
|
||||
/** 全局按买入状态查询(分页) */
|
||||
fun findAllByBuyStatusOrderByTriggeredAtDesc(
|
||||
buyStatus: String,
|
||||
pageable: Pageable
|
||||
): Page<SportsTailStrategyTrigger>
|
||||
|
||||
/** 全局按买入状态和时间范围查询(分页) */
|
||||
fun findAllByBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc(
|
||||
buyStatus: String,
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
pageable: Pageable
|
||||
): Page<SportsTailStrategyTrigger>
|
||||
|
||||
/** 全局统计 */
|
||||
fun countByBuyStatus(buyStatus: String): Long
|
||||
|
||||
/** 查询某策略最近一条买入成功的触发记录(用于卖出时更新) */
|
||||
fun findFirstByStrategyIdAndBuyStatusOrderByTriggeredAtDesc(
|
||||
strategyId: Long,
|
||||
buyStatus: String
|
||||
): SportsTailStrategyTrigger?
|
||||
}
|
||||
+23
-32
@@ -365,14 +365,14 @@ class AccountService(
|
||||
}
|
||||
|
||||
/**
|
||||
* Polymarket 代币批准检查:pUSD 需授权的 spender 合约地址(Polygon 主网)
|
||||
* Polymarket 代币批准检查:USDC.e 需授权的 spender 合约地址(Polygon 主网)
|
||||
* 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals
|
||||
* 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
|
||||
*/
|
||||
private val setupApprovalSpenders = mapOf(
|
||||
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
|
||||
"CTF_EXCHANGE" to "0xE111180000d2663C0091e4f400237545B87B996B", // 普通市场交易所
|
||||
"NEG_RISK_EXCHANGE" to "0xe2222d279d744050d28e00520010520000310F59", // 负风险市场交易所
|
||||
"CTF_EXCHANGE" to "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", // 普通市场交易所
|
||||
"NEG_RISK_EXCHANGE" to "0xC5d563A36AE78145C45a50134d48A1215220f80a", // 负风险市场交易所
|
||||
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
|
||||
)
|
||||
|
||||
@@ -941,7 +941,7 @@ class AccountService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包。
|
||||
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e。
|
||||
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
|
||||
*/
|
||||
suspend fun runWcolUnwrapForAllAccounts() {
|
||||
@@ -1244,9 +1244,22 @@ class AccountService(
|
||||
else -> "GTC"
|
||||
}
|
||||
|
||||
// GTC 和 FOK 订单的 expiration 必须为 "0"
|
||||
// 只有 GTD 订单才需要设置具体的过期时间
|
||||
val expiration = "0"
|
||||
|
||||
// 7. 解密私钥
|
||||
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)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
@@ -1256,7 +1269,10 @@ class AccountService(
|
||||
side = "SELL",
|
||||
price = sellPrice,
|
||||
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = expiration
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建并签名订单失败", e)
|
||||
@@ -1268,7 +1284,8 @@ class AccountService(
|
||||
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey, // API Key
|
||||
orderType = orderType
|
||||
orderType = orderType,
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
// 13. 解密 API 凭证并使用账户的API凭证创建订单
|
||||
@@ -1908,32 +1925,6 @@ class AccountService(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service
|
||||
|
||||
/**
|
||||
* WCOL 解包轮询任务
|
||||
* 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则执行解包。
|
||||
* 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则解包为 USDC.e。
|
||||
* 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。
|
||||
* 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。
|
||||
*/
|
||||
|
||||
+4
-21
@@ -408,25 +408,10 @@ class BacktestExecutionService(
|
||||
val cost = actualSellQuantity.multiply(position.avgPrice)
|
||||
val profitLoss = netAmount.subtract(cost)
|
||||
|
||||
// Bug #39 Fix: correctly reduce position quantity after sell
|
||||
// 更新余额和持仓
|
||||
currentBalance += netAmount
|
||||
val remainingQuantity = position.quantity - actualSellQuantity
|
||||
val remainingLeaderBuyQuantity = if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
|
||||
val totalQty = position.quantity
|
||||
val leaderReduction = actualSellQuantity.divide(
|
||||
totalQty, 8, java.math.RoundingMode.DOWN
|
||||
)
|
||||
(position.leaderBuyQuantity - leaderReduction).coerceAtLeast(BigDecimal.ZERO)
|
||||
} else {
|
||||
position.leaderBuyQuantity
|
||||
}
|
||||
if (remainingQuantity <= BigDecimal.ZERO) {
|
||||
if (position.quantity <= BigDecimal.ZERO) {
|
||||
positions.remove(positionKey)
|
||||
} else {
|
||||
positions[positionKey] = position.copy(
|
||||
quantity = remainingQuantity,
|
||||
leaderBuyQuantity = remainingLeaderBuyQuantity
|
||||
)
|
||||
}
|
||||
|
||||
// 记录交易到当前页列表
|
||||
@@ -647,8 +632,7 @@ class BacktestExecutionService(
|
||||
val settlementPrice = avgPrice
|
||||
|
||||
val settlementValue = quantity.multiply(settlementPrice)
|
||||
// Bug #39 Fix: profitLoss for closed settlement at avgPrice should be ~0
|
||||
val profitLoss = settlementValue.subtract(quantity.multiply(avgPrice))
|
||||
val profitLoss = settlementValue.negate()
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
@@ -718,8 +702,7 @@ class BacktestExecutionService(
|
||||
if (balance > peakBalance) {
|
||||
peakBalance = balance
|
||||
}
|
||||
// Bug #39 Fix: use current balance, not runningBalance from previous iteration
|
||||
val drawdown = peakBalance - balance
|
||||
val drawdown = peakBalance - runningBalance
|
||||
if (drawdown > maxDrawdown) {
|
||||
maxDrawdown = drawdown
|
||||
}
|
||||
|
||||
+6
-99
@@ -39,8 +39,8 @@ class BlockchainService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BlockchainService::class.java)
|
||||
|
||||
// pUSD 合约地址(Polygon 主网,Polymarket 使用 Polygon)
|
||||
private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
|
||||
// USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon)
|
||||
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
// Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户)
|
||||
// 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b
|
||||
@@ -56,7 +56,7 @@ class BlockchainService(
|
||||
// ConditionalTokens 合约地址(Polygon 主网)
|
||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
|
||||
// Neg Risk WrappedCollateral 合约地址(Polygon)
|
||||
// Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e)
|
||||
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||
|
||||
// 空集合ID(用于计算collectionId)
|
||||
@@ -812,11 +812,11 @@ class BlockchainService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 将代理钱包内的 WCOL 执行解包(解包后转入代理地址)
|
||||
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可执行解包后续资产处理。
|
||||
* 将代理钱包内的 WCOL 解包为 USDC.e(解包后转入代理地址)
|
||||
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可转为 USDC.e 以便显示/使用。
|
||||
*
|
||||
* Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute];
|
||||
* Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount)。
|
||||
* Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入 proxyAddress。
|
||||
*
|
||||
* @param privateKey 主钱包私钥
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理)
|
||||
@@ -855,99 +855,6 @@ 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 交易)
|
||||
*/
|
||||
|
||||
+9
-1
@@ -223,7 +223,15 @@ class PolymarketClobService(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建订单(已废弃,使用 createSignedOrder 代替)
|
||||
* @deprecated 使用 createSignedOrder 代替,需要签名的订单对象
|
||||
*/
|
||||
@Deprecated("使用 createSignedOrder 代替")
|
||||
suspend fun createOrder(request: CreateOrderRequest): Result<OrderResponse> {
|
||||
return Result.failure(UnsupportedOperationException("已废弃,请使用 createSignedOrder 方法"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建签名的订单
|
||||
* 注意:此方法需要完整的订单签名逻辑,当前为占位实现
|
||||
|
||||
+3
-67
@@ -45,9 +45,7 @@ object OnChainWsUtils {
|
||||
}
|
||||
|
||||
// 合约地址
|
||||
const val PUSD_CONTRACT = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // V2 pUSD
|
||||
const val USDC_CONTRACT = PUSD_CONTRACT // 默认使用 pUSD
|
||||
private val COLLATERAL_CONTRACTS = setOf(PUSD_CONTRACT.lowercase())
|
||||
const val USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
const val ERC1155_CONTRACT = "0x4d97dcd97ec945f40cf65f87097ace5ea0476045"
|
||||
const val ERC20_TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||
const val ERC1155_TRANSFER_SINGLE_TOPIC = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
|
||||
@@ -98,8 +96,8 @@ object OnChainWsUtils {
|
||||
val t0 = topics[0].lowercase()
|
||||
val data = log.get("data")?.asString ?: "0x"
|
||||
|
||||
// 抵押品 ERC20 Transfer(当前仅匹配 pUSD)
|
||||
if (address in COLLATERAL_CONTRACTS && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
|
||||
// USDC ERC20 Transfer
|
||||
if (address == USDC_CONTRACT.lowercase() && t0 == ERC20_TRANSFER_TOPIC && topics.size >= 3) {
|
||||
val from = topicToAddress(topics[1])
|
||||
val to = topicToAddress(topics[2])
|
||||
val value = hexToBigInt(data)
|
||||
@@ -151,42 +149,6 @@ object OnChainWsUtils {
|
||||
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 日志解析交易信息
|
||||
*/
|
||||
@@ -243,32 +205,6 @@ object OnChainWsUtils {
|
||||
asset = bestOutId
|
||||
sizeRaw = bestOutVal
|
||||
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 {
|
||||
// 无法判断交易方向
|
||||
logger.debug("无法判断交易方向: txHash=$txHash, bestInId=$bestInId, bestInVal=$bestInVal, bestOutId=$bestOutId, bestOutVal=$bestOutVal, usdcOut=$usdcOut, usdcIn=$usdcIn")
|
||||
|
||||
+64
-44
@@ -41,9 +41,10 @@ class OrderSigningService {
|
||||
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
|
||||
}
|
||||
|
||||
// V2 合约地址
|
||||
private val EXCHANGE_CONTRACT = "0xE111180000d2663C0091e4f400237545B87B996B"
|
||||
private val NEG_RISK_EXCHANGE_CONTRACT = "0xe2222d279d744050d28e00520010520000310F59"
|
||||
// Polygon 主网合约地址(标准 CTF Exchange)
|
||||
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
|
||||
// Neg Risk CTF Exchange(neg risk 市场需用此合约签约,否则服务端返回 invalid signature)
|
||||
private val NEG_RISK_EXCHANGE_CONTRACT = "0xC5d563A36AE78145C45a50134d48A1215220f80a"
|
||||
private val CHAIN_ID = 137L
|
||||
|
||||
// USDC 有 6 位小数
|
||||
@@ -155,8 +156,8 @@ class OrderSigningService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并签名订单 (V2)
|
||||
*
|
||||
* 创建并签名订单
|
||||
*
|
||||
* @param privateKey 私钥(十六进制字符串)
|
||||
* @param makerAddress maker 地址(funder,通常是 proxyAddress)
|
||||
* @param tokenId token ID
|
||||
@@ -164,6 +165,9 @@ class OrderSigningService {
|
||||
* @param price 价格
|
||||
* @param size 数量
|
||||
* @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 Exchange,neg risk 市场需传 Neg Risk Exchange
|
||||
* @return 签名的订单对象
|
||||
*/
|
||||
@@ -174,7 +178,10 @@ class OrderSigningService {
|
||||
side: String,
|
||||
price: String,
|
||||
size: String,
|
||||
signatureType: Int = 2,
|
||||
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
|
||||
nonce: String = "0",
|
||||
feeRateBps: String = "0",
|
||||
expiration: String = "0",
|
||||
exchangeContract: String? = null
|
||||
): SignedOrderObject {
|
||||
try {
|
||||
@@ -182,32 +189,33 @@ class OrderSigningService {
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val credentials = Credentials.create(privateKeyBigInt.toString(16))
|
||||
// 统一转换为小写,确保与 EIP-712 编码时使用的地址格式一致
|
||||
// EIP-712 编码时地址会被转换为小写,所以订单对象中的地址也应该是小写
|
||||
val signerAddress = credentials.address.lowercase()
|
||||
|
||||
|
||||
// 2. 计算订单金额
|
||||
val amounts = calculateOrderAmounts(side, size, price)
|
||||
|
||||
// 3. 生成 salt 和 timestamp(V2: timestamp 替代 nonce 保证唯一性)
|
||||
|
||||
// 3. 生成 salt(使用时间戳,毫秒)
|
||||
val salt = generateSalt()
|
||||
val timestamp = System.currentTimeMillis().toString()
|
||||
|
||||
// 4. V2 字段默认值
|
||||
val metadata = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
val builder = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
|
||||
// 4. taker 地址(默认使用零地址)
|
||||
val taker = "0x0000000000000000000000000000000000000000"
|
||||
|
||||
// 5. 确保 maker 地址也是小写格式
|
||||
val makerAddressLower = makerAddress.lowercase()
|
||||
|
||||
logger.debug("========== 订单签名前参数 (V2) ==========")
|
||||
|
||||
// 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露)
|
||||
logger.debug("========== 订单签名前参数 ==========")
|
||||
logger.debug("订单方向: $side, 价格: $price, 数量: $size")
|
||||
logger.debug("Token ID: $tokenId")
|
||||
logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}")
|
||||
logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}")
|
||||
logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}")
|
||||
logger.debug("Salt: $salt, Timestamp: $timestamp")
|
||||
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
|
||||
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
|
||||
|
||||
// 6. 构建订单数据并签名
|
||||
|
||||
// 6. 构建订单数据并签名(neg risk 市场需用 NEG_RISK_EXCHANGE_CONTRACT)
|
||||
val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT
|
||||
val signature = signOrder(
|
||||
privateKey = privateKey,
|
||||
@@ -216,41 +224,44 @@ class OrderSigningService {
|
||||
salt = salt,
|
||||
maker = makerAddressLower,
|
||||
signer = signerAddress,
|
||||
taker = taker,
|
||||
tokenId = tokenId,
|
||||
makerAmount = amounts.makerAmount,
|
||||
takerAmount = amounts.takerAmount,
|
||||
expiration = expiration,
|
||||
nonce = nonce,
|
||||
feeRateBps = feeRateBps,
|
||||
side = side.uppercase(),
|
||||
signatureType = signatureType,
|
||||
timestamp = timestamp,
|
||||
metadata = metadata,
|
||||
builder = builder
|
||||
signatureType = signatureType
|
||||
)
|
||||
|
||||
// 7. 创建 V2 签名订单对象
|
||||
|
||||
// 7. 创建签名的订单对象
|
||||
// 注意:所有地址字段都使用小写格式,确保与签名时使用的地址一致
|
||||
return SignedOrderObject(
|
||||
salt = salt,
|
||||
maker = makerAddressLower,
|
||||
signer = signerAddress,
|
||||
taker = "0x0000000000000000000000000000000000000000",
|
||||
taker = taker,
|
||||
tokenId = tokenId,
|
||||
makerAmount = amounts.makerAmount,
|
||||
takerAmount = amounts.takerAmount,
|
||||
expiration = expiration,
|
||||
nonce = nonce,
|
||||
feeRateBps = feeRateBps,
|
||||
side = side.uppercase(),
|
||||
signatureType = signatureType,
|
||||
timestamp = timestamp,
|
||||
expiration = "0",
|
||||
metadata = metadata,
|
||||
builder = builder,
|
||||
signature = signature
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建并签名订单失败 (V2)", e)
|
||||
throw RuntimeException("创建并签名订单失败 (V2): ${e.message}", e)
|
||||
logger.error("创建并签名订单失败", e)
|
||||
throw RuntimeException("创建并签名订单失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签名订单 V2(EIP-712)
|
||||
* 签名订单(EIP-712)
|
||||
*
|
||||
* 参考: @polymarket/order-utils 的 ExchangeOrderBuilder
|
||||
*/
|
||||
private fun signOrder(
|
||||
privateKey: String,
|
||||
@@ -259,47 +270,56 @@ class OrderSigningService {
|
||||
salt: Long,
|
||||
maker: String,
|
||||
signer: String,
|
||||
taker: String,
|
||||
tokenId: String,
|
||||
makerAmount: String,
|
||||
takerAmount: String,
|
||||
expiration: String,
|
||||
nonce: String,
|
||||
feeRateBps: String,
|
||||
side: String,
|
||||
signatureType: Int,
|
||||
timestamp: String,
|
||||
metadata: String,
|
||||
builder: String
|
||||
signatureType: Int
|
||||
): String {
|
||||
try {
|
||||
// 1. 私钥与密钥对
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val credentials = Credentials.create(privateKeyBigInt.toString(16))
|
||||
val ecKeyPair = credentials.ecKeyPair
|
||||
|
||||
// 2. 编码域分隔符(verifyingContract 显式小写,与 EIP-712 约定一致)
|
||||
val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain(
|
||||
chainId = chainId,
|
||||
verifyingContract = exchangeContract.lowercase()
|
||||
)
|
||||
|
||||
// 3. 编码订单消息哈希
|
||||
// signatureType:1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA
|
||||
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
|
||||
salt = salt,
|
||||
maker = maker,
|
||||
signer = signer,
|
||||
taker = taker,
|
||||
tokenId = tokenId,
|
||||
makerAmount = makerAmount,
|
||||
takerAmount = takerAmount,
|
||||
expiration = expiration,
|
||||
nonce = nonce,
|
||||
feeRateBps = feeRateBps,
|
||||
side = side,
|
||||
signatureType = signatureType,
|
||||
timestamp = timestamp,
|
||||
metadata = metadata,
|
||||
builder = builder
|
||||
signatureType = signatureType
|
||||
)
|
||||
|
||||
// 4. 计算完整 EIP-712 结构化数据哈希
|
||||
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
|
||||
domainSeparator = domainSeparator,
|
||||
messageHash = orderHash
|
||||
)
|
||||
|
||||
// 5. 使用私钥签名(needToHash=false,对 32 字节 hash 直接签名)
|
||||
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 sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
|
||||
val vBytes = signature.v
|
||||
@@ -308,8 +328,8 @@ class OrderSigningService {
|
||||
|
||||
return "0x$rHex$sHex$vHex"
|
||||
} catch (e: Exception) {
|
||||
logger.error("订单签名失败 (V2)", e)
|
||||
throw RuntimeException("订单签名失败 (V2): ${e.message}", e)
|
||||
logger.error("订单签名失败", e)
|
||||
throw RuntimeException("订单签名失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-3
@@ -562,7 +562,16 @@ open class CopyOrderTrackingService(
|
||||
// 解密私钥
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity")
|
||||
// 获取费率(根据 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"
|
||||
}
|
||||
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps")
|
||||
|
||||
// Neg Risk 市场需用 Neg Risk Exchange 签约,否则服务端返回 invalid signature
|
||||
val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true
|
||||
@@ -585,6 +594,7 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id,
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
@@ -1008,6 +1018,15 @@ open class CopyOrderTrackingService(
|
||||
// 8. 解密私钥(在方法开始时解密一次,后续复用)
|
||||
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 签约
|
||||
val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true
|
||||
val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell)
|
||||
@@ -1023,6 +1042,9 @@ open class CopyOrderTrackingService(
|
||||
price = sellPrice.toString(),
|
||||
size = totalMatched.toString(),
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0",
|
||||
exchangeContract = exchangeContractSell
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -1036,7 +1058,8 @@ open class CopyOrderTrackingService(
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey,
|
||||
orderType = "FAK" // Fill-And-Kill
|
||||
orderType = "FAK", // Fill-And-Kill
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||
@@ -1061,6 +1084,7 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id,
|
||||
tradeId = leaderSellTrade.id,
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
@@ -1155,6 +1179,7 @@ open class CopyOrderTrackingService(
|
||||
* @param owner API Key(用于owner字段)
|
||||
* @param copyTradingId 跟单配置ID(用于日志)
|
||||
* @param tradeId Leader 交易ID(用于日志)
|
||||
* @param feeRateBps 费率基点(从API动态获取)
|
||||
* @param signatureType 签名类型(1=Magic, 2=Safe)
|
||||
* @return 成功返回订单ID,失败返回异常
|
||||
*/
|
||||
@@ -1171,6 +1196,7 @@ open class CopyOrderTrackingService(
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String,
|
||||
feeRateBps: String,
|
||||
signatureType: Int
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
@@ -1187,6 +1213,9 @@ open class CopyOrderTrackingService(
|
||||
price = price,
|
||||
size = size,
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0",
|
||||
exchangeContract = exchangeContract
|
||||
)
|
||||
|
||||
@@ -1203,7 +1232,8 @@ open class CopyOrderTrackingService(
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = owner,
|
||||
orderType = "FAK" // Fill-And-Kill
|
||||
orderType = "FAK", // Fill-And-Kill
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
// 调用 API 创建订单
|
||||
|
||||
+2
-4
@@ -815,11 +815,9 @@ class OrderStatusUpdateService(
|
||||
val actualPrice = orderDetail.price?.toSafeBigDecimal() ?: order.price
|
||||
val actualSize = orderDetail.originalSize?.toSafeBigDecimal() ?: order.quantity
|
||||
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 || actualCreatedAt != order.createdAt
|
||||
val needUpdate = actualPrice != order.price || actualSize != order.quantity
|
||||
|
||||
// 先保存更新后的订单,标记 notificationSent = true
|
||||
// 这样可以防止其他并发任务重复发送通知
|
||||
@@ -840,7 +838,7 @@ class OrderStatusUpdateService(
|
||||
status = order.status,
|
||||
notificationSent = true, // 标记为已发送通知
|
||||
source = order.source, // 保留原始订单来源
|
||||
createdAt = actualCreatedAt,
|
||||
createdAt = order.createdAt,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ class CryptoTailOrderbookWsService(
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
|
||||
private val client = createClient().build()
|
||||
private val client by lazy { createClient().build() }
|
||||
|
||||
/** 订阅成功后设置的倒计时 Job,在周期结束时自动刷新订阅 */
|
||||
private var periodEndCountdownJob: Job? = null
|
||||
|
||||
+56
-51
@@ -46,9 +46,6 @@ private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02"
|
||||
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
||||
private const val SIZE_DECIMAL_SCALE = 2
|
||||
|
||||
/** 单笔下单最小 USDC 金额(平台限制),RATIO 模式计算值低于此值时按此值下单 */
|
||||
private val MIN_ORDER_USDC = BigDecimal("1")
|
||||
|
||||
/**
|
||||
* 周期内预置上下文:账户、解密凭证、费率、签名类型、CLOB 客户端;不含预签订单。
|
||||
* 触发时 FIXED/RATIO 均按 outcomeIndex 计算 size 并签名提交。
|
||||
@@ -61,6 +58,7 @@ private data class PeriodContext(
|
||||
val apiSecretDecrypted: String,
|
||||
val apiPassphraseDecrypted: String,
|
||||
val clobApi: PolymarketClobApi,
|
||||
val feeRateByTokenId: Map<String, String>,
|
||||
val signatureType: Int,
|
||||
val tokenIds: List<String>,
|
||||
val marketTitle: String?
|
||||
@@ -151,9 +149,12 @@ class CryptoTailStrategyExecutionService(
|
||||
}
|
||||
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val feeRateByTokenId = tokenIds.associate { tokenId ->
|
||||
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
|
||||
}
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < MIN_ORDER_USDC) return null
|
||||
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < BigDecimal("1")) return null
|
||||
|
||||
val ctx = PeriodContext(
|
||||
strategy = strategy,
|
||||
@@ -163,6 +164,7 @@ class CryptoTailStrategyExecutionService(
|
||||
apiSecretDecrypted = apiSecret,
|
||||
apiPassphraseDecrypted = apiPassphrase,
|
||||
clobApi = clobApi,
|
||||
feeRateByTokenId = feeRateByTokenId,
|
||||
signatureType = signatureType,
|
||||
tokenIds = tokenIds,
|
||||
marketTitle = marketTitle
|
||||
@@ -335,36 +337,29 @@ class CryptoTailStrategyExecutionService(
|
||||
val ctx = getOrInvalidatePeriodContext(strategy, periodStartUnix)
|
||||
|
||||
if (ctx != null) {
|
||||
var availableBalanceForRatio = BigDecimal.ZERO
|
||||
var amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> {
|
||||
val balanceResult = accountService.getAccountBalance(ctx.account.id)
|
||||
val availableBalance =
|
||||
balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
availableBalanceForRatio = availableBalance
|
||||
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
else -> strategy.amountValue
|
||||
}
|
||||
if (amountUsdc < MIN_ORDER_USDC) {
|
||||
val amountMode = strategy.amountMode.uppercase()
|
||||
if (amountMode == "RATIO" && availableBalanceForRatio >= MIN_ORDER_USDC) {
|
||||
amountUsdc = MIN_ORDER_USDC
|
||||
} else {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
"投入金额不足"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (amountUsdc < BigDecimal("1")) {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
"投入金额不足"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
@@ -392,6 +387,7 @@ class CryptoTailStrategyExecutionService(
|
||||
}
|
||||
val priceStr = price.toPlainString()
|
||||
val size = computeSize(amountUsdc, price)
|
||||
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = ctx.decryptedPrivateKey,
|
||||
makerAddress = ctx.account.proxyAddress,
|
||||
@@ -399,12 +395,16 @@ class CryptoTailStrategyExecutionService(
|
||||
side = "BUY",
|
||||
price = priceStr,
|
||||
size = size,
|
||||
signatureType = ctx.signatureType
|
||||
signatureType = ctx.signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = ctx.account.apiKey!!,
|
||||
orderType = "FAK"
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
submitOrderAndSaveRecord(
|
||||
ctx.clobApi,
|
||||
@@ -521,28 +521,23 @@ class CryptoTailStrategyExecutionService(
|
||||
|
||||
val balanceResult = accountService.getAccountBalance(account.id)
|
||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
var amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||
else -> strategy.amountValue
|
||||
}
|
||||
if (amountUsdc < MIN_ORDER_USDC) {
|
||||
val amountMode = strategy.amountMode.uppercase()
|
||||
if (amountMode == "RATIO" && availableBalance >= MIN_ORDER_USDC) {
|
||||
amountUsdc = MIN_ORDER_USDC
|
||||
} else {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
"投入金额不足"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (amountUsdc < BigDecimal("1")) {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
"投入金额不足"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
@@ -599,6 +594,7 @@ class CryptoTailStrategyExecutionService(
|
||||
""
|
||||
}
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
@@ -608,12 +604,16 @@ class CryptoTailStrategyExecutionService(
|
||||
side = "BUY",
|
||||
price = priceStr,
|
||||
size = size,
|
||||
signatureType = signatureType
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!,
|
||||
orderType = "FAK"
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
submitOrderAndSaveRecord(
|
||||
clobApi,
|
||||
@@ -702,7 +702,7 @@ class CryptoTailStrategyExecutionService(
|
||||
|
||||
val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP)
|
||||
if (amountUsdc < BigDecimal.ONE) {
|
||||
return Result.failure(IllegalArgumentException("总金额不能少于 \$1"))
|
||||
return Result.failure(IllegalArgumentException("总金额不能少于 1 USDC"))
|
||||
}
|
||||
|
||||
val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix)
|
||||
@@ -730,6 +730,7 @@ class CryptoTailStrategyExecutionService(
|
||||
|
||||
val priceStr = priceRounded.toPlainString()
|
||||
val sizeStr = size.toPlainString()
|
||||
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = ctx.decryptedPrivateKey,
|
||||
@@ -738,13 +739,17 @@ class CryptoTailStrategyExecutionService(
|
||||
side = "BUY",
|
||||
price = priceStr,
|
||||
size = sizeStr,
|
||||
signatureType = ctx.signatureType
|
||||
signatureType = ctx.signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = ctx.account.apiKey!!,
|
||||
orderType = "FAK"
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
val orderResult = submitOrderForManualOrder(
|
||||
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package com.wrbug.polymarketbot.service.sportstail
|
||||
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.entity.SportsTailStrategy
|
||||
import com.wrbug.polymarketbot.event.SportsTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.repository.SportsTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.gte
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.lte
|
||||
import com.wrbug.polymarketbot.util.toJson
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonPrimitive
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Service
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 体育尾盘策略订单簿 WebSocket 服务:订阅 CLOB 市场频道,价格达到触发价时执行买入/止盈止损卖出。
|
||||
*/
|
||||
@Service
|
||||
class SportsTailOrderbookWsService(
|
||||
private val strategyRepository: SportsTailStrategyRepository,
|
||||
private val executionService: SportsTailStrategyExecutionService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(SportsTailOrderbookWsService::class.java)
|
||||
|
||||
private val scopeJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + scopeJob)
|
||||
|
||||
/** tokenId -> list of (strategy, outcomeIndex for buy=0/1, isSellPhase) */
|
||||
private val tokenToEntries = AtomicReference<Map<String, List<WsEntry>>>(emptyMap())
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
|
||||
private val client: OkHttpClient by lazy { createClient().build() }
|
||||
|
||||
private val reconnectDelayMs = 3_000L
|
||||
private val closedForNoStrategies = AtomicBoolean(false)
|
||||
private val connectLock = Any()
|
||||
private val refreshLock = Any()
|
||||
private val isRefreshing = AtomicBoolean(false)
|
||||
|
||||
private data class WsEntry(
|
||||
val strategy: SportsTailStrategy,
|
||||
val outcomeIndex: Int,
|
||||
val isSellPhase: Boolean
|
||||
)
|
||||
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
if (hasActiveStrategies()) connect()
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
closedForNoStrategies.set(true)
|
||||
try {
|
||||
webSocket?.close(1000, "shutdown")
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭体育尾盘 WebSocket 时异常: ${e.message}")
|
||||
}
|
||||
webSocket = null
|
||||
scopeJob.cancel()
|
||||
}
|
||||
|
||||
private fun hasActiveStrategies(): Boolean {
|
||||
val all = strategyRepository.findAll()
|
||||
return all.any { !it.filled || (it.filled && !it.sold && (it.takeProfitPrice != null || it.stopLossPrice != null)) }
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
synchronized(connectLock) {
|
||||
if (webSocket != null) return
|
||||
try {
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
logger.info("体育尾盘策略订单簿 WebSocket 已连接")
|
||||
refreshAndSubscribe(fromConnect = true)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleMessage(text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
this@SportsTailOrderbookWsService.webSocket = null
|
||||
if (!closedForNoStrategies.getAndSet(false)) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
logger.warn("体育尾盘策略订单簿 WebSocket 异常: ${t.message}")
|
||||
this@SportsTailOrderbookWsService.webSocket = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
logger.error("体育尾盘策略订单簿 WebSocket 连接失败: ${e.message}", e)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
delay(reconnectDelayMs)
|
||||
reconnectJob = null
|
||||
if (!hasActiveStrategies()) return@launch
|
||||
logger.info("体育尾盘策略订单簿 WebSocket 尝试重连")
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(text: String) {
|
||||
if (text == "pong" || text.isEmpty()) return
|
||||
if (closedForNoStrategies.get()) return
|
||||
val json = text.fromJson<JsonObject>() ?: return
|
||||
val eventType = (json.get("event_type") as? JsonPrimitive)?.asString ?: return
|
||||
|
||||
when (eventType) {
|
||||
"book" -> {
|
||||
val assetId = (json.get("asset_id") as? JsonPrimitive)?.asString ?: return
|
||||
val bids = json.get("bids") as? JsonArray
|
||||
if (bids == null || bids.isEmpty) return
|
||||
var bestBid: BigDecimal? = null
|
||||
for (i in 0 until bids.size()) {
|
||||
val level = bids.get(i) as? JsonObject ?: continue
|
||||
val p = (level.get("price") as? JsonPrimitive)?.asString?.toSafeBigDecimal() ?: continue
|
||||
if (bestBid == null || p.gt(bestBid)) bestBid = p
|
||||
}
|
||||
if (bestBid != null) onPriceUpdate(assetId, bestBid)
|
||||
}
|
||||
"price_change" -> {
|
||||
val priceChanges = json.get("price_changes") as? JsonArray ?: return
|
||||
for (i in 0 until priceChanges.size()) {
|
||||
val pc = priceChanges.get(i) as? JsonObject ?: continue
|
||||
val assetId = (pc.get("asset_id") as? JsonPrimitive)?.asString ?: continue
|
||||
val bestBidStr = (pc.get("best_bid") as? JsonPrimitive)?.asString
|
||||
val bestBid = bestBidStr?.toSafeBigDecimal()
|
||||
if (bestBid != null) onPriceUpdate(assetId, bestBid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPriceUpdate(tokenId: String, bestBid: BigDecimal) {
|
||||
if (closedForNoStrategies.get()) return
|
||||
val entries = tokenToEntries.get()[tokenId] ?: return
|
||||
for (e in entries) {
|
||||
scope.launch {
|
||||
try {
|
||||
if (e.isSellPhase) {
|
||||
checkSellTrigger(e.strategy, bestBid)
|
||||
} else {
|
||||
checkBuyTrigger(e.strategy, e.outcomeIndex, bestBid)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
logger.error("体育尾盘 WS 处理异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkBuyTrigger(strategy: SportsTailStrategy, outcomeIndex: Int, price: BigDecimal) {
|
||||
if (strategy.filled) return
|
||||
if (price.gte(strategy.triggerPrice)) {
|
||||
executionService.executeBuy(strategy, outcomeIndex, price)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkSellTrigger(strategy: SportsTailStrategy, currentPrice: BigDecimal) {
|
||||
if (!strategy.filled || strategy.sold) return
|
||||
strategy.takeProfitPrice?.let { if (currentPrice.gte(it)) { executionService.executeSell(strategy, "TAKE_PROFIT", currentPrice); return } }
|
||||
strategy.stopLossPrice?.let { if (currentPrice.lte(it)) { executionService.executeSell(strategy, "STOP_LOSS", currentPrice); return } }
|
||||
}
|
||||
|
||||
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
|
||||
synchronized(refreshLock) {
|
||||
if (isRefreshing.get()) return
|
||||
isRefreshing.set(true)
|
||||
}
|
||||
try {
|
||||
val strategies = strategyRepository.findAll()
|
||||
val active = strategies.filter { s ->
|
||||
!s.filled || (s.filled && !s.sold && (s.takeProfitPrice != null || s.stopLossPrice != null))
|
||||
}
|
||||
val tokenIdSet = mutableSetOf<String>()
|
||||
val map = mutableMapOf<String, MutableList<WsEntry>>()
|
||||
|
||||
for (s in active) {
|
||||
if (!s.filled) {
|
||||
s.yesTokenId?.let { id ->
|
||||
if (id.isNotBlank()) {
|
||||
tokenIdSet.add(id)
|
||||
map.getOrPut(id) { mutableListOf() }.add(WsEntry(s, 0, false))
|
||||
}
|
||||
}
|
||||
s.noTokenId?.let { id ->
|
||||
if (id.isNotBlank()) {
|
||||
tokenIdSet.add(id)
|
||||
map.getOrPut(id) { mutableListOf() }.add(WsEntry(s, 1, false))
|
||||
}
|
||||
}
|
||||
} else if (!s.sold && (s.takeProfitPrice != null || s.stopLossPrice != null)) {
|
||||
val idx = s.filledOutcomeIndex ?: continue
|
||||
val tokenId = if (idx == 0) s.yesTokenId else s.noTokenId
|
||||
tokenId?.takeIf { it.isNotBlank() }?.let { id ->
|
||||
tokenIdSet.add(id)
|
||||
map.getOrPut(id) { mutableListOf() }.add(WsEntry(s, idx, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokenToEntries.set(map)
|
||||
|
||||
if (tokenIdSet.isEmpty()) {
|
||||
closeForNoStrategies()
|
||||
return
|
||||
}
|
||||
if (!fromConnect) {
|
||||
if (webSocket == null) {
|
||||
connect()
|
||||
return
|
||||
}
|
||||
closeAndReconnect()
|
||||
return
|
||||
}
|
||||
val msg = """{"type":"MARKET","assets_ids":${tokenIdSet.toList().toJson()}}"""
|
||||
try {
|
||||
webSocket?.send(msg)
|
||||
logger.info("体育尾盘策略订单簿订阅: ${tokenIdSet.size} 个 token")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送体育尾盘订阅失败: ${e.message}")
|
||||
}
|
||||
} finally {
|
||||
isRefreshing.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeAndReconnect() {
|
||||
val ws = webSocket
|
||||
if (ws != null) {
|
||||
webSocket = null
|
||||
try { ws.close(1000, "subscription_change") } catch (e: Exception) { }
|
||||
logger.info("体育尾盘策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeForNoStrategies() {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
val ws = webSocket
|
||||
if (ws != null) {
|
||||
closedForNoStrategies.set(true)
|
||||
webSocket = null
|
||||
try { ws.close(1000, "no_active_strategies") } catch (e: Exception) { }
|
||||
logger.info("体育尾盘策略订单簿 WebSocket 已关闭(无活跃策略)")
|
||||
}
|
||||
}
|
||||
|
||||
@EventListener
|
||||
fun onStrategyChanged(event: SportsTailStrategyChangedEvent) {
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
package com.wrbug.polymarketbot.service.sportstail
|
||||
|
||||
import com.wrbug.polymarketbot.api.NewOrderRequest
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.entity.SportsTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.SportsTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.event.SportsTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.SportsTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.SportsTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private const val SIZE_DECIMAL_SCALE = 2
|
||||
|
||||
/**
|
||||
* 体育尾盘策略执行服务:根据价格触发执行买入/卖出,并更新策略与触发记录。
|
||||
*/
|
||||
@Service
|
||||
class SportsTailStrategyExecutionService(
|
||||
private val strategyRepository: SportsTailStrategyRepository,
|
||||
private val triggerRepository: SportsTailStrategyTriggerRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val accountService: AccountService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val orderSigningService: OrderSigningService,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(SportsTailStrategyExecutionService::class.java)
|
||||
|
||||
private val buyMutexMap = ConcurrentHashMap<Long, Mutex>()
|
||||
|
||||
private fun buyMutex(strategyId: Long): Mutex =
|
||||
buyMutexMap.getOrPut(strategyId) { Mutex() }
|
||||
|
||||
/**
|
||||
* 执行买入:市价买入指定方向,写入触发记录并更新策略为已成交。
|
||||
*/
|
||||
@Transactional
|
||||
suspend fun executeBuy(
|
||||
strategy: SportsTailStrategy,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal
|
||||
): Result<Unit> {
|
||||
if (strategy.filled) return Result.failure(IllegalStateException("策略已成交"))
|
||||
val tokenId = if (outcomeIndex == 0) strategy.yesTokenId else strategy.noTokenId
|
||||
if (tokenId.isNullOrBlank()) return Result.failure(IllegalStateException("Token ID 为空"))
|
||||
|
||||
return buyMutex(strategy.id!!).withLock {
|
||||
val latest = strategyRepository.findById(strategy.id!!).orElse(null)
|
||||
?: return@withLock Result.failure(IllegalStateException("策略不存在"))
|
||||
if (latest.filled) return@withLock Result.success(Unit)
|
||||
|
||||
val account = accountRepository.findById(latest.accountId).orElse(null)
|
||||
?: return@withLock Result.failure(IllegalStateException("账户不存在"))
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
return@withLock Result.failure(IllegalStateException("账户未配置 API 凭证"))
|
||||
}
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: return@withLock Result.failure(IllegalStateException("解密私钥失败"))
|
||||
} catch (e: Exception) {
|
||||
logger.error("解密私钥失败: accountId=${account.id}", e)
|
||||
return@withLock Result.failure(e)
|
||||
}
|
||||
val apiSecret = try { cryptoUtils.decrypt(account.apiSecret) ?: "" } catch (e: Exception) { "" }
|
||||
val apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase) ?: "" } catch (e: Exception) { "" }
|
||||
|
||||
val amountUsdc = when (latest.amountMode.uppercase()) {
|
||||
"RATIO" -> {
|
||||
val balanceResult = accountService.getAccountBalance(account.id!!)
|
||||
val available = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
available.multiply(latest.amountValue).div(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||
}
|
||||
else -> latest.amountValue
|
||||
}
|
||||
if (amountUsdc < BigDecimal("1")) {
|
||||
saveTriggerOnBuyFail(latest, outcomeIndex, triggerPrice, amountUsdc, "投入金额不足")
|
||||
return@withLock Result.failure(IllegalStateException("投入金额不足"))
|
||||
}
|
||||
|
||||
val priceStr = triggerPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()
|
||||
val size = amountUsdc.div(triggerPrice, SIZE_DECIMAL_SCALE, RoundingMode.UP).max(BigDecimal.ONE)
|
||||
val sizeStr = size.toPlainString()
|
||||
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = priceStr,
|
||||
size = sizeStr,
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
val response = clobApi.createOrder(orderRequest)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.success && body.orderId != null) {
|
||||
val outcomeName = if (outcomeIndex == 0) "Yes" else "No"
|
||||
triggerRepository.save(
|
||||
SportsTailStrategyTrigger(
|
||||
strategyId = latest.id!!,
|
||||
accountId = latest.accountId,
|
||||
conditionId = latest.conditionId,
|
||||
marketTitle = latest.marketTitle,
|
||||
buyPrice = triggerPrice,
|
||||
outcomeIndex = outcomeIndex,
|
||||
outcomeName = outcomeName,
|
||||
buyAmount = amountUsdc,
|
||||
buyShares = size,
|
||||
buyOrderId = body.orderId,
|
||||
buyStatus = "SUCCESS",
|
||||
triggeredAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
strategyRepository.save(
|
||||
latest.copy(
|
||||
filled = true,
|
||||
filledPrice = triggerPrice,
|
||||
filledOutcomeIndex = outcomeIndex,
|
||||
filledOutcomeName = outcomeName,
|
||||
filledAmount = amountUsdc,
|
||||
filledShares = size,
|
||||
filledAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this))
|
||||
logger.info("体育尾盘策略买入成功: strategyId=${latest.id}, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
}
|
||||
val failReason = response.body()?.getErrorMessage() ?: response.errorBody()?.string() ?: "下单失败"
|
||||
saveTriggerOnBuyFail(latest, outcomeIndex, triggerPrice, amountUsdc, failReason)
|
||||
logger.error("体育尾盘策略买入失败: strategyId=${latest.id}, reason=$failReason")
|
||||
Result.failure(IllegalStateException(failReason))
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveTriggerOnBuyFail(
|
||||
strategy: SportsTailStrategy,
|
||||
outcomeIndex: Int,
|
||||
buyPrice: BigDecimal,
|
||||
buyAmount: BigDecimal,
|
||||
failReason: String
|
||||
) {
|
||||
val outcomeName = if (outcomeIndex == 0) "Yes" else "No"
|
||||
triggerRepository.save(
|
||||
SportsTailStrategyTrigger(
|
||||
strategyId = strategy.id!!,
|
||||
accountId = strategy.accountId,
|
||||
conditionId = strategy.conditionId,
|
||||
marketTitle = strategy.marketTitle,
|
||||
buyPrice = buyPrice,
|
||||
outcomeIndex = outcomeIndex,
|
||||
outcomeName = outcomeName,
|
||||
buyAmount = buyAmount,
|
||||
buyStatus = "FAIL",
|
||||
buyFailReason = failReason,
|
||||
triggeredAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行卖出:按当前价市价卖出持仓,更新策略与触发记录。
|
||||
*/
|
||||
@Transactional
|
||||
suspend fun executeSell(
|
||||
strategy: SportsTailStrategy,
|
||||
sellType: String,
|
||||
currentPrice: BigDecimal
|
||||
): Result<Unit> {
|
||||
if (!strategy.filled || strategy.sold) return Result.failure(IllegalStateException("策略未成交或已卖出"))
|
||||
val outcomeIndex = strategy.filledOutcomeIndex ?: return Result.failure(IllegalStateException("无成交方向"))
|
||||
val tokenId = if (outcomeIndex == 0) strategy.yesTokenId else strategy.noTokenId
|
||||
val filledShares = strategy.filledShares ?: return Result.failure(IllegalStateException("无成交份额"))
|
||||
if (tokenId.isNullOrBlank()) return Result.failure(IllegalStateException("Token ID 为空"))
|
||||
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null)
|
||||
?: return Result.failure(IllegalStateException("账户不存在"))
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
return Result.failure(IllegalStateException("账户未配置 API 凭证"))
|
||||
}
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: return Result.failure(IllegalStateException("解密私钥失败"))
|
||||
} catch (e: Exception) {
|
||||
logger.error("解密私钥失败: accountId=${account.id}", e)
|
||||
return Result.failure(e)
|
||||
}
|
||||
val apiSecret = try { cryptoUtils.decrypt(account.apiSecret) ?: "" } catch (e: Exception) { "" }
|
||||
val apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase) ?: "" } catch (e: Exception) { "" }
|
||||
|
||||
val priceStr = currentPrice.setScale(2, RoundingMode.HALF_UP).toPlainString()
|
||||
val sizeStr = filledShares.setScale(SIZE_DECIMAL_SCALE, RoundingMode.DOWN).toPlainString()
|
||||
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "SELL",
|
||||
price = priceStr,
|
||||
size = sizeStr,
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
val response = clobApi.createOrder(orderRequest)
|
||||
val filledAmount = strategy.filledAmount ?: BigDecimal.ZERO
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.success && body.orderId != null) {
|
||||
val sellAmount = currentPrice.multiply(filledShares).setScale(2, RoundingMode.HALF_UP)
|
||||
val pnl = sellAmount.subtract(filledAmount)
|
||||
|
||||
strategyRepository.save(
|
||||
strategy.copy(
|
||||
sold = true,
|
||||
sellPrice = currentPrice,
|
||||
sellType = sellType,
|
||||
sellAmount = sellAmount,
|
||||
realizedPnl = pnl,
|
||||
soldAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
val trigger = triggerRepository.findFirstByStrategyIdAndBuyStatusOrderByTriggeredAtDesc(strategy.id!!, "SUCCESS")
|
||||
if (trigger != null) {
|
||||
triggerRepository.save(
|
||||
trigger.copy(
|
||||
sellPrice = currentPrice,
|
||||
sellType = sellType,
|
||||
sellAmount = sellAmount,
|
||||
sellOrderId = body.orderId,
|
||||
sellStatus = "SUCCESS",
|
||||
realizedPnl = pnl,
|
||||
soldAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this))
|
||||
logger.info("体育尾盘策略卖出成功: strategyId=${strategy.id}, sellType=$sellType, orderId=${body.orderId}")
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
val failReason = response.body()?.getErrorMessage() ?: response.errorBody()?.string() ?: "卖出失败"
|
||||
val trigger = triggerRepository.findFirstByStrategyIdAndBuyStatusOrderByTriggeredAtDesc(strategy.id!!, "SUCCESS")
|
||||
if (trigger != null) {
|
||||
triggerRepository.save(
|
||||
trigger.copy(
|
||||
sellStatus = "FAIL",
|
||||
sellFailReason = failReason
|
||||
)
|
||||
)
|
||||
}
|
||||
logger.error("体育尾盘策略卖出失败: strategyId=${strategy.id}, reason=$failReason")
|
||||
return Result.failure(IllegalStateException(failReason))
|
||||
}
|
||||
}
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
package com.wrbug.polymarketbot.service.sportstail
|
||||
|
||||
import com.wrbug.polymarketbot.api.MarketResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.SportsTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.SportsTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.event.SportsTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.SportsTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.SportsTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Service
|
||||
class SportsTailStrategyService(
|
||||
private val strategyRepository: SportsTailStrategyRepository,
|
||||
private val triggerRepository: SportsTailStrategyTriggerRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(SportsTailStrategyService::class.java)
|
||||
|
||||
companion object {
|
||||
private val SPORT_NAMES = mapOf(
|
||||
"nba" to "NBA",
|
||||
"nfl" to "NFL",
|
||||
"epl" to "英超",
|
||||
"lal" to "西甲",
|
||||
"mlb" to "MLB",
|
||||
"nhl" to "NHL",
|
||||
"ufc" to "UFC"
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(request: SportsTailStrategyCreateRequest): Result<SportsTailStrategyDto> {
|
||||
return try {
|
||||
if (request.accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey))
|
||||
}
|
||||
if (request.conditionId.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_CONDITION_ID_EMPTY.messageKey))
|
||||
}
|
||||
|
||||
val triggerPrice = request.triggerPrice.toSafeBigDecimal()
|
||||
if (triggerPrice <= BigDecimal.ZERO || triggerPrice >= BigDecimal.ONE) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_PRICE_INVALID.messageKey))
|
||||
}
|
||||
|
||||
val amountMode = request.amountMode.uppercase()
|
||||
if (amountMode != "FIXED" && amountMode != "RATIO") {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey))
|
||||
}
|
||||
|
||||
val amountValue = request.amountValue.toSafeBigDecimal()
|
||||
if (amountValue <= BigDecimal.ZERO) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
|
||||
val account = accountRepository.findById(request.accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException(ErrorCode.ACCOUNT_NOT_FOUND.messageKey))
|
||||
|
||||
val existing = strategyRepository.findByAccountIdAndConditionId(request.accountId, request.conditionId)
|
||||
if (existing != null) {
|
||||
return Result.failure(IllegalArgumentException("该市场已存在策略"))
|
||||
}
|
||||
|
||||
val takeProfitPrice = request.takeProfitPrice?.takeIf { it.isNotBlank() }?.toSafeBigDecimal()
|
||||
val stopLossPrice = request.stopLossPrice?.takeIf { it.isNotBlank() }?.toSafeBigDecimal()
|
||||
|
||||
val marketInfo = runBlocking { fetchMarketInfo(request.conditionId).getOrNull() }
|
||||
|
||||
val entity = SportsTailStrategy(
|
||||
accountId = request.accountId,
|
||||
conditionId = request.conditionId,
|
||||
marketTitle = request.marketTitle.takeIf { it.isNotBlank() } ?: marketInfo?.question,
|
||||
eventSlug = request.eventSlug ?: marketInfo?.eventSlug,
|
||||
yesTokenId = marketInfo?.yesTokenId,
|
||||
noTokenId = marketInfo?.noTokenId,
|
||||
triggerPrice = triggerPrice,
|
||||
amountMode = amountMode,
|
||||
amountValue = amountValue,
|
||||
takeProfitPrice = takeProfitPrice,
|
||||
stopLossPrice = stopLossPrice
|
||||
)
|
||||
val saved = strategyRepository.save(entity)
|
||||
eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this))
|
||||
Result.success(entityToDto(saved, account))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建体育尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun delete(id: Long): Result<Unit> {
|
||||
return try {
|
||||
val existing = strategyRepository.findById(id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException(ErrorCode.SPORTS_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
|
||||
if (existing.filled && !existing.sold) {
|
||||
return Result.failure(IllegalArgumentException("已成交未卖出的策略不能删除"))
|
||||
}
|
||||
|
||||
strategyRepository.deleteById(id)
|
||||
eventPublisher.publishEvent(SportsTailStrategyChangedEvent(this))
|
||||
Result.success(Unit)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除体育尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun list(request: SportsTailStrategyListRequest): Result<SportsTailStrategyListResponse> {
|
||||
return try {
|
||||
val list = when {
|
||||
request.accountId != null -> strategyRepository.findAllByAccountIdOrderByCreatedAtDesc(request.accountId)
|
||||
else -> strategyRepository.findAllByOrderByCreatedAtDesc()
|
||||
}
|
||||
|
||||
val accountIds = list.map { it.accountId }.distinct()
|
||||
val accountMap = accountRepository.findAllById(accountIds).associateBy { it.id }
|
||||
|
||||
val dtos = list.map { entityToDto(it, accountMap[it.accountId]) }
|
||||
Result.success(SportsTailStrategyListResponse(list = dtos))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询体育尾盘策略列表失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTriggers(request: SportsTailTriggerListRequest): Result<SportsTailTriggerListResponse> {
|
||||
return try {
|
||||
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
|
||||
val startTs = request.startTime ?: 0L
|
||||
val endTs = request.endTime ?: Long.MAX_VALUE
|
||||
val useTimeRange = request.startTime != null || request.endTime != null
|
||||
val useStatus = !request.status.isNullOrBlank()
|
||||
|
||||
val pageResult: Page<SportsTailStrategyTrigger> = when {
|
||||
request.accountId != null && useTimeRange && useStatus ->
|
||||
triggerRepository.findAllByAccountIdAndBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc(
|
||||
request.accountId, request.status!!, startTs, endTs, page
|
||||
)
|
||||
request.accountId != null && useTimeRange ->
|
||||
triggerRepository.findAllByAccountIdAndTriggeredAtBetweenOrderByTriggeredAtDesc(
|
||||
request.accountId, startTs, endTs, page
|
||||
)
|
||||
request.accountId != null && useStatus ->
|
||||
triggerRepository.findAllByAccountIdAndBuyStatusOrderByTriggeredAtDesc(
|
||||
request.accountId, request.status!!, page
|
||||
)
|
||||
request.accountId != null ->
|
||||
triggerRepository.findAllByAccountIdOrderByTriggeredAtDesc(request.accountId, page)
|
||||
useTimeRange && useStatus ->
|
||||
triggerRepository.findAllByBuyStatusAndTriggeredAtBetweenOrderByTriggeredAtDesc(
|
||||
request.status!!, startTs, endTs, page
|
||||
)
|
||||
useTimeRange ->
|
||||
triggerRepository.findAllByTriggeredAtBetweenOrderByTriggeredAtDesc(startTs, endTs, page)
|
||||
useStatus ->
|
||||
triggerRepository.findAllByBuyStatusOrderByTriggeredAtDesc(request.status!!, page)
|
||||
else ->
|
||||
triggerRepository.findAllByOrderByTriggeredAtDesc(page)
|
||||
}
|
||||
|
||||
val total = pageResult.totalElements
|
||||
val list = pageResult.content.map { triggerToDto(it) }
|
||||
Result.success(SportsTailTriggerListResponse(total = total, list = list))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSportsCategories(): Result<SportsCategoryListResponse> {
|
||||
return try {
|
||||
val api = retrofitFactory.createGammaSportsApi()
|
||||
val response = api.getSports()
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
val list = body.map { c -> categoryToDto(c) }
|
||||
Result.success(SportsCategoryListResponse(list = list))
|
||||
} else {
|
||||
logger.warn("获取体育类别失败: ${response.code()}")
|
||||
Result.failure(Exception("获取体育类别失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取体育类别失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchMarkets(request: SportsMarketSearchRequest): Result<SportsMarketSearchResponse> {
|
||||
return try {
|
||||
val api = retrofitFactory.createGammaSportsApi()
|
||||
|
||||
val tagId = if (!request.sport.isNullOrBlank()) {
|
||||
getTagIdBySport(request.sport)
|
||||
} else null
|
||||
|
||||
val response = api.searchMarkets(
|
||||
tagId = tagId,
|
||||
active = true,
|
||||
closed = false,
|
||||
limit = request.limit,
|
||||
order = "endDate",
|
||||
ascending = true,
|
||||
slug = request.keyword
|
||||
)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val markets = response.body()!!
|
||||
val filtered = if (!request.minLiquidity.isNullOrBlank()) {
|
||||
val minLiquidity = request.minLiquidity.toSafeBigDecimal()
|
||||
markets.filter { m ->
|
||||
val liquidity = m.liquidityNum?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
liquidity >= minLiquidity
|
||||
}
|
||||
} else {
|
||||
markets
|
||||
}
|
||||
val list = filtered.map { m -> marketToDto(m) }
|
||||
Result.success(SportsMarketSearchResponse(list = list))
|
||||
} else {
|
||||
logger.warn("搜索市场失败: ${response.code()}")
|
||||
Result.failure(Exception("搜索市场失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("搜索市场失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMarketDetail(conditionId: String): Result<SportsMarketDetailResponse> {
|
||||
return try {
|
||||
val marketInfo = fetchMarketInfo(conditionId).getOrNull()
|
||||
?: return Result.failure(Exception("市场不存在"))
|
||||
|
||||
Result.success(
|
||||
SportsMarketDetailResponse(
|
||||
conditionId = marketInfo.conditionId,
|
||||
question = marketInfo.question,
|
||||
outcomes = marketInfo.outcomes,
|
||||
outcomePrices = marketInfo.outcomePrices,
|
||||
endDate = marketInfo.endDate,
|
||||
liquidity = marketInfo.liquidity,
|
||||
bestBid = marketInfo.bestBid,
|
||||
bestAsk = marketInfo.bestAsk,
|
||||
yesTokenId = marketInfo.yesTokenId,
|
||||
noTokenId = marketInfo.noTokenId,
|
||||
eventSlug = marketInfo.eventSlug
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取市场详情失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMarketInfo(conditionId: String): Result<SportsMarketDto> {
|
||||
return try {
|
||||
val api = retrofitFactory.createGammaApi()
|
||||
val response = api.listMarkets(conditionIds = listOf(conditionId))
|
||||
if (response.isSuccessful && !response.body().isNullOrEmpty()) {
|
||||
val m = response.body()!![0]
|
||||
Result.success(marketResponseToDto(m))
|
||||
} else {
|
||||
Result.failure(Exception("市场不存在"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取市场信息失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getTagIdBySport(sport: String): Long? {
|
||||
return try {
|
||||
val api = retrofitFactory.createGammaSportsApi()
|
||||
val response = api.getSports()
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
val category = body.find { c -> c.sport == sport.lowercase() }
|
||||
category?.tags?.split(",")?.firstOrNull()?.toLongOrNull()
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||
return clobTokenIds.fromJson<List<String>>() ?: emptyList()
|
||||
}
|
||||
|
||||
private fun parseOutcomes(outcomes: String?): List<String> {
|
||||
if (outcomes.isNullOrBlank()) return emptyList()
|
||||
return outcomes.fromJson<List<String>>() ?: emptyList()
|
||||
}
|
||||
|
||||
private fun parseOutcomePrices(outcomePrices: String?): List<String> {
|
||||
if (outcomePrices.isNullOrBlank()) return emptyList()
|
||||
return outcomePrices.fromJson<List<String>>() ?: emptyList()
|
||||
}
|
||||
|
||||
private fun entityToDto(e: SportsTailStrategy, account: Account?): SportsTailStrategyDto {
|
||||
return SportsTailStrategyDto(
|
||||
id = e.id ?: 0L,
|
||||
accountId = e.accountId,
|
||||
accountName = account?.accountName ?: account?.walletAddress?.take(8),
|
||||
conditionId = e.conditionId,
|
||||
marketTitle = e.marketTitle,
|
||||
eventSlug = e.eventSlug,
|
||||
triggerPrice = e.triggerPrice.toPlainString(),
|
||||
amountMode = e.amountMode,
|
||||
amountValue = e.amountValue.toPlainString(),
|
||||
takeProfitPrice = e.takeProfitPrice?.toPlainString(),
|
||||
stopLossPrice = e.stopLossPrice?.toPlainString(),
|
||||
filled = e.filled,
|
||||
filledPrice = e.filledPrice?.toPlainString(),
|
||||
filledOutcomeIndex = e.filledOutcomeIndex,
|
||||
filledOutcomeName = e.filledOutcomeName,
|
||||
filledAmount = e.filledAmount?.toPlainString(),
|
||||
filledShares = e.filledShares?.toPlainString(),
|
||||
filledAt = e.filledAt,
|
||||
sold = e.sold,
|
||||
sellPrice = e.sellPrice?.toPlainString(),
|
||||
sellType = e.sellType,
|
||||
sellAmount = e.sellAmount?.toPlainString(),
|
||||
realizedPnl = e.realizedPnl?.toPlainString(),
|
||||
soldAt = e.soldAt,
|
||||
createdAt = e.createdAt,
|
||||
updatedAt = e.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
private fun categoryToDto(c: com.wrbug.polymarketbot.api.SportsCategoryResponse): SportsCategoryDto {
|
||||
val tagId = c.tags?.split(",")?.firstOrNull()?.toLongOrNull() ?: 0L
|
||||
return SportsCategoryDto(
|
||||
sport = c.sport ?: "",
|
||||
image = c.image,
|
||||
tagId = tagId,
|
||||
name = SPORT_NAMES[c.sport] ?: c.sport ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
private fun marketResponseToDto(m: MarketResponse): SportsMarketDto {
|
||||
val tokenIds = parseClobTokenIds(m.clobTokenIds ?: m.clob_token_ids)
|
||||
return SportsMarketDto(
|
||||
conditionId = m.conditionId ?: "",
|
||||
question = m.question ?: "",
|
||||
outcomes = parseOutcomes(m.outcomes),
|
||||
outcomePrices = parseOutcomePrices(m.outcomePrices),
|
||||
endDate = m.endDate,
|
||||
liquidity = m.liquidityNum?.toString() ?: m.liquidity,
|
||||
bestBid = m.bestBid,
|
||||
bestAsk = m.bestAsk,
|
||||
yesTokenId = tokenIds.getOrNull(0),
|
||||
noTokenId = tokenIds.getOrNull(1),
|
||||
eventSlug = m.events?.firstOrNull()?.slug
|
||||
)
|
||||
}
|
||||
|
||||
private fun marketToDto(m: com.wrbug.polymarketbot.api.SportsMarketResponse): SportsMarketDto {
|
||||
val tokenIds = parseClobTokenIds(m.clobTokenIds)
|
||||
return SportsMarketDto(
|
||||
conditionId = m.conditionId ?: "",
|
||||
question = m.question ?: "",
|
||||
outcomes = parseOutcomes(m.outcomes),
|
||||
outcomePrices = parseOutcomePrices(m.outcomePrices),
|
||||
endDate = m.endDate,
|
||||
liquidity = m.liquidityNum?.toString() ?: m.liquidity,
|
||||
bestBid = m.bestBid,
|
||||
bestAsk = m.bestAsk,
|
||||
yesTokenId = tokenIds.getOrNull(0),
|
||||
noTokenId = tokenIds.getOrNull(1),
|
||||
eventSlug = m.events?.firstOrNull()?.slug
|
||||
)
|
||||
}
|
||||
|
||||
private fun triggerToDto(t: SportsTailStrategyTrigger): SportsTailTriggerDto {
|
||||
return SportsTailTriggerDto(
|
||||
id = t.id ?: 0L,
|
||||
strategyId = t.strategyId,
|
||||
marketTitle = t.marketTitle,
|
||||
conditionId = t.conditionId,
|
||||
buyPrice = t.buyPrice.toPlainString(),
|
||||
outcomeIndex = t.outcomeIndex,
|
||||
outcomeName = t.outcomeName,
|
||||
buyAmount = t.buyAmount.toPlainString(),
|
||||
buyShares = t.buyShares?.toPlainString(),
|
||||
buyStatus = t.buyStatus,
|
||||
sellPrice = t.sellPrice?.toPlainString(),
|
||||
sellType = t.sellType,
|
||||
sellAmount = t.sellAmount?.toPlainString(),
|
||||
sellStatus = t.sellStatus,
|
||||
realizedPnl = t.realizedPnl?.toPlainString(),
|
||||
triggeredAt = t.triggeredAt,
|
||||
soldAt = t.soldAt
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -169,9 +169,9 @@ class NotificationTemplateService(
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>${'$'}{{amount}}</code>
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
• 可用余额: <code>${'$'}{{available_balance}}</code>
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
@@ -184,7 +184,7 @@ class NotificationTemplateService(
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>${'$'}{{amount}}</code>
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>错误信息:</b>
|
||||
@@ -201,7 +201,7 @@ class NotificationTemplateService(
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>${'$'}{{amount}}</code>
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>过滤类型:</b> <code>{{filter_type}}</code>
|
||||
@@ -222,7 +222,7 @@ class NotificationTemplateService(
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>${'$'}{{amount}}</code>
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
@@ -233,8 +233,8 @@ class NotificationTemplateService(
|
||||
📊 <b>赎回信息:</b>
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 赎回总价值: <code>${'$'}{{total_value}}</code>
|
||||
• 可用余额: <code>${'$'}{{available_balance}}</code>
|
||||
• 赎回总价值: <code>{{total_value}}</code> USDC
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
@@ -246,7 +246,7 @@ class NotificationTemplateService(
|
||||
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 可用余额: <code>${'$'}{{available_balance}}</code>
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent()
|
||||
|
||||
+48
-102
@@ -24,8 +24,7 @@ import java.util.concurrent.TimeUnit
|
||||
*/
|
||||
@Service
|
||||
class ProxyConfigService(
|
||||
private val proxyConfigRepository: ProxyConfigRepository,
|
||||
private val geoblockService: GeoblockService
|
||||
private val proxyConfigRepository: ProxyConfigRepository
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
@@ -149,153 +148,100 @@ class ProxyConfigService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理是否可用,并检测经该代理访问 Polymarket 时的地域限制
|
||||
* 检查代理是否可用
|
||||
* 使用配置的代理请求 Polymarket 健康检查接口
|
||||
*/
|
||||
fun checkProxy(): ProxyCheckResponse {
|
||||
return try {
|
||||
val config = proxyConfigRepository.findByEnabledTrue()
|
||||
if (config == null) {
|
||||
return ProxyCheckResponse.create(
|
||||
?: return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "未配置代理或代理未启用",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
message = "未配置代理或代理未启用"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (config.type != "HTTP") {
|
||||
return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (config.host == null || config.port == null) {
|
||||
return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理配置不完整:缺少主机或端口",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
message = "代理配置不完整:缺少主机或端口"
|
||||
)
|
||||
}
|
||||
|
||||
val client = buildProxyHttpClient(config)
|
||||
val geoblock = checkGeoblockForProxy(client)
|
||||
|
||||
|
||||
// 创建代理
|
||||
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))
|
||||
|
||||
// 创建 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()
|
||||
.url("https://data-api.polymarket.com/")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val response = client.newCall(request).execute()
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
|
||||
|
||||
val responseBody = response.body?.string()
|
||||
|
||||
|
||||
if (response.isSuccessful && responseBody != null) {
|
||||
// 检查响应内容是否为 {"data": "OK"}
|
||||
if (responseBody.contains("\"data\"") && responseBody.contains("OK")) {
|
||||
logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms")
|
||||
val message = buildProxyCheckMessage("代理连接成功", geoblock)
|
||||
ProxyCheckResponse.create(
|
||||
success = true,
|
||||
message = message,
|
||||
responseTime = responseTime,
|
||||
geoblock = geoblock
|
||||
message = "代理连接成功",
|
||||
responseTime = responseTime
|
||||
)
|
||||
} else {
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = buildProxyCheckMessage(
|
||||
"代理连接成功,但响应格式不正确:$responseBody",
|
||||
geoblock
|
||||
),
|
||||
responseTime = responseTime,
|
||||
geoblock = geoblock
|
||||
message = "代理连接成功,但响应格式不正确:$responseBody",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = buildProxyCheckMessage(
|
||||
"代理连接失败:HTTP ${response.code} ${response.message}",
|
||||
geoblock
|
||||
),
|
||||
responseTime = responseTime,
|
||||
geoblock = geoblock
|
||||
message = "代理连接失败:HTTP ${response.code} ${response.message}",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("代理检查异常", e)
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理检查失败:${e.message}",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
message = "代理检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除代理配置
|
||||
|
||||
+7
-59
@@ -39,14 +39,8 @@ class RelayClientService(
|
||||
// ConditionalTokens 合约地址
|
||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||
|
||||
// pUSD 合约地址(普通市场抵押品)
|
||||
private val usdcContractAddress = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB"
|
||||
|
||||
// USDC.e 合约地址(仅用于 wrap 到 pUSD)
|
||||
private val usdceContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
// CollateralOnramp 合约地址(USDC.e → pUSD)
|
||||
private val collateralOnrampAddress = "0x93070a847efEf7F70739046A929D47a521F5B8ee"
|
||||
// USDC.e 合约地址(普通市场抵押品)
|
||||
private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||
|
||||
// Neg Risk 市场使用的 WrappedCollateral 合约地址(Polygon,neg-risk-ctf-adapter)
|
||||
private val negRiskWrappedCollateralAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||
@@ -57,9 +51,7 @@ class RelayClientService(
|
||||
// Polygon PROXY(Magic)合约地址,参考 builder-relayer-client config
|
||||
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
||||
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
|
||||
// PROXY relayCall 内层 gasLimit(签名参数)不能给过大值,否则 RelayHub 会因 gasleft 校验失败回滚。
|
||||
private val defaultProxyGasLimit = "2400000"
|
||||
private val maxProxyGasLimit = BigInteger.valueOf(2400000)
|
||||
private val defaultProxyGasLimit = "10000000"
|
||||
|
||||
// Safe MultiSend 合约地址(Polygon 主网)
|
||||
private val safeMultisendAddress = "0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761"
|
||||
@@ -290,14 +282,14 @@ class RelayClientService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 WCOL 解包交易(将 Wrapped Collateral 执行解包)
|
||||
* 创建 WCOL 解包交易(将 Wrapped Collateral 解包为 USDC.e)
|
||||
* 合约: Neg Risk WrappedCollateral 0x3A3BD7bb9528E159577F7C2e685CC81A765002E2
|
||||
* 方法: unwrap(address _to, uint256 _amount)
|
||||
* 方法: unwrap(address _to, uint256 _amount),解包后 USDC.e 转到 _to
|
||||
*
|
||||
* Safe 与 Magic 共用此交易对象:Safe 走 [executeViaBuilderRelayer] / [executeManually](execTransaction),
|
||||
* Magic 走 [executeViaBuilderRelayerProxy](encodeProxyTransactionData),语义一致。
|
||||
*
|
||||
* @param toAddress 接收解包资产的地址(通常为 proxy 自身,使余额留在代理钱包)
|
||||
* @param toAddress 接收 USDC.e 的地址(通常为 proxy 自身,使余额留在代理钱包)
|
||||
* @param amountWei WCOL 数量(6 位小数对应的 raw 值,与 balanceOf 返回一致)
|
||||
* @return Safe 交易对象
|
||||
*/
|
||||
@@ -331,41 +323,6 @@ 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 为一笔交易)
|
||||
* 参考 TypeScript: builder-relayer-client/src/encode/safe.ts createSafeMultisendTransaction
|
||||
@@ -532,16 +489,7 @@ class RelayClientService(
|
||||
|
||||
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit)
|
||||
val gasLimit = try {
|
||||
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
|
||||
}
|
||||
estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
|
||||
defaultProxyGasLimit
|
||||
|
||||
+19
-17
@@ -33,11 +33,13 @@ class TelegramNotificationService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(TelegramNotificationService::class.java)
|
||||
|
||||
private val okHttpClient = createClient()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val okHttpClient by lazy {
|
||||
createClient()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val apiBaseUrl = "https://api.telegram.org/bot"
|
||||
|
||||
@@ -691,7 +693,7 @@ class TelegramNotificationService(
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>${'$'}$amountDisplay</code>
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
⚠️ <b>$filterTypeLabel:</b> <code>$filterTypeDisplay</code>
|
||||
@@ -1169,9 +1171,9 @@ class TelegramNotificationService(
|
||||
} else {
|
||||
balanceDecimal.stripTrailingZeros()
|
||||
}
|
||||
"\n• $availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
|
||||
"\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC"
|
||||
} catch (e: Exception) {
|
||||
"\n• $availableBalanceLabel: <code>${'$'}$availableBalance</code>"
|
||||
"\n• $availableBalanceLabel: <code>$availableBalance</code> USDC"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
@@ -1185,7 +1187,7 @@ class TelegramNotificationService(
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>${'$'}$amountDisplay</code>
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
@@ -1264,7 +1266,7 @@ class TelegramNotificationService(
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>${'$'}$amountDisplay</code>
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
@@ -1381,7 +1383,7 @@ class TelegramNotificationService(
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>${'$'}$amountDisplay</code>
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
⚠️ <b>$errorInfo:</b>
|
||||
@@ -1515,7 +1517,7 @@ class TelegramNotificationService(
|
||||
} catch (e: Exception) {
|
||||
position.value
|
||||
}
|
||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = ${'$'}$valueDisplay"
|
||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
|
||||
}
|
||||
|
||||
// 格式化可用余额
|
||||
@@ -1527,9 +1529,9 @@ class TelegramNotificationService(
|
||||
} else {
|
||||
balanceDecimal.stripTrailingZeros()
|
||||
}
|
||||
"\n• $availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
|
||||
"\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC"
|
||||
} catch (e: Exception) {
|
||||
"\n• $availableBalanceLabel: <code>${'$'}$availableBalance</code>"
|
||||
"\n• $availableBalanceLabel: <code>$availableBalance</code> USDC"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
@@ -1540,7 +1542,7 @@ class TelegramNotificationService(
|
||||
📊 <b>$redeemInfo:</b>
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
• $transactionHashLabel: <code>$escapedTxHash</code>
|
||||
• $totalValueLabel: <code>${'$'}$totalValueDisplay</code>$availableBalanceDisplay
|
||||
• $totalValueLabel: <code>$totalValueDisplay</code> USDC$availableBalanceDisplay
|
||||
|
||||
📦 <b>$positionsLabel:</b>
|
||||
$positionsText
|
||||
@@ -1642,9 +1644,9 @@ $positionsText
|
||||
} else {
|
||||
balanceDecimal.stripTrailingZeros()
|
||||
}
|
||||
"\n• $availableBalanceLabel: <code>${'$'}${formatted.toPlainString()}</code>"
|
||||
"\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC"
|
||||
} catch (e: Exception) {
|
||||
"\n• $availableBalanceLabel: <code>${'$'}$availableBalance</code>"
|
||||
"\n• $availableBalanceLabel: <code>$availableBalance</code> USDC"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
|
||||
@@ -167,8 +167,9 @@ object Eip712Encoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 ExchangeOrder V2 域分隔符
|
||||
* Domain: { name: "Polymarket CTF Exchange", version: "2", chainId: chainId, verifyingContract: exchangeContract }
|
||||
* 编码 ExchangeOrder 域分隔符
|
||||
* 参考: @polymarket/order-utils 的 ExchangeOrderBuilder
|
||||
* Domain: { name: "Polymarket CTF Exchange", version: "1", chainId: chainId, verifyingContract: exchangeContract }
|
||||
*/
|
||||
fun encodeExchangeDomain(
|
||||
chainId: Long,
|
||||
@@ -183,9 +184,9 @@ object Eip712Encoder {
|
||||
"verifyingContract" to "address"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
val nameHash = encodeString("Polymarket CTF Exchange")
|
||||
val versionHash = encodeString("2")
|
||||
val versionHash = encodeString("1")
|
||||
val chainIdBytes = encodeUint256(BigInteger.valueOf(chainId))
|
||||
val contractBytes = encodeAddress(verifyingContract)
|
||||
|
||||
@@ -200,21 +201,23 @@ object Eip712Encoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 ExchangeOrder V2 消息哈希
|
||||
* V2 Order: { salt, maker, signer, tokenId, makerAmount, takerAmount, side, signatureType, timestamp, metadata, builder }
|
||||
* 编码 ExchangeOrder 消息哈希
|
||||
* 参考: @polymarket/order-utils 的 ExchangeOrderBuilder
|
||||
* Order: { salt, maker, signer, taker, tokenId, makerAmount, takerAmount, expiration, nonce, feeRateBps, side, signatureType }
|
||||
*/
|
||||
fun encodeExchangeOrder(
|
||||
salt: Long,
|
||||
maker: String,
|
||||
signer: String,
|
||||
taker: String,
|
||||
tokenId: String,
|
||||
makerAmount: String,
|
||||
takerAmount: String,
|
||||
expiration: String,
|
||||
nonce: String,
|
||||
feeRateBps: String,
|
||||
side: String,
|
||||
signatureType: Int,
|
||||
timestamp: String,
|
||||
metadata: String,
|
||||
builder: String
|
||||
signatureType: Int
|
||||
): ByteArray {
|
||||
val orderTypeHash = encodeType(
|
||||
"Order",
|
||||
@@ -222,51 +225,57 @@ object Eip712Encoder {
|
||||
"salt" to "uint256",
|
||||
"maker" to "address",
|
||||
"signer" to "address",
|
||||
"taker" to "address",
|
||||
"tokenId" to "uint256",
|
||||
"makerAmount" to "uint256",
|
||||
"takerAmount" to "uint256",
|
||||
"expiration" to "uint256",
|
||||
"nonce" to "uint256",
|
||||
"feeRateBps" to "uint256",
|
||||
"side" to "uint8",
|
||||
"signatureType" to "uint8",
|
||||
"timestamp" to "uint256",
|
||||
"metadata" to "bytes32",
|
||||
"builder" to "bytes32"
|
||||
"signatureType" to "uint8"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
// 编码订单字段
|
||||
val saltBytes = encodeUint256(BigInteger.valueOf(salt))
|
||||
val makerBytes = encodeAddress(maker)
|
||||
val signerBytes = encodeAddress(signer)
|
||||
val takerBytes = encodeAddress(taker)
|
||||
val tokenIdBytes = encodeUint256(BigInteger(tokenId))
|
||||
val makerAmountBytes = encodeUint256(BigInteger(makerAmount))
|
||||
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()) {
|
||||
"BUY" -> 0
|
||||
"SELL" -> 1
|
||||
else -> throw IllegalArgumentException("side 必须是 BUY 或 SELL")
|
||||
}
|
||||
// uint8 类型,但 EIP-712 编码时仍需要 32 字节
|
||||
val sideBytes = encodeUint256(BigInteger.valueOf(sideValue.toLong()))
|
||||
val signatureTypeBytes = encodeUint256(BigInteger.valueOf(signatureType.toLong()))
|
||||
|
||||
val timestampBytes = encodeUint256(BigInteger(timestamp))
|
||||
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 个字段
|
||||
|
||||
// 组合所有字段
|
||||
val encoded = ByteArray(32 * 13) // 13 个字段,每个 32 字节
|
||||
var offset = 0
|
||||
System.arraycopy(orderTypeHash, 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(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(makerAmountBytes, 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(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)
|
||||
|
||||
System.arraycopy(signatureTypeBytes, 0, encoded, offset, 32)
|
||||
|
||||
return keccak256(encoded)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.math.BigInteger
|
||||
object EthereumUtils {
|
||||
|
||||
// Polymarket 合约地址(Polygon 主网)
|
||||
private val COLLATERAL_TOKEN_ADDRESS = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" // pUSD
|
||||
private val COLLATERAL_TOKEN_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" // USDC
|
||||
private val CONDITIONAL_TOKENS_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" // ConditionalTokens
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.api.GitHubApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaApi
|
||||
import com.wrbug.polymarketbot.api.PolymarketGammaSportsApi
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
@@ -360,6 +361,25 @@ class RetrofitFactory(
|
||||
fun createGitHubApi(): GitHubApi {
|
||||
return githubApi
|
||||
}
|
||||
|
||||
// 缓存 Gamma Sports API 客户端(单例)
|
||||
private val gammaSportsApi: PolymarketGammaSportsApi by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl(PolymarketConstants.GAMMA_BASE_URL)
|
||||
.client(sharedOkHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(PolymarketGammaSportsApi::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Polymarket Gamma Sports API 客户端
|
||||
* Gamma Sports API 是公开 API,不需要认证
|
||||
* @return PolymarketGammaSportsApi 客户端(单例)
|
||||
*/
|
||||
fun createGammaSportsApi(): PolymarketGammaSportsApi {
|
||||
return gammaSportsApi
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存(用于测试或配置变更时)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Flyway migration V41
|
||||
-- Create sports_tail_strategy table
|
||||
CREATE TABLE `sports_tail_strategy` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`account_id` BIGINT NOT NULL COMMENT '账户ID',
|
||||
`condition_id` VARCHAR(100) NOT NULL COMMENT '市场 conditionId',
|
||||
`market_title` VARCHAR(500) COMMENT '市场标题',
|
||||
`event_slug` VARCHAR(255) COMMENT '事件slug',
|
||||
`yes_token_id` VARCHAR(100) COMMENT 'YES Token ID',
|
||||
`no_token_id` VARCHAR(100) COMMENT 'NO Token ID',
|
||||
`trigger_price` DECIMAL(20, 8) NOT NULL COMMENT '触发价格',
|
||||
`amount_mode` VARCHAR(10) NOT NULL COMMENT '金额模式: FIXED/RATIO',
|
||||
`amount_value` DECIMAL(20, 8) NOT NULL COMMENT '金额值',
|
||||
`take_profit_price` DECIMAL(20, 8) COMMENT '止盈价格',
|
||||
`stop_loss_price` DECIMAL(20, 8) COMMENT '止损价格',
|
||||
`filled` BOOLEAN NOT NULL DEFAULT false COMMENT '是否已成交',
|
||||
`filled_price` DECIMAL(20, 8) COMMENT '成交价格',
|
||||
`filled_outcome_index` INT COMMENT '成交方向索引 0=YES, 1=NO',
|
||||
`filled_outcome_name` VARCHAR(50) COMMENT '成交方向名称',
|
||||
`filled_amount` DECIMAL(20, 8) COMMENT '成交金额',
|
||||
`filled_shares` DECIMAL(20, 8) COMMENT '成交份额',
|
||||
`filled_at` BIGINT COMMENT '成交时间',
|
||||
`sold` BOOLEAN NOT NULL DEFAULT false COMMENT '是否已卖出',
|
||||
`sell_price` DECIMAL(20, 8) COMMENT '卖出价格',
|
||||
`sell_type` VARCHAR(20) COMMENT '卖出类型',
|
||||
`sell_amount` DECIMAL(20, 8) COMMENT '卖出金额',
|
||||
`realized_pnl` DECIMAL(20, 8) COMMENT '已实现盈亏',
|
||||
`sold_at` BIGINT COMMENT '卖出时间',
|
||||
`created_at` BIGINT NOT NULL COMMENT '创建时间',
|
||||
`updated_at` BIGINT NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`)
|
||||
);
|
||||
|
||||
-- Create sports_tail_strategy_trigger table
|
||||
CREATE TABLE `sports_tail_strategy_trigger` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`strategy_id` BIGINT NOT NULL COMMENT '策略ID',
|
||||
`account_id` BIGINT NOT NULL COMMENT '账户ID',
|
||||
`condition_id` VARCHAR(100) NOT NULL COMMENT '市场 conditionId',
|
||||
`market_title` VARCHAR(500) COMMENT '市场标题',
|
||||
`buy_price` DECIMAL(20, 8) NOT NULL COMMENT '买入价格',
|
||||
`outcome_index` INT NOT NULL COMMENT '买入方向索引 0=YES, 1=NO',
|
||||
`outcome_name` VARCHAR(50) COMMENT '买入方向名称',
|
||||
`buy_amount` DECIMAL(20, 8) NOT NULL COMMENT '买入金额',
|
||||
`buy_shares` DECIMAL(20, 8) COMMENT '买入份额',
|
||||
`buy_order_id` VARCHAR(100) COMMENT '买入订单ID',
|
||||
`buy_status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '买入状态',
|
||||
`buy_fail_reason` VARCHAR(500) COMMENT '买入失败原因',
|
||||
`sell_price` DECIMAL(20, 8) COMMENT '卖出价格',
|
||||
`sell_type` VARCHAR(20) COMMENT '卖出类型',
|
||||
`sell_amount` DECIMAL(20, 8) COMMENT '卖出金额',
|
||||
`sell_order_id` VARCHAR(100) COMMENT '卖出订单ID',
|
||||
`sell_status` VARCHAR(20) COMMENT '卖出状态',
|
||||
`sell_fail_reason` VARCHAR(500) COMMENT '卖出失败原因',
|
||||
`realized_pnl` DECIMAL(20, 8) COMMENT '已实现盈亏',
|
||||
`triggered_at` BIGINT NOT NULL COMMENT '触发时间',
|
||||
`sold_at` BIGINT COMMENT '卖出时间',
|
||||
`created_at` BIGINT NOT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`)
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_sports_tail_strategy_account_id ON sports_tail_strategy (account_id);
|
||||
CREATE INDEX idx_sports_tail_strategy_condition_id ON sports_tail_strategy (condition_id);
|
||||
CREATE INDEX idx_sports_tail_trigger_account_id ON sports_tail_strategy_trigger (account_id);
|
||||
CREATE INDEX idx_sports_tail_trigger_strategy_id ON sports_tail_strategy_trigger (strategy_id);
|
||||
CREATE INDEX idx_sports_tail_trigger_triggered_at ON sports_tail_strategy_trigger (triggered_at);
|
||||
@@ -338,3 +338,20 @@ backtest.copy_mode.fixed=Fixed Amount
|
||||
backtest.price_tolerance=Price Tolerance
|
||||
backtest.delay_seconds=Delay Seconds
|
||||
backtest.support_sell=Support Sell
|
||||
|
||||
# Sports Tail Strategy
|
||||
error.sports_tail_strategy_not_found=Sports tail strategy not found
|
||||
error.sports_tail_strategy_already_filled=Strategy already filled
|
||||
error.sports_tail_strategy_already_sold=Strategy already sold
|
||||
error.sports_tail_strategy_amount_mode_invalid=Amount mode must be FIXED or RATIO
|
||||
error.sports_tail_strategy_price_invalid=Trigger price is invalid
|
||||
error.sports_tail_strategy_condition_id_empty=Market ID cannot be empty
|
||||
error.server.sports_tail_strategy_create_failed=Failed to create sports tail strategy
|
||||
error.server.sports_tail_strategy_delete_failed=Failed to delete sports tail strategy
|
||||
error.server.sports_tail_strategy_list_fetch_failed=Failed to fetch sports tail strategy list
|
||||
error.server.sports_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
|
||||
error.server.sports_tail_strategy_sports_fetch_failed=Failed to fetch sports categories
|
||||
error.server.sports_tail_strategy_market_search_failed=Failed to search markets
|
||||
error.server.sports_tail_strategy_market_detail_failed=Failed to fetch market detail
|
||||
error.server.sports_tail_strategy_buy_failed=Failed to execute buy
|
||||
error.server.sports_tail_strategy_sell_failed=Failed to execute sell
|
||||
|
||||
@@ -344,3 +344,20 @@ error.server.order_tracking_process_failed=处理订单跟踪失败
|
||||
error.server.order_tracking_buy_failed=处理买入订单失败
|
||||
error.server.order_tracking_sell_failed=处理卖出订单失败
|
||||
error.server.order_tracking_match_failed=订单匹配失败
|
||||
|
||||
# 体育尾盘策略
|
||||
error.sports_tail_strategy_not_found=体育尾盘策略不存在
|
||||
error.sports_tail_strategy_already_filled=策略已成交
|
||||
error.sports_tail_strategy_already_sold=策略已卖出
|
||||
error.sports_tail_strategy_amount_mode_invalid=金额模式仅支持 FIXED 或 RATIO
|
||||
error.sports_tail_strategy_price_invalid=触发价格无效
|
||||
error.sports_tail_strategy_condition_id_empty=市场ID不能为空
|
||||
error.server.sports_tail_strategy_create_failed=创建体育尾盘策略失败
|
||||
error.server.sports_tail_strategy_delete_failed=删除体育尾盘策略失败
|
||||
error.server.sports_tail_strategy_list_fetch_failed=查询体育尾盘策略列表失败
|
||||
error.server.sports_tail_strategy_triggers_fetch_failed=查询触发记录失败
|
||||
error.server.sports_tail_strategy_sports_fetch_failed=查询体育类别失败
|
||||
error.server.sports_tail_strategy_market_search_failed=搜索市场失败
|
||||
error.server.sports_tail_strategy_market_detail_failed=查询市场详情失败
|
||||
error.server.sports_tail_strategy_buy_failed=买入执行失败
|
||||
error.server.sports_tail_strategy_sell_failed=卖出执行失败
|
||||
|
||||
@@ -338,3 +338,20 @@ backtest.copy_mode.fixed=固定金額
|
||||
backtest.price_tolerance=價格容忍度
|
||||
backtest.delay_seconds=延遲秒數
|
||||
backtest.support_sell=支持賣出
|
||||
|
||||
# 體育尾盤策略
|
||||
error.sports_tail_strategy_not_found=體育尾盤策略不存在
|
||||
error.sports_tail_strategy_already_filled=策略已成交
|
||||
error.sports_tail_strategy_already_sold=策略已賣出
|
||||
error.sports_tail_strategy_amount_mode_invalid=金額模式僅支持 FIXED 或 RATIO
|
||||
error.sports_tail_strategy_price_invalid=觸發價格無效
|
||||
error.sports_tail_strategy_condition_id_empty=市場ID不能為空
|
||||
error.server.sports_tail_strategy_create_failed=創建體育尾盤策略失敗
|
||||
error.server.sports_tail_strategy_delete_failed=刪除體育尾盤策略失敗
|
||||
error.server.sports_tail_strategy_list_fetch_failed=查詢體育尾盤策略列表失敗
|
||||
error.server.sports_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
|
||||
error.server.sports_tail_strategy_sports_fetch_failed=查詢體育類別失敗
|
||||
error.server.sports_tail_strategy_market_search_failed=搜尋市場失敗
|
||||
error.server.sports_tail_strategy_market_detail_failed=查詢市場詳情失敗
|
||||
error.server.sports_tail_strategy_buy_failed=買入執行失敗
|
||||
error.server.sports_tail_strategy_sell_failed=賣出執行失敗
|
||||
|
||||
@@ -10,7 +10,6 @@ RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
COMPOSE_FILES=(-f docker-compose.yml)
|
||||
|
||||
# 打印信息
|
||||
info() {
|
||||
@@ -25,40 +24,6 @@ error() {
|
||||
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 环境
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
@@ -183,7 +148,6 @@ check_security_config() {
|
||||
deploy() {
|
||||
# 检查安全配置
|
||||
check_security_config
|
||||
configure_compose_files
|
||||
|
||||
# 检查是否使用 Docker Hub 镜像
|
||||
USE_DOCKER_HUB="${USE_DOCKER_HUB:-false}"
|
||||
@@ -218,20 +182,20 @@ deploy() {
|
||||
export GIT_TAG=${DOCKER_VERSION}
|
||||
export GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
|
||||
compose build
|
||||
docker-compose build
|
||||
fi
|
||||
|
||||
info "启动服务..."
|
||||
compose up -d
|
||||
docker-compose up -d
|
||||
|
||||
info "等待服务启动..."
|
||||
sleep 5
|
||||
|
||||
info "检查服务状态..."
|
||||
compose ps
|
||||
docker-compose ps
|
||||
|
||||
info "查看日志: docker-compose ${COMPOSE_FILES[*]} logs -f"
|
||||
info "停止服务: docker-compose ${COMPOSE_FILES[*]} down"
|
||||
info "查看日志: docker-compose logs -f"
|
||||
info "停止服务: docker-compose down"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,41 @@
|
||||
# 体育尾盘策略文档 (Sports Tail Strategy)
|
||||
|
||||
本目录集中存放与 Polymarket 体育市场尾盘策略相关的文档。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
sports-tail-strategy/
|
||||
├── README.md # 本说明
|
||||
└── zh/ # 中文文档
|
||||
├── sports-tail-strategy-tasks.md # 任务与验收
|
||||
├── sports-tail-strategy-ui-spec.md # UI 规格
|
||||
├── sports-tail-strategy-flow.md # 流程说明
|
||||
└── sports-tail-strategy-market-data.md # 市场数据与订阅
|
||||
```
|
||||
|
||||
## 文档说明
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| **tasks** (zh) | 开发任务与验收项 |
|
||||
| **ui-spec** (zh) | 前端列表、表单、触发记录等 UI 规格 |
|
||||
| **flow** (zh) | 策略整体流程(创建→触发→止盈止损→完成) |
|
||||
| **market-data** (zh) | Gamma API 数据获取、WebSocket 订阅、价格监控 |
|
||||
|
||||
## 功能概述
|
||||
|
||||
体育尾盘策略用于在体育市场接近尾盘(胜率 90%+)时自动买入,利用高胜率市场低风险获利。
|
||||
|
||||
### 核心特性
|
||||
|
||||
1. **不区分方向**:只设置触发价格,系统自动监控两个方向,任意方向达到触发价即买入
|
||||
2. **实时订阅**:通过 WebSocket 订阅订单簿,实时监控价格变化
|
||||
3. **止盈止损**:支持设置止盈/止损价格,自动卖出
|
||||
4. **订阅管理**:同一市场多策略共享订阅,无策略时自动取消订阅
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 体育比赛接近尾声,一方胜率 90%+ 时买入
|
||||
- 大小分市场接近尾盘时套利
|
||||
- 低风险稳定收益场景
|
||||
@@ -0,0 +1,356 @@
|
||||
# 体育尾盘策略 - API 设计
|
||||
|
||||
## 一、后端 API
|
||||
|
||||
### 1.1 策略管理
|
||||
|
||||
#### 列表
|
||||
```
|
||||
POST /api/sports-tail-strategy/list
|
||||
```
|
||||
|
||||
**请求**:
|
||||
```typescript
|
||||
interface StrategyListRequest {
|
||||
accountId?: number; // 筛选账户
|
||||
sport?: string; // 筛选类别
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```typescript
|
||||
interface StrategyListResponse {
|
||||
list: StrategyDto[];
|
||||
}
|
||||
|
||||
interface StrategyDto {
|
||||
id: number;
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
conditionId: string;
|
||||
marketTitle: string;
|
||||
eventSlug: string;
|
||||
triggerPrice: string;
|
||||
amountMode: "FIXED" | "RATIO";
|
||||
amountValue: string;
|
||||
takeProfitPrice: string | null;
|
||||
stopLossPrice: string | null;
|
||||
|
||||
// 成交信息
|
||||
filled: boolean;
|
||||
filledPrice: string | null;
|
||||
filledOutcomeIndex: number | null;
|
||||
filledOutcomeName: string | null;
|
||||
filledAmount: string | null;
|
||||
filledShares: string | null;
|
||||
filledAt: number | null;
|
||||
|
||||
// 卖出信息
|
||||
sold: boolean;
|
||||
sellPrice: string | null;
|
||||
sellType: string | null;
|
||||
sellAmount: string | null;
|
||||
realizedPnl: string | null;
|
||||
soldAt: number | null;
|
||||
|
||||
// 实时价格(未成交时返回)
|
||||
realtimeYesPrice: string | null;
|
||||
realtimeNoPrice: string | null;
|
||||
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
#### 创建
|
||||
```
|
||||
POST /api/sports-tail-strategy/create
|
||||
```
|
||||
|
||||
**请求**:
|
||||
```typescript
|
||||
interface StrategyCreateRequest {
|
||||
accountId: number; // 账户ID
|
||||
conditionId: string; // 市场ID
|
||||
marketTitle: string; // 市场标题
|
||||
eventSlug?: string; // 事件slug
|
||||
triggerPrice: string; // 触发价格
|
||||
amountMode: "FIXED" | "RATIO";
|
||||
amountValue: string; // 金额值
|
||||
takeProfitPrice?: string; // 止盈价格
|
||||
stopLossPrice?: string; // 止损价格
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```typescript
|
||||
interface StrategyCreateResponse {
|
||||
id: number;
|
||||
}
|
||||
```
|
||||
|
||||
#### 删除
|
||||
```
|
||||
POST /api/sports-tail-strategy/delete
|
||||
```
|
||||
|
||||
**请求**:
|
||||
```typescript
|
||||
interface StrategyDeleteRequest {
|
||||
id: number;
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```typescript
|
||||
interface StrategyDeleteResponse {
|
||||
success: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 市场数据
|
||||
|
||||
#### 体育类别列表
|
||||
```
|
||||
POST /api/sports-tail-strategy/sports-list
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```typescript
|
||||
interface SportsListResponse {
|
||||
list: SportDto[];
|
||||
}
|
||||
|
||||
interface SportDto {
|
||||
sport: string; // 类别标识:nba, nfl, epl...
|
||||
image: string; // 图标URL
|
||||
tagId: number; // 主Tag ID
|
||||
name: string; // 显示名称(多语言)
|
||||
}
|
||||
```
|
||||
|
||||
#### 市场搜索
|
||||
```
|
||||
POST /api/sports-tail-strategy/market-search
|
||||
```
|
||||
|
||||
**请求**:
|
||||
```typescript
|
||||
interface MarketSearchRequest {
|
||||
sport?: string; // 体育类别
|
||||
endDateMin?: string; // 最小结束时间 ISO 8601
|
||||
endDateMax?: string; // 最大结束时间 ISO 8601
|
||||
minLiquidity?: string; // 最小流动性
|
||||
keyword?: string; // 搜索关键词
|
||||
limit?: number; // 返回数量,默认50
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```typescript
|
||||
interface MarketSearchResponse {
|
||||
list: MarketDto[];
|
||||
}
|
||||
|
||||
interface MarketDto {
|
||||
conditionId: string;
|
||||
question: string;
|
||||
outcomes: string[]; // ["Yes", "No"] 或 ["Over", "Under"]
|
||||
outcomePrices: string[]; // 当前价格
|
||||
endDate: string; // 结束时间 ISO 8601
|
||||
liquidity: string; // 流动性
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
yesTokenId: string;
|
||||
noTokenId: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 市场详情
|
||||
```
|
||||
POST /api/sports-tail-strategy/market-detail
|
||||
```
|
||||
|
||||
**请求**:
|
||||
```typescript
|
||||
interface MarketDetailRequest {
|
||||
conditionId: string;
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```typescript
|
||||
interface MarketDetailResponse {
|
||||
conditionId: string;
|
||||
question: string;
|
||||
outcomes: string[];
|
||||
outcomePrices: string[];
|
||||
endDate: string;
|
||||
liquidity: string;
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
yesTokenId: string;
|
||||
noTokenId: string;
|
||||
eventSlug: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 触发记录
|
||||
|
||||
#### 全局记录列表
|
||||
```
|
||||
POST /api/sports-tail-strategy/triggers
|
||||
```
|
||||
|
||||
**请求**:
|
||||
```typescript
|
||||
interface TriggerListRequest {
|
||||
accountId?: number; // 筛选账户
|
||||
status?: string; // 筛选状态: SUCCESS/FAIL
|
||||
startTime?: number; // 开始时间戳
|
||||
endTime?: number; // 结束时间戳
|
||||
page?: number; // 页码,默认1
|
||||
pageSize?: number; // 每页数量,默认20
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```typescript
|
||||
interface TriggerListResponse {
|
||||
total: number;
|
||||
list: TriggerDto[];
|
||||
}
|
||||
|
||||
interface TriggerDto {
|
||||
id: number;
|
||||
strategyId: number;
|
||||
|
||||
// 市场信息
|
||||
marketTitle: string;
|
||||
conditionId: string;
|
||||
|
||||
// 买入信息
|
||||
buyPrice: string;
|
||||
outcomeIndex: number;
|
||||
outcomeName: string | null;
|
||||
buyAmount: string;
|
||||
buyShares: string | null;
|
||||
buyStatus: "PENDING" | "SUCCESS" | "FAIL";
|
||||
|
||||
// 卖出信息
|
||||
sellPrice: string | null;
|
||||
sellType: string | null; // TAKE_PROFIT/STOP_LOSS/MANUAL
|
||||
sellAmount: string | null;
|
||||
sellStatus: string | null;
|
||||
|
||||
// 盈亏
|
||||
realizedPnl: string | null;
|
||||
|
||||
// 时间
|
||||
triggeredAt: number;
|
||||
soldAt: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、前端 API 封装
|
||||
|
||||
### 2.1 apiService 方法
|
||||
|
||||
```typescript
|
||||
// 策略管理
|
||||
sportsTailStrategyList(params: StrategyListRequest): Promise<StrategyListResponse>
|
||||
sportsTailStrategyCreate(data: StrategyCreateRequest): Promise<StrategyCreateResponse>
|
||||
sportsTailStrategyDelete(id: number): Promise<StrategyDeleteResponse>
|
||||
|
||||
// 市场数据
|
||||
sportsTailStrategySportsList(): Promise<SportsListResponse>
|
||||
sportsTailStrategyMarketSearch(params: MarketSearchRequest): Promise<MarketSearchResponse>
|
||||
sportsTailStrategyMarketDetail(conditionId: string): Promise<MarketDetailResponse>
|
||||
|
||||
// 触发记录
|
||||
sportsTailStrategyTriggers(params: TriggerListRequest): Promise<TriggerListResponse>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、多语言 Key
|
||||
|
||||
### 3.1 页面标题
|
||||
```
|
||||
sportsTailStrategy.list.title=体育尾盘策略
|
||||
sportsTailStrategy.list.addStrategy=新增策略
|
||||
sportsTailStrategy.list.filter.account=账户
|
||||
sportsTailStrategy.list.filter.sport=类别
|
||||
sportsTailStrategy.list.filter.all=全部
|
||||
```
|
||||
|
||||
### 3.2 表单字段
|
||||
```
|
||||
sportsTailStrategy.form.account=账户
|
||||
sportsTailStrategy.form.market=市场
|
||||
sportsTailStrategy.form.triggerPrice=触发价格
|
||||
sportsTailStrategy.form.amount=金额
|
||||
sportsTailStrategy.form.amountMode=金额模式
|
||||
sportsTailStrategy.form.fixed=固定金额
|
||||
sportsTailStrategy.form.ratio=余额比例
|
||||
sportsTailStrategy.form.takeProfit=止盈价格
|
||||
sportsTailStrategy.form.stopLoss=止损价格
|
||||
sportsTailStrategy.form.autoSell=自动卖出
|
||||
```
|
||||
|
||||
### 3.3 列表字段
|
||||
```
|
||||
sportsTailStrategy.list.triggerPrice=触发价
|
||||
sportsTailStrategy.list.amount=金额
|
||||
sportsTailStrategy.list.takeProfitStopLoss=止盈/止损
|
||||
sportsTailStrategy.list.filledPrice=成交价
|
||||
sportsTailStrategy.list.shares=份
|
||||
sportsTailStrategy.list.pnl=盈亏
|
||||
sportsTailStrategy.list.realtimePrice=实时价格
|
||||
sportsTailStrategy.list.pending=待结算
|
||||
sportsTailStrategy.list.viewRecords=查看记录
|
||||
sportsTailStrategy.list.delete=删除
|
||||
```
|
||||
|
||||
### 3.4 市场筛选
|
||||
```
|
||||
sportsTailStrategy.market.filter.sport=类别
|
||||
sportsTailStrategy.market.filter.allSports=全部类别
|
||||
sportsTailStrategy.market.filter.endTime=结束时间
|
||||
sportsTailStrategy.market.filter.today=今天
|
||||
sportsTailStrategy.market.filter.next24h=未来24小时
|
||||
sportsTailStrategy.market.filter.next7days=未来7天
|
||||
sportsTailStrategy.market.filter.minLiquidity=最小流动性
|
||||
sportsTailStrategy.market.filter.keyword=关键词
|
||||
sportsTailStrategy.market.filter.search=搜索
|
||||
sportsTailStrategy.market.select=选择市场
|
||||
```
|
||||
|
||||
### 3.5 触发记录
|
||||
```
|
||||
sportsTailStrategy.records.title=触发记录
|
||||
sportsTailStrategy.records.market=市场
|
||||
sportsTailStrategy.records.direction=方向
|
||||
sportsTailStrategy.records.buyPrice=买入价
|
||||
sportsTailStrategy.records.buyAmount=买入金额
|
||||
sportsTailStrategy.records.sellPrice=卖出价
|
||||
sportsTailStrategy.records.sellType=卖出类型
|
||||
sportsTailStrategy.records.pnl=盈亏
|
||||
sportsTailStrategy.records.time=时间
|
||||
sportsTailStrategy.records.status=状态
|
||||
```
|
||||
|
||||
### 3.6 消息提示
|
||||
```
|
||||
sportsTailStrategy.message.createSuccess=策略创建成功
|
||||
sportsTailStrategy.message.deleteSuccess=策略删除成功
|
||||
sportsTailStrategy.message.deleteConfirm=确定删除该策略吗?
|
||||
sportsTailStrategy.message.noMarketSelected=请选择市场
|
||||
sportsTailStrategy.message.invalidPrice=价格格式无效
|
||||
```
|
||||
@@ -0,0 +1,262 @@
|
||||
# 体育尾盘策略 - 流程说明
|
||||
|
||||
## 一、策略状态流转
|
||||
|
||||
```
|
||||
┌─────────────┐ 价格>=触发价 ┌─────────────┐
|
||||
│ 待触发 │ ──────────────────▶ │ 已成交 │
|
||||
│ (filled=F) │ │ (filled=T) │
|
||||
└─────────────┘ └─────────────┘
|
||||
│ │
|
||||
│ ┌─────────────────┼─────────────────┐
|
||||
│ │ │ │
|
||||
▼ │ 有止盈止损 │ 无止盈止损 │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────────────────────────────┐
|
||||
│ 保持订阅 │ │ 检查同市场是否有其他未完成策略 │
|
||||
│ 监控卖出 │ │ - 有: 保持订阅 │
|
||||
└─────────────┘ │ - 无: 取消订阅 │
|
||||
│ └─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 价格 >= 止盈价 │
|
||||
│ 或 价格 <= 止损价 │
|
||||
└──────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ 自动卖出 │
|
||||
│ (sold=T) │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、订阅生命周期
|
||||
|
||||
### 2.1 策略创建时
|
||||
|
||||
```
|
||||
策略创建
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SubscriptionManager.subscribeStrategy(strategy) │
|
||||
│ 1. 检查市场是否已有订阅(marketSubscriptions 计数) │
|
||||
│ 2. 如果市场已有订阅:仅增加计数,不新建连接 │
|
||||
│ 3. 如果市场无订阅:建立 WebSocket 连接,订阅订单簿频道 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 实时监控
|
||||
|
||||
```
|
||||
WebSocket 订单簿推送
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SportsTailWebSocketHandler.onOrderbookUpdate() │
|
||||
│ 1. 解析消息,获取当前价格 │
|
||||
│ 2. 查找该 Token ID 对应的所有未完成策略 │
|
||||
│ 3. 遍历策略,检查触发条件: │
|
||||
│ - 未成交:检查买入条件(价格 >= 触发价) │
|
||||
│ - 已成交未卖出:检查卖出条件(止盈/止损) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 成交后处理
|
||||
|
||||
```
|
||||
策略成交
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SubscriptionManager.onStrategyFilled(strategy) │
|
||||
│ 1. 更新策略状态为已成交 │
|
||||
│ 2. 检查是否需要保持订阅: │
|
||||
│ - 有止盈止损:保持订阅,继续监控卖出条件 │
|
||||
│ - 无止盈止损:检查同市场是否有其他未完成策略 │
|
||||
│ - 有:保持订阅 │
|
||||
│ - 无:取消订阅,关闭 WebSocket 连接 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.4 卖出后处理
|
||||
|
||||
```
|
||||
策略卖出
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SubscriptionManager.onStrategySold(strategy) │
|
||||
│ 1. 更新策略状态为已卖出 │
|
||||
│ 2. 计算并记录盈亏 │
|
||||
│ 3. 检查同市场是否有其他未完成策略: │
|
||||
│ - 有:保持订阅 │
|
||||
│ - 无:取消订阅,关闭 WebSocket 连接 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.5 策略删除时
|
||||
|
||||
```
|
||||
策略删除
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SubscriptionManager.unsubscribeStrategy(strategy) │
|
||||
│ 1. 减少市场的订阅计数 │
|
||||
│ 2. 如果计数归零:取消订阅,关闭 WebSocket 连接 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、买入逻辑(不区分方向)
|
||||
|
||||
### 3.1 触发条件检查
|
||||
|
||||
```kotlin
|
||||
fun checkBuyTrigger(strategy: SportsTailStrategy, yesPrice: BigDecimal, noPrice: BigDecimal): BuyTrigger? {
|
||||
// 如果已成交,不检查
|
||||
if (strategy.filled) return null
|
||||
|
||||
// 检查 YES 方向
|
||||
if (yesPrice >= strategy.triggerPrice) {
|
||||
return BuyTrigger(outcomeIndex = 0, price = yesPrice)
|
||||
}
|
||||
|
||||
// 检查 NO 方向
|
||||
if (noPrice >= strategy.triggerPrice) {
|
||||
return BuyTrigger(outcomeIndex = 1, price = noPrice)
|
||||
}
|
||||
|
||||
// 都不满足
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 执行买入
|
||||
|
||||
```kotlin
|
||||
suspend fun executeBuy(strategy: SportsTailStrategy, trigger: BuyTrigger) {
|
||||
// 1. 创建市价单
|
||||
val order = createMarketOrder(
|
||||
tokenId = getTokenId(strategy.conditionId, trigger.outcomeIndex),
|
||||
side = "BUY",
|
||||
amount = calculateAmount(strategy)
|
||||
)
|
||||
|
||||
// 2. 提交订单
|
||||
val result = clobApi.createOrder(order)
|
||||
|
||||
// 3. 更新策略状态
|
||||
strategy.filled = true
|
||||
strategy.filledPrice = trigger.price
|
||||
strategy.filledOutcomeIndex = trigger.outcomeIndex
|
||||
strategy.filledAmount = order.amount
|
||||
strategy.filledShares = calculateShares(order.amount, trigger.price)
|
||||
strategy.filledAt = System.currentTimeMillis()
|
||||
strategyRepository.save(strategy)
|
||||
|
||||
// 4. 记录触发记录
|
||||
createTriggerRecord(strategy, trigger, result)
|
||||
|
||||
// 5. 通知订阅管理器
|
||||
subscriptionManager.onStrategyFilled(strategy)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、卖出逻辑(止盈止损)
|
||||
|
||||
### 4.1 止盈止损检查
|
||||
|
||||
```kotlin
|
||||
fun checkSellTrigger(strategy: SportsTailStrategy, currentPrice: BigDecimal): SellTrigger? {
|
||||
// 如果已卖出或未成交,不检查
|
||||
if (strategy.sold || !strategy.filled) return null
|
||||
|
||||
// 只检查已买入方向的价格
|
||||
val filledTokenId = getTokenId(strategy.conditionId, strategy.filledOutcomeIndex)
|
||||
if (currentTokenId != filledTokenId) return null
|
||||
|
||||
// 检查止盈
|
||||
if (strategy.takeProfitPrice != null && currentPrice >= strategy.takeProfitPrice) {
|
||||
return SellTrigger(type = "TAKE_PROFIT", price = currentPrice)
|
||||
}
|
||||
|
||||
// 检查止损
|
||||
if (strategy.stopLossPrice != null && currentPrice <= strategy.stopLossPrice) {
|
||||
return SellTrigger(type = "STOP_LOSS", price = currentPrice)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 执行卖出
|
||||
|
||||
```kotlin
|
||||
suspend fun executeSell(strategy: SportsTailStrategy, trigger: SellTrigger) {
|
||||
// 1. 创建市价单
|
||||
val order = createMarketOrder(
|
||||
tokenId = getTokenId(strategy.conditionId, strategy.filledOutcomeIndex),
|
||||
side = "SELL",
|
||||
shares = strategy.filledShares
|
||||
)
|
||||
|
||||
// 2. 提交订单
|
||||
val result = clobApi.createOrder(order)
|
||||
|
||||
// 3. 计算盈亏
|
||||
val sellAmount = calculateSellAmount(trigger.price, strategy.filledShares)
|
||||
val pnl = sellAmount - strategy.filledAmount
|
||||
|
||||
// 4. 更新策略状态
|
||||
strategy.sold = true
|
||||
strategy.sellPrice = trigger.price
|
||||
strategy.sellType = trigger.type
|
||||
strategy.sellAmount = sellAmount
|
||||
strategy.realizedPnl = pnl
|
||||
strategy.soldAt = System.currentTimeMillis()
|
||||
strategyRepository.save(strategy)
|
||||
|
||||
// 5. 更新触发记录
|
||||
updateTriggerRecord(strategy, trigger, result, pnl)
|
||||
|
||||
// 6. 通知订阅管理器
|
||||
subscriptionManager.onStrategySold(strategy)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、关键设计要点
|
||||
|
||||
### 5.1 订阅共享
|
||||
|
||||
- 同一市场(conditionId)多个策略共享一个 WebSocket 订阅
|
||||
- 使用计数器管理订阅生命周期
|
||||
- 避免重复连接和资源浪费
|
||||
|
||||
### 5.2 不区分方向
|
||||
|
||||
- 只设置触发价格,不选择 YES/NO
|
||||
- 系统自动监控两个方向的价格
|
||||
- 任意方向满足条件即买入该方向
|
||||
|
||||
### 5.3 实时订阅
|
||||
|
||||
- 使用 WebSocket 订阅订单簿,实时接收价格变化
|
||||
- 不使用轮询方式
|
||||
- 响应速度快,延迟低
|
||||
|
||||
### 5.4 智能取消
|
||||
|
||||
- 无策略时取消订阅
|
||||
- 策略完成且无止盈止损时检查是否需要取消
|
||||
- 有止盈止损时保持订阅直到卖出
|
||||
@@ -0,0 +1,215 @@
|
||||
# 体育尾盘策略 - 市场数据与订阅
|
||||
|
||||
> 本文档描述体育市场数据获取方式、 WebSocket 订阅管理逻辑。
|
||||
|
||||
## 一、Gamma API 数据获取
|
||||
|
||||
> 本文档描述如何从 Gamma API 获取体育市场数据。### 1.1 萜索条件
|
||||
### 1.1 获取体育类别列表
|
||||
> **前端选择体育类别时,需要获取可选的体育类别列表供用户选择。
|
||||
```
|
||||
GET https://gamma-api.polymarket.com/sports
|
||||
```
|
||||
> **返回示例**:
|
||||
```json
|
||||
[
|
||||
{"sport": "nba", "image": "https://...", "tags": "1,745,100639"},
|
||||
{"sport": "nfl", "image": "https://...", "tags": "1,450,100639"},
|
||||
{"sport": "epl", "image": "https://...", "tags": "1,82,306,100639"},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
**主要类别**:
|
||||
| sport | 名称 | tag_id |
|
||||
|-------|------|--------|
|
||||
| nba | 篮球 NBA | 745 |
|
||||
| nfl | 美式足球 NFL | 450 |
|
||||
| epl | 英超 | 82 |
|
||||
| lal | 西甲 | 780 |
|
||||
| mlb | 棒球 MLB | 100381 |
|
||||
| nhl | 冰球 NHL | 899 |
|
||||
|
||||
| ufc | 格斗 UFC | 100639 |
|
||||
|
||||
### 1.2 按类别筛选市场
|
||||
> 根据用户选择的体育类别,使用 `tag_id` 参数筛选市场。
|
||||
```
|
||||
GET https://gamma-api.polymarket.com/markets?tag_id=745&active=true&closed=false&limit=50&order=endDate&ascending=true
|
||||
```
|
||||
> **返回字段**:
|
||||
- `id` - 市场ID
|
||||
- `question` - 市场问题
|
||||
- `conditionId` - 市场 conditionId
|
||||
- `outcomes` - 结果选项 `["Yes", "No"]` 或 `["Over", "Under"]`
|
||||
- `outcomePrices` - 当前价格 `["0.92", "0.08"]`
|
||||
- `endDate` - 结束时间
|
||||
- `bestBid` - 最佳买价
|
||||
- `bestAsk` - 最佳卖价
|
||||
- `clobTokenIds` - Token IDs
|
||||
- `gameStartTime` - 比赛开始时间
|
||||
- `sportsMarketType` - 市场类型
|
||||
- `liquidityNum` - 流动性
|
||||
- `volumeNum` - 成交量
|
||||
|
||||
- `events` - 关联事件信息
|
||||
|
||||
```
|
||||
|
||||
> **请求参数**:
|
||||
```kotlin
|
||||
data class SportsMarketSearchRequest(
|
||||
val sport: String? = null, // 体育类别: nba, nfl, epl...
|
||||
val marketType: String? = null, // 市场类型: moneyline, spreads, totals
|
||||
val endDateMin: String? = null, // 最小结束时间
|
||||
val endDateMax: String? = null, // 最大结束时间
|
||||
val minLiquidity: BigDecimal? = null, // 最小流动性
|
||||
val keyword: String? = null, // 搜索关键词
|
||||
val limit: Int = 50 // 返回数量
|
||||
)
|
||||
```
|
||||
|
||||
> **marketType 说明**:
|
||||
- `moneyline` - 胜负市场(谁会赢)
|
||||
- `spreads` - 让分市场
|
||||
- `totals` - 大小分市场
|
||||
|
||||
### 1.3 获取单个市场详情
|
||||
```
|
||||
GET https://gamma-api.polymarket.com/markets?condition_ids={conditionId}
|
||||
```
|
||||
> **用于**:
|
||||
- 创建策略时获取 `clobTokenIds`
|
||||
- 监控价格时获取实时价格
|
||||
- 获取 Token ID 用于下单
|
||||
|
||||
---
|
||||
|
||||
## 二、WebSocket 订阅管理
|
||||
### 2.1 订阅策略
|
||||
> 同一市场可能有多个策略,但只维护一个 WebSocket 订阅。
|
||||
> 策略创建/删除时需要更新订阅计数。
|
||||
```kotlin
|
||||
// 市场订阅计数
|
||||
private val marketSubscriptions = ConcurrentHashMap<String, Int>()
|
||||
|
||||
// 市场对应的 Token IDs 缓存
|
||||
private val marketTokenIds = ConcurrentHashMap<String, Pair<String, String>>()
|
||||
|
||||
/**
|
||||
* 订阅策略(创建策略时调用)
|
||||
*/
|
||||
fun subscribeStrategy(strategy: SportsTailStrategy) {
|
||||
val conditionId = strategy.conditionId
|
||||
|
||||
marketSubscriptions.compute(conditionId) { _, count ->
|
||||
val newCount = (count ?: 0) + 1
|
||||
if (newCount == 1) {
|
||||
// 首次订阅,建立 WebSocket 连接
|
||||
subscribeMarket(conditionId)
|
||||
}
|
||||
newCount
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
> **订阅管理规则**:
|
||||
| 场景 | 操作 |
|
||||
|------|------|
|
||||
| 策略创建 | 如果市场无订阅,建立订阅;否则计数 +1 |
|
||||
| 策略删除 | 计数 -1;如果计数为 0,取消订阅 |
|
||||
| 策略成交(无止盈止损) | 检查同市场是否有其他未完成策略;无则取消订阅 |
|
||||
| 策略卖出 | 检查同市场是否有其他未完成策略;无则取消订阅 |
|
||||
|
||||
### 2.2 讣阅流程
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ 策略创建 │────▶│ 检查订阅计数 │────▶│ 首次? 建立连接 │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WebSocket 接收订单簿更新 │
|
||||
│ 1. 解析消息,获取 tokenId 和价格 │
|
||||
│ 2. 查找该 Token 对应的未完成策略 │
|
||||
│ 3. 检查触发条件 │
|
||||
│ 4. 满足条件则执行买入/卖出 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> **WebSocket 消息格式**:
|
||||
```json
|
||||
{
|
||||
"event_type": "book",
|
||||
"asset_id": "1234567890",
|
||||
"market": {
|
||||
"bids": [...],
|
||||
"asks": [...],
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、价格监控与触发
|
||||
### 3.1 买入触发逻辑
|
||||
> 任意方向价格达到触发价即买入
|
||||
```kotlin
|
||||
fun checkBuyTrigger(
|
||||
strategy: SportsTailStrategy,
|
||||
yesPrice: BigDecimal,
|
||||
noPrice: BigDecimal
|
||||
): TriggerResult? {
|
||||
// 检查 YES 方向
|
||||
if (yesPrice >= strategy.triggerPrice) {
|
||||
return TriggerResult(outcomeIndex = 0, price = yesPrice)
|
||||
}
|
||||
|
||||
// 检查 NO 方向
|
||||
if (noPrice >= strategy.triggerPrice) {
|
||||
return TriggerResult(outcomeIndex = 1, price = noPrice)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
> **注意**:不区分方向,系统自动选择价格满足的方向买入。
|
||||
### 3.2 止盈止损逻辑
|
||||
> 已成交的策略,监控持仓方向的价格变化
|
||||
```kotlin
|
||||
fun checkSellTrigger(
|
||||
strategy: SportsTailStrategy,
|
||||
currentPrice: BigDecimal
|
||||
): SellTrigger? {
|
||||
if (!strategy.filled || strategy.sold) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 止盈:当前价格 >= 止盈价
|
||||
if (strategy.takeProfitPrice != null && currentPrice >= strategy.takeProfitPrice) {
|
||||
return SellTrigger(type = "TAKE_PROFIT", price = currentPrice)
|
||||
}
|
||||
|
||||
// 止损:当前价格 <= 止损价
|
||||
if (strategy.stopLossPrice != null && currentPrice <= strategy.stopLossPrice) {
|
||||
return SellTrigger(type = "STOP_LOSS", price = currentPrice)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
```
|
||||
> **注意**:只监控已买入方向的价格,不是两个方向都监控。
|
||||
### 3.3 订阅生命周期
|
||||
```
|
||||
┌─────────────┐ 价格>=触发价 ┌─────────────┐ 止盈/止损 ┌─────────────┐
|
||||
│ 监控两个方向 │ ────────────────▶ │ 已成交 │ ────────────────▶ │ 已完成 │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 成交后检查止盈止损 │
|
||||
│ - 有止盈止损:保持订阅,只监控买入方向 │
|
||||
│ - 无止盈止损:检查同市场是否有其他未完成策略,无则取消订阅 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,141 @@
|
||||
# 体育尾盘策略 - 任务梳理
|
||||
|
||||
> 需求与 UI 见 `sports-tail-strategy-ui-spec.md`,市场数据与订阅见 `sports-tail-strategy-market-data.md`。
|
||||
|
||||
以下按**数据库 / 后端 / 前端**拆分为可执行任务,便于排期与验收。
|
||||
|
||||
---
|
||||
|
||||
## 一、数据库
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| D1 | 策略表 migration | 新建表 `sports_tail_strategy`,字段:id, account_id, condition_id, market_title, event_slug, trigger_price, amount_mode(FIXED/RATIO), amount_value, take_profit_price, stop_loss_price, filled(BOOL), filled_price, filled_outcome_index, filled_amount, filled_shares, filled_at, sold(BOOL), sell_price, sell_type, sell_amount, realized_pnl, sold_at, created_at, updated_at |
|
||||
| D2 | 触发记录表 migration | 新建表 `sports_tail_strategy_trigger`,字段:id, strategy_id, market_title, condition_id, account_id, buy_order_id, buy_price, outcome_index, outcome_name, buy_amount, buy_shares, buy_status(PENDING/SUCCESS/FAIL), sell_order_id, sell_price, sell_type(TAKE_PROFIT/STOP_LOSS/MANUAL), sell_amount, sell_status, realized_pnl, triggered_at, sold_at |
|
||||
|
||||
---
|
||||
|
||||
## 二、后端(Kotlin)
|
||||
|
||||
### 2.1 实体与 Repository
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B1 | 策略实体 Entity | 对应 `sports_tail_strategy` 表;ID 用 `Long?`;时间用 `Long` 时间戳;金额用 `BigDecimal`;遵守 backend.mdc 实体规范 |
|
||||
| B2 | 触发记录实体 Entity | 对应 `sports_tail_strategy_trigger` 表 |
|
||||
| B3 | JpaRepository | 策略、触发记录的 Repository;按 conditionId、accountId、filled、sold 等查询 |
|
||||
|
||||
### 2.2 Gamma API 扩展
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B4 | 体育类别 API | `GET /sports` 获取体育元数据(sport, tags, image) |
|
||||
| B5 | 市场搜索 API | `GET /markets` 支持 tag_id、sports_market_types、end_date_min/max、liquidity_num_min 等筛选参数 |
|
||||
| B6 | 事件列表 API | `GET /events` 支持 tag_slug、active、live 等参数,获取比赛状态 |
|
||||
|
||||
### 2.3 订阅管理
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B7 | 订阅管理器 SubscriptionManager | 维护市场订阅计数,同一市场多策略共享订阅;无策略时自动取消订阅 |
|
||||
| B8 | WebSocket 订单簿订阅 | 订阅订单簿 `channel: "book:<token_id>"`,接收实时价格更新 |
|
||||
| B9 | 订阅生命周期管理 | 策略创建时订阅,成交后检查是否需要保持(有止盈止损则保持),卖出后检查是否需要取消 |
|
||||
|
||||
### 2.4 策略执行核心逻辑
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B10 | 买入触发判断 | 接收订单簿更新,检查未成交策略;当任意方向价格 >= triggerPrice 时买入该方向 |
|
||||
| B11 | 买入执行 | 调用 CLOB API 创建市价买单;记录成交价格、数量、方向;更新策略状态为已成交 |
|
||||
| B12 | 止盈判断 | 已成交策略,当前价格 >= takeProfitPrice 时执行卖出 |
|
||||
| B13 | 止损判断 | 已成交策略,当前价格 <= stopLossPrice 时执行卖出 |
|
||||
| B14 | 卖出执行 | 调用 CLOB API 创建市价卖单;计算盈亏;更新策略状态为已卖出 |
|
||||
|
||||
### 2.5 API 与 DTO
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B15 | 策略 CRUD API | 列表(分页/筛选)、创建、删除;统一 ApiResponse;错误码与 MessageSource |
|
||||
| B16 | 策略 DTO | 创建请求:accountId, conditionId, marketTitle, eventSlug, triggerPrice, amountMode, amountValue, takeProfitPrice(可选), stopLossPrice(可选) |
|
||||
| B17 | 触发记录 API | 全局触发记录列表;支持 accountId、status、时间筛选;返回市场信息、成交价、数量、盈亏等 |
|
||||
| B18 | 市场搜索 API | 体育类别列表、市场搜索(支持筛选)、市场详情(含实时价格) |
|
||||
|
||||
---
|
||||
|
||||
## 三、前端(React + TypeScript)
|
||||
|
||||
### 3.1 路由与导航
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F1 | 路由 | App.tsx 增加 `/sports-tail-strategy` |
|
||||
| F2 | 菜单 | Layout 中增加「体育尾盘策略」菜单项 |
|
||||
|
||||
### 3.2 列表页
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F3 | 列表页组件 | SportsTailStrategyList.tsx;页面标题、新增按钮、筛选(账户、类别) |
|
||||
| F4 | 列表展示 | 桌面 Table / 移动 Card:市场标题、账户、触发价、金额、止盈止损、成交价/数量(未成交显示实时价格)、操作(查看记录、删除) |
|
||||
| F5 | 实时价格显示 | 未成交策略通过 WebSocket 获取实时价格并显示 |
|
||||
|
||||
### 3.3 新增/编辑表单
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F6 | 表单弹窗 | 账户选择、市场搜索(支持筛选)、触发价格、下注金额、止盈止损(可选) |
|
||||
| F7 | 市场筛选器 | 体育类别、市场类型、结束时间、最小流动性、搜索关键词 |
|
||||
| F8 | 市场选择器 | 显示搜索结果列表,包含市场标题、当前价格、结束时间、流动性 |
|
||||
| F9 | 预估收益 | 根据触发价、金额计算预估份额和收益 |
|
||||
| F10 | 表单校验 | 触发价 0-1,止盈 > 触发价,止损 < 触发价 |
|
||||
|
||||
### 3.4 触发记录
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F11 | 触发记录列表 | 全局记录列表(非单个市场);支持账户、状态、时间筛选 |
|
||||
| F12 | 记录详情 | 市场标题、成交价格/方向、数量、卖出价格/类型、盈亏 |
|
||||
|
||||
### 3.5 通用
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F13 | 类型定义 | 策略、触发记录、市场、体育类别等 TypeScript 类型 |
|
||||
| F14 | API 封装 | apiService 中 sportsTailStrategy.* 方法 |
|
||||
| F15 | 多语言 | zh-CN、zh-TW、en 的 sportsTailStrategy.* 文案 |
|
||||
|
||||
---
|
||||
|
||||
## 四、依赖关系简图
|
||||
|
||||
```
|
||||
D1,D2 数据库
|
||||
↓
|
||||
B1-B3 实体与 Repository
|
||||
↓
|
||||
B4-B6 Gamma API 扩展
|
||||
↓
|
||||
B7-B9 订阅管理
|
||||
↓
|
||||
B10-B14 执行逻辑
|
||||
↓
|
||||
B15-B18 API 与 DTO
|
||||
↓
|
||||
F1-F2 路由与菜单
|
||||
F13-F15 类型与 API 封装
|
||||
↓
|
||||
F3-F5 列表与实时价格
|
||||
F6-F10 表单(含市场筛选)
|
||||
F11-F12 触发记录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、验收要点
|
||||
|
||||
- **不区分方向**:只设置触发价格,系统自动监控两个方向,任意方向达到即买入
|
||||
- **实时订阅**:通过 WebSocket 订阅订单簿,实时监控价格,不使用轮询
|
||||
- **订阅管理**:同一市场多策略共享一个订阅;无策略或策略完成且无止盈止损时取消订阅
|
||||
- **止盈止损**:有止盈止损的策略成交后保持订阅,直到卖出
|
||||
- **列表显示**:已成交显示成交价和数量,未成交显示实时价格
|
||||
- **触发记录**:全局记录列表,每条记录包含完整市场信息
|
||||
@@ -0,0 +1,259 @@
|
||||
# 体育尾盘策略 - UI 规格
|
||||
|
||||
## 一、策略列表页
|
||||
|
||||
### 1.1 页面布局
|
||||
|
||||
**路由**:`/sports-tail-strategy`
|
||||
|
||||
**桌面端**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 体育尾盘策略 [+ 新增策略] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ 筛选: [账户选择▼] [类别: 全部▼] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Lakers vs Bulls - Who will win? │ │
|
||||
│ │ 账户: Account 1 │ │
|
||||
│ │ 触发价: >=0.90 | 金额: 10 USDC │ │
|
||||
│ │ 止盈: 0.98 | 止损: 0.85 │ │
|
||||
│ │ ─────────────────────────────────────────────────────────── │ │
|
||||
│ │ 成交价: 0.91 YES | 数量: 10.99 份 | 盈亏: 待结算 │ │
|
||||
│ │ [查看记录] [删除] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**移动端**:使用卡片布局,信息折叠展示
|
||||
|
||||
### 1.2 列表字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 市场标题 | `marketTitle`,可点击跳转 Polymarket |
|
||||
| 账户 | 关联的账户名称 |
|
||||
| 触发价 | `>= {triggerPrice}` |
|
||||
| 金额 | 固定金额 USDC 或 余额比例 % |
|
||||
| 止盈/止损 | 配置的止盈止损价格,未配置显示 `-` |
|
||||
| 成交信息 | 已成交:`{filledPrice} {YES/NO} \| {filledShares} 份`<br>未成交:`实时价格: YES {price} \| NO {price}` |
|
||||
| 盈亏 | 已卖出:`+{realizedPnl} USDC`<br>已成交未卖出:`待结算`<br>未成交:`-` |
|
||||
|
||||
### 1.3 操作按钮
|
||||
|
||||
| 按钮 | 说明 |
|
||||
|------|------|
|
||||
| 查看记录 | 打开该策略的触发记录详情 |
|
||||
| 删除 | 删除策略(需二次确认) |
|
||||
|
||||
**注意**:不支持启用/禁用功能,无状态列
|
||||
|
||||
---
|
||||
|
||||
## 二、新增策略表单
|
||||
|
||||
### 2.1 表单字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 账户选择 | 下拉 | 是 | 选择账户 |
|
||||
| 选择市场 | 搜索选择 | 是 | 从市场列表中选择(支持筛选) |
|
||||
| 触发条件 | 复合输入 | 是 | `当价格 [>=] [0.90] 时触发买入` |
|
||||
| 下注金额 | 单选+输入 | 是 | 固定金额 USDC 或 余额比例 % |
|
||||
| 启用自动卖出 | 开关 | 否 | 开启后显示止盈止损 |
|
||||
| 止盈价格 | 输入 | 否 | 价格上涨到此值时自动卖出 |
|
||||
| 止损价格 | 输入 | 否 | 价格下跌到此值时自动卖出 |
|
||||
|
||||
**注意**:
|
||||
- 不选择方向(YES/NO),只设置触发价格
|
||||
- 系统自动监控两个方向,任意方向达到触发价即买入
|
||||
- 默认只触发一次
|
||||
|
||||
### 2.2 市场选择器
|
||||
|
||||
**筛选条件**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 体育类别 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [全部 ▼] │ │
|
||||
│ │ 选项: 全部 / NBA / NFL / 英超 / 西甲 / 棒球... │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 市场类型 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [全部 ▼] │ │
|
||||
│ │ 选项: 全部 / 胜负 / 让分 / 大小分 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 结束时间 │
|
||||
│ ○ 全部 ○ 今天 ○ 未来24小时 ○ 未来7天 │
|
||||
│ │
|
||||
│ 最小流动性 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [ ] USDC(留空表示不限制) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 搜索关键词 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [搜索球队、比赛...] [搜索] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**市场列表**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ○ Lakers vs Bulls - Who will win? │
|
||||
│ YES: 0.92 NO: 0.08 | 流动性: 50,000 USDC │
|
||||
│ 结束: 2024-03-07 15:30 (剩余2小时30分) │
|
||||
│ │
|
||||
│ ○ Warriors @ Heat - O/U 220.5 │
|
||||
│ Over: 0.55 Under: 0.45 | 流动性: 30,000 USDC │
|
||||
│ 结束: 2024-03-07 18:00 (剩余5小时) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 预估收益展示
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 预估收益 │
|
||||
│ ──────────────────────────────────────────────────────── │
|
||||
│ 买入价格: 0.90 | 买入金额: 10 USDC │
|
||||
│ 预计份额: 11.11 | 预计收益: +1.11 USDC (11.1%) │
|
||||
│ │
|
||||
│ 止盈 (0.98): 收益 +8.89 USDC (88.9%) │
|
||||
│ 止损 (0.85): 亏损 -1.67 USDC (-16.7%) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、触发记录页(全局)
|
||||
|
||||
### 3.1 页面布局
|
||||
|
||||
**路由**:`/sports-tail-strategy/records`
|
||||
|
||||
**说明**:全局记录列表,不是单个策略的记录
|
||||
|
||||
**桌面端表格**:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 触发记录 │
|
||||
├────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 筛选: [账户选择▼] [状态: 全部▼] [时间范围: 最近7天▼] │
|
||||
├────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 时间 │ 市场标题 │ 方向 │ 买入价 │ 金额 │ 卖出价 │ 盈亏 │
|
||||
│ 03-07 15:30 │ Lakers vs Bulls │ YES │ 0.91 │ 10.00 │ 0.98 │ +7.89 USDC │
|
||||
│ 03-07 14:20 │ Warriors @ Heat │ Over │ 0.55 │ 5.00 │ - │ 待结算 │
|
||||
│ 03-06 18:45 │ Celtics vs Heat │ NO │ 0.92 │ 20.00 │ 0.85 │ -7.00 USDC │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**移动端**:使用卡片布局
|
||||
|
||||
### 3.2 记录字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 时间 | `triggeredAt` 格式化显示 |
|
||||
| 市场标题 | `marketTitle`,可点击跳转 Polymarket |
|
||||
| 方向 | `outcomeName` 或 YES/NO/Over/Under |
|
||||
| 买入价 | `buyPrice` |
|
||||
| 金额 | `buyAmount` USDC |
|
||||
| 卖出价 | `sellPrice`,未卖出显示 `-` |
|
||||
| 盈亏 | `realizedPnl`,未结算显示 `待结算` |
|
||||
|
||||
### 3.3 筛选条件
|
||||
|
||||
| 条件 | 说明 |
|
||||
|------|------|
|
||||
| 账户 | 按账户筛选 |
|
||||
| 状态 | 全部 / 待结算 / 已止盈 / 已止损 / 已完成 |
|
||||
| 时间范围 | 最近24小时 / 最近7天 / 最近30天 / 全部 |
|
||||
|
||||
---
|
||||
|
||||
## 四、响应式适配
|
||||
|
||||
### 4.1 断点
|
||||
|
||||
- 移动端: < 768px
|
||||
- 桌面端: >= 768px
|
||||
|
||||
### 4.2 移动端适配
|
||||
|
||||
1. 列表使用卡片布局,信息可折叠
|
||||
2. 表单使用分步或滚动布局
|
||||
3. 触发记录使用卡片列表
|
||||
4. 按钮最小触摸目标 44x44px
|
||||
|
||||
---
|
||||
|
||||
## 五、多语言 Key
|
||||
|
||||
```
|
||||
sportsTailStrategy.list.title=体育尾盘策略
|
||||
sportsTailStrategy.list.addStrategy=新增策略
|
||||
sportsTailStrategy.list.filter.account=账户
|
||||
sportsTailStrategy.list.filter.category=类别
|
||||
sportsTailStrategy.list.filter.allCategory=全部
|
||||
sportsTailStrategy.list.triggerPrice=触发价
|
||||
sportsTailStrategy.list.amount=金额
|
||||
sportsTailStrategy.list.takeProfitStopLoss=止盈/止损
|
||||
sportsTailStrategy.list.filledPrice=成交价
|
||||
sportsTailStrategy.list.realtimePrice=实时价格
|
||||
sportsTailStrategy.list.shares=份
|
||||
sportsTailStrategy.list.pnl=盈亏
|
||||
sportsTailStrategy.list.pending=待结算
|
||||
sportsTailStrategy.list.viewRecords=查看记录
|
||||
sportsTailStrategy.list.delete=删除
|
||||
sportsTailStrategy.list.deleteConfirm=确定删除该策略吗?
|
||||
|
||||
sportsTailStrategy.form.title=新增体育尾盘策略
|
||||
sportsTailStrategy.form.account=账户
|
||||
sportsTailStrategy.form.selectAccount=选择账户
|
||||
sportsTailStrategy.form.selectMarket=选择市场
|
||||
sportsTailStrategy.form.triggerCondition=触发条件
|
||||
sportsTailStrategy.form.triggerPriceHelp=当任意方向价格达到触发价时买入
|
||||
sportsTailStrategy.form.amount=下注金额
|
||||
sportsTailStrategy.form.fixedAmount=固定金额
|
||||
sportsTailStrategy.form.ratio=余额比例
|
||||
sportsTailStrategy.form.autoSell=启用自动卖出
|
||||
sportsTailStrategy.form.takeProfitPrice=止盈价格
|
||||
sportsTailStrategy.form.takeProfitHelp=价格上涨到此值时自动卖出
|
||||
sportsTailStrategy.form.stopLossPrice=止损价格
|
||||
sportsTailStrategy.form.stopLossHelp=价格下跌到此值时自动卖出
|
||||
sportsTailStrategy.form.estimatedReturn=预估收益
|
||||
sportsTailStrategy.form.buyPrice=买入价格
|
||||
sportsTailStrategy.form.buyAmount=买入金额
|
||||
sportsTailStrategy.form.estimatedShares=预计份额
|
||||
sportsTailStrategy.form.estimatedPnl=预计收益
|
||||
|
||||
sportsTailStrategy.marketSearch.sport=体育类别
|
||||
sportsTailStrategy.marketSearch.marketType=市场类型
|
||||
sportsTailStrategy.marketSearch.endTime=结束时间
|
||||
sportsTailStrategy.marketSearch.minLiquidity=最小流动性
|
||||
sportsTailStrategy.marketSearch.keyword=搜索关键词
|
||||
sportsTailStrategy.marketSearch.search=搜索
|
||||
sportsTailStrategy.marketSearch.liquidity=流动性
|
||||
sportsTailStrategy.marketSearch.remaining=剩余
|
||||
|
||||
sportsTailStrategy.records.title=触发记录
|
||||
sportsTailStrategy.records.time=时间
|
||||
sportsTailStrategy.records.market=市场
|
||||
sportsTailStrategy.records.direction=方向
|
||||
sportsTailStrategy.records.buyPrice=买入价
|
||||
sportsTailStrategy.records.amount=金额
|
||||
sportsTailStrategy.records.sellPrice=卖出价
|
||||
sportsTailStrategy.records.pnl=盈亏
|
||||
sportsTailStrategy.records.pending=待结算
|
||||
sportsTailStrategy.records.takeProfit=已止盈
|
||||
sportsTailStrategy.records.stopLoss=已止损
|
||||
```
|
||||
+2
-10
@@ -37,11 +37,11 @@ import BacktestList from './pages/BacktestList'
|
||||
import BacktestDetail from './pages/BacktestDetail'
|
||||
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
|
||||
import CryptoTailMonitor from './pages/CryptoTailMonitor'
|
||||
import SportsTailStrategyList from './pages/SportsTailStrategyList'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
import { apiService } from './services/api'
|
||||
import { hasToken } from './utils'
|
||||
import ClobMigrationModal, { STORAGE_KEY as CLOB_MIGRATION_KEY } from './components/ClobMigrationModal'
|
||||
|
||||
/**
|
||||
* 路由保护组件
|
||||
@@ -65,7 +65,6 @@ function App() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [isFirstUse, setIsFirstUse] = useState<boolean | null>(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
const [clobMigrationVisible, setClobMigrationVisible] = useState(false)
|
||||
|
||||
// 根据当前语言设置 Ant Design 的 locale
|
||||
const getAntdLocale = () => {
|
||||
@@ -201,13 +200,6 @@ function App() {
|
||||
wsManager.disconnect()
|
||||
}
|
||||
}, [checking, isFirstUse])
|
||||
|
||||
// 已登录且未查看过 CLOB V2 迁移提醒时显示弹窗
|
||||
useEffect(() => {
|
||||
if (!checking && isFirstUse === false && hasToken() && !localStorage.getItem(CLOB_MIGRATION_KEY)) {
|
||||
setClobMigrationVisible(true)
|
||||
}
|
||||
}, [checking, isFirstUse])
|
||||
|
||||
// 订阅订单推送并显示全局通知
|
||||
useEffect(() => {
|
||||
@@ -273,6 +265,7 @@ function App() {
|
||||
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
|
||||
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
|
||||
<Route path="/crypto-tail-monitor" element={<ProtectedRoute><CryptoTailMonitor /></ProtectedRoute>} />
|
||||
<Route path="/sports-tail-strategy" element={<ProtectedRoute><SportsTailStrategyList /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
||||
{/* 保留旧路由以保持向后兼容 */}
|
||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
||||
@@ -293,7 +286,6 @@ function App() {
|
||||
{/* 默认重定向到登录页 */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
<ClobMigrationModal open={clobMigrationVisible} onClose={() => setClobMigrationVisible(false)} />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
)
|
||||
|
||||
@@ -525,7 +525,7 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
</Space>
|
||||
{!option.error && (
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ant-color-primary)' }}>
|
||||
${formatUSDC(option.totalBalance)}
|
||||
{formatUSDC(option.totalBalance)} USDC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -247,7 +247,7 @@ const AccountSetupStatusBlock: React.FC<AccountSetupStatusBlockProps> = ({
|
||||
const displayText = isUnlimited
|
||||
? t('accountSetup.approvalDetails.unlimited')
|
||||
: isApproved
|
||||
? `$${parseFloat(allowance).toFixed(2)}`
|
||||
? `${parseFloat(allowance).toFixed(2)} USDC`
|
||||
: t('accountSetup.approvalDetails.notApproved')
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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 }
|
||||
@@ -1,139 +0,0 @@
|
||||
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
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
NotificationOutlined,
|
||||
LineChartOutlined,
|
||||
RocketOutlined,
|
||||
DashboardOutlined
|
||||
DashboardOutlined,
|
||||
TrophyOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -31,7 +32,6 @@ import { removeToken, getVersionText, getVersionInfo } from '../utils'
|
||||
import { wsManager } from '../services/websocket'
|
||||
import { apiClient } from '../services/api'
|
||||
import Logo from './Logo'
|
||||
import GeoblockStatusTrigger from './GeoblockStatusTrigger'
|
||||
|
||||
const { Header, Content, Sider } = AntLayout
|
||||
|
||||
@@ -78,7 +78,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor') || path.startsWith('/sports-tail-strategy')) {
|
||||
keys.push('/crypto-tail-management')
|
||||
}
|
||||
if (path.startsWith('/system-settings')) {
|
||||
@@ -96,7 +96,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor') || path.startsWith('/sports-tail-strategy')) {
|
||||
keys.push('/crypto-tail-management')
|
||||
}
|
||||
if (path.startsWith('/system-settings')) {
|
||||
@@ -180,6 +180,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
key: '/crypto-tail-monitor',
|
||||
icon: <DashboardOutlined />,
|
||||
label: t('menu.cryptoTailMonitor')
|
||||
},
|
||||
{
|
||||
key: '/sports-tail-strategy',
|
||||
icon: <TrophyOutlined />,
|
||||
label: t('menu.sportsTailStrategy')
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -347,7 +352,6 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
>
|
||||
<SendOutlined />
|
||||
</a>
|
||||
<GeoblockStatusTrigger iconSize={16} dotSize={10} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined />}
|
||||
@@ -395,9 +399,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
@@ -477,7 +479,6 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
>
|
||||
<SendOutlined />
|
||||
</a>
|
||||
<GeoblockStatusTrigger iconSize={18} dotSize={12} />
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
@@ -488,8 +489,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
height: 'calc(100vh - 100px)',
|
||||
borderRight: 0,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
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
|
||||
@@ -1,105 +0,0 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,7 @@
|
||||
"cryptoSpreadStrategy": "Crypto Spread Strategy",
|
||||
"cryptoTailStrategy": "Strategy Config",
|
||||
"cryptoTailMonitor": "Real-time Monitor",
|
||||
"sportsTailStrategy": "Sports Tail Strategy",
|
||||
"positions": "Position Management",
|
||||
"backtest": "Backtest",
|
||||
"statistics": "Statistics",
|
||||
@@ -381,21 +382,7 @@
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"getFailed": "Failed to get proxy configuration",
|
||||
"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}})"
|
||||
"latency": "Latency"
|
||||
},
|
||||
"configPage": {
|
||||
"title": "Global Configuration",
|
||||
@@ -1300,36 +1287,6 @@
|
||||
"webhookUrlPlaceholder": "Webhook URL (from Slack App settings)",
|
||||
"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": {
|
||||
"title": "Announcements",
|
||||
"noAnnouncements": "No announcements",
|
||||
@@ -1735,6 +1692,79 @@
|
||||
"empty": "No settled orders yet, cannot show PnL curve"
|
||||
}
|
||||
},
|
||||
"sportsTailStrategy": {
|
||||
"list": {
|
||||
"title": "Sports Tail Strategy",
|
||||
"addStrategy": "Add Strategy",
|
||||
"filter": {
|
||||
"account": "Account",
|
||||
"category": "Category",
|
||||
"allCategory": "All"
|
||||
},
|
||||
"triggerPrice": "Trigger Price",
|
||||
"amount": "Amount",
|
||||
"takeProfitStopLoss": "Take Profit / Stop Loss",
|
||||
"filledPrice": "Filled Price",
|
||||
"realtimePrice": "Realtime Price",
|
||||
"shares": "Shares",
|
||||
"pnl": "PnL",
|
||||
"pending": "Pending",
|
||||
"viewRecords": "View Records",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Delete this strategy?",
|
||||
"fetchFailed": "Failed to fetch list"
|
||||
},
|
||||
"form": {
|
||||
"title": "Add Sports Tail Strategy",
|
||||
"account": "Account",
|
||||
"selectAccount": "Select Account",
|
||||
"selectMarket": "Select Market",
|
||||
"triggerCondition": "Trigger Condition",
|
||||
"triggerPriceHelp": "Buy when either side reaches trigger price",
|
||||
"amount": "Amount",
|
||||
"fixedAmount": "Fixed Amount",
|
||||
"ratio": "Balance Ratio",
|
||||
"autoSell": "Enable Auto Sell",
|
||||
"takeProfitPrice": "Take Profit Price",
|
||||
"takeProfitHelp": "Sell when price rises to this value",
|
||||
"stopLossPrice": "Stop Loss Price",
|
||||
"stopLossHelp": "Sell when price falls to this value",
|
||||
"estimatedReturn": "Estimated Return",
|
||||
"buyPrice": "Buy Price",
|
||||
"buyAmount": "Buy Amount",
|
||||
"estimatedShares": "Estimated Shares",
|
||||
"estimatedPnl": "Estimated PnL",
|
||||
"createSuccess": "Strategy created",
|
||||
"createFailed": "Failed to create strategy"
|
||||
},
|
||||
"marketSearch": {
|
||||
"sport": "Sport",
|
||||
"marketType": "Market Type",
|
||||
"endTime": "End Time",
|
||||
"minLiquidity": "Min Liquidity",
|
||||
"keyword": "Keyword",
|
||||
"search": "Search",
|
||||
"liquidity": "Liquidity",
|
||||
"remaining": "Remaining",
|
||||
"all": "All",
|
||||
"today": "Today",
|
||||
"next24h": "Next 24 Hours",
|
||||
"next7days": "Next 7 Days"
|
||||
},
|
||||
"records": {
|
||||
"title": "Trigger Records",
|
||||
"time": "Time",
|
||||
"market": "Market",
|
||||
"direction": "Direction",
|
||||
"buyPrice": "Buy Price",
|
||||
"amount": "Amount",
|
||||
"sellPrice": "Sell Price",
|
||||
"pnl": "PnL",
|
||||
"pending": "Pending",
|
||||
"takeProfit": "Take Profit",
|
||||
"stopLoss": "Stop Loss"
|
||||
}
|
||||
},
|
||||
"cryptoTailMonitor": {
|
||||
"title": "Crypto Spread Strategy Monitor",
|
||||
"selectStrategy": "Strategy",
|
||||
@@ -1845,14 +1875,5 @@
|
||||
"maxSizeUpdated": "Updated to max size",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,7 @@
|
||||
"cryptoSpreadStrategy": "加密价差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "实时监控",
|
||||
"sportsTailStrategy": "体育尾盘策略",
|
||||
"positions": "仓位管理",
|
||||
"backtest": "回测",
|
||||
"statistics": "统计信息",
|
||||
@@ -366,25 +367,11 @@
|
||||
"passwordHelpUpdate": "留空则不更新密码,输入新密码则更新",
|
||||
"check": "检查代理",
|
||||
"checkSuccess": "代理检查成功",
|
||||
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
|
||||
"checkFailed": "代理检查失败",
|
||||
"geoblockTitle": "地域检测",
|
||||
"geoblockAvailable": "出口 IP 可下单({{location}})",
|
||||
"geoblockBlocked": "出口 IP 受限,无法下单({{location}})",
|
||||
"saveSuccess": "保存配置成功",
|
||||
"saveFailed": "保存配置失败",
|
||||
"getFailed": "获取代理配置失败",
|
||||
"latency": "延迟",
|
||||
"checkResult": {
|
||||
"connection": "代理连接",
|
||||
"connected": "正常",
|
||||
"failed": "失败",
|
||||
"latency": "响应延迟",
|
||||
"detail": "详情",
|
||||
"geoblockOk": "可下单",
|
||||
"geoblockBlocked": "地域受限",
|
||||
"geoblockUnknown": "检测异常"
|
||||
},
|
||||
"hostInvalid": "请输入有效的主机地址",
|
||||
"portInvalid": "端口必须在 1-65535 之间"
|
||||
},
|
||||
@@ -1300,36 +1287,6 @@
|
||||
"webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)",
|
||||
"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": {
|
||||
"title": "公告",
|
||||
"noAnnouncements": "暂无公告",
|
||||
@@ -1735,6 +1692,79 @@
|
||||
"empty": "暂无已结算订单,无法展示收益曲线"
|
||||
}
|
||||
},
|
||||
"sportsTailStrategy": {
|
||||
"list": {
|
||||
"title": "体育尾盘策略",
|
||||
"addStrategy": "新增策略",
|
||||
"filter": {
|
||||
"account": "账户",
|
||||
"category": "类别",
|
||||
"allCategory": "全部"
|
||||
},
|
||||
"triggerPrice": "触发价",
|
||||
"amount": "金额",
|
||||
"takeProfitStopLoss": "止盈/止损",
|
||||
"filledPrice": "成交价",
|
||||
"realtimePrice": "实时价格",
|
||||
"shares": "份",
|
||||
"pnl": "盈亏",
|
||||
"pending": "待结算",
|
||||
"viewRecords": "查看记录",
|
||||
"delete": "删除",
|
||||
"deleteConfirm": "确定删除该策略吗?",
|
||||
"fetchFailed": "获取列表失败"
|
||||
},
|
||||
"form": {
|
||||
"title": "新增体育尾盘策略",
|
||||
"account": "账户",
|
||||
"selectAccount": "选择账户",
|
||||
"selectMarket": "选择市场",
|
||||
"triggerCondition": "触发条件",
|
||||
"triggerPriceHelp": "当任意方向价格达到触发价时买入",
|
||||
"amount": "下注金额",
|
||||
"fixedAmount": "固定金额",
|
||||
"ratio": "余额比例",
|
||||
"autoSell": "启用自动卖出",
|
||||
"takeProfitPrice": "止盈价格",
|
||||
"takeProfitHelp": "价格上涨到此值时自动卖出",
|
||||
"stopLossPrice": "止损价格",
|
||||
"stopLossHelp": "价格下跌到此值时自动卖出",
|
||||
"estimatedReturn": "预估收益",
|
||||
"buyPrice": "买入价格",
|
||||
"buyAmount": "买入金额",
|
||||
"estimatedShares": "预计份额",
|
||||
"estimatedPnl": "预计收益",
|
||||
"createSuccess": "策略创建成功",
|
||||
"createFailed": "策略创建失败"
|
||||
},
|
||||
"marketSearch": {
|
||||
"sport": "体育类别",
|
||||
"marketType": "市场类型",
|
||||
"endTime": "结束时间",
|
||||
"minLiquidity": "最小流动性",
|
||||
"keyword": "搜索关键词",
|
||||
"search": "搜索",
|
||||
"liquidity": "流动性",
|
||||
"remaining": "剩余",
|
||||
"all": "全部",
|
||||
"today": "今天",
|
||||
"next24h": "未来24小时",
|
||||
"next7days": "未来7天"
|
||||
},
|
||||
"records": {
|
||||
"title": "触发记录",
|
||||
"time": "时间",
|
||||
"market": "市场",
|
||||
"direction": "方向",
|
||||
"buyPrice": "买入价",
|
||||
"amount": "金额",
|
||||
"sellPrice": "卖出价",
|
||||
"pnl": "盈亏",
|
||||
"pending": "待结算",
|
||||
"takeProfit": "已止盈",
|
||||
"stopLoss": "已止损"
|
||||
}
|
||||
},
|
||||
"cryptoTailMonitor": {
|
||||
"title": "加密价差策略监控",
|
||||
"selectStrategy": "选择策略",
|
||||
@@ -1845,14 +1875,5 @@
|
||||
"maxSizeUpdated": "已更新为最大数量",
|
||||
"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": "知道了"
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,7 @@
|
||||
"cryptoSpreadStrategy": "加密價差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "即時監控",
|
||||
"sportsTailStrategy": "體育尾盤策略",
|
||||
"positions": "倉位管理",
|
||||
"backtest": "回測",
|
||||
"statistics": "統計信息",
|
||||
@@ -381,21 +382,7 @@
|
||||
"saveSuccess": "保存配置成功",
|
||||
"saveFailed": "保存配置失敗",
|
||||
"getFailed": "獲取代理配置失敗",
|
||||
"latency": "延遲",
|
||||
"checkResult": {
|
||||
"connection": "代理連線",
|
||||
"connected": "正常",
|
||||
"failed": "失敗",
|
||||
"latency": "回應延遲",
|
||||
"detail": "詳情",
|
||||
"geoblockOk": "可下單",
|
||||
"geoblockBlocked": "地域受限",
|
||||
"geoblockUnknown": "檢測異常"
|
||||
},
|
||||
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
|
||||
"geoblockTitle": "地域檢測",
|
||||
"geoblockAvailable": "出口 IP 可下單({{location}})",
|
||||
"geoblockBlocked": "出口 IP 受限,無法下單({{location}})"
|
||||
"latency": "延遲"
|
||||
},
|
||||
"configPage": {
|
||||
"title": "全局配置",
|
||||
@@ -1300,36 +1287,6 @@
|
||||
"webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)",
|
||||
"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": {
|
||||
"title": "公告",
|
||||
"noAnnouncements": "暫無公告",
|
||||
@@ -1735,6 +1692,79 @@
|
||||
"empty": "暫無已結算訂單,無法展示收益曲線"
|
||||
}
|
||||
},
|
||||
"sportsTailStrategy": {
|
||||
"list": {
|
||||
"title": "體育尾盤策略",
|
||||
"addStrategy": "新增策略",
|
||||
"filter": {
|
||||
"account": "賬戶",
|
||||
"category": "類別",
|
||||
"allCategory": "全部"
|
||||
},
|
||||
"triggerPrice": "觸發價",
|
||||
"amount": "金額",
|
||||
"takeProfitStopLoss": "止盈/止損",
|
||||
"filledPrice": "成交價",
|
||||
"realtimePrice": "實時價格",
|
||||
"shares": "份",
|
||||
"pnl": "盈虧",
|
||||
"pending": "待結算",
|
||||
"viewRecords": "查看記錄",
|
||||
"delete": "刪除",
|
||||
"deleteConfirm": "確定刪除該策略嗎?",
|
||||
"fetchFailed": "獲取列表失敗"
|
||||
},
|
||||
"form": {
|
||||
"title": "新增體育尾盤策略",
|
||||
"account": "賬戶",
|
||||
"selectAccount": "選擇賬戶",
|
||||
"selectMarket": "選擇市場",
|
||||
"triggerCondition": "觸發條件",
|
||||
"triggerPriceHelp": "當任意方向價格達到觸發價時買入",
|
||||
"amount": "下注金額",
|
||||
"fixedAmount": "固定金額",
|
||||
"ratio": "餘額比例",
|
||||
"autoSell": "啟用自動賣出",
|
||||
"takeProfitPrice": "止盈價格",
|
||||
"takeProfitHelp": "價格上漲到此值時自動賣出",
|
||||
"stopLossPrice": "止損價格",
|
||||
"stopLossHelp": "價格下跌到此值時自動賣出",
|
||||
"estimatedReturn": "預估收益",
|
||||
"buyPrice": "買入價格",
|
||||
"buyAmount": "買入金額",
|
||||
"estimatedShares": "預計份額",
|
||||
"estimatedPnl": "預計收益",
|
||||
"createSuccess": "策略創建成功",
|
||||
"createFailed": "策略創建失敗"
|
||||
},
|
||||
"marketSearch": {
|
||||
"sport": "體育類別",
|
||||
"marketType": "市場類型",
|
||||
"endTime": "結束時間",
|
||||
"minLiquidity": "最小流動性",
|
||||
"keyword": "搜索關鍵詞",
|
||||
"search": "搜索",
|
||||
"liquidity": "流動性",
|
||||
"remaining": "剩餘",
|
||||
"all": "全部",
|
||||
"today": "今天",
|
||||
"next24h": "未來24小時",
|
||||
"next7days": "未來7天"
|
||||
},
|
||||
"records": {
|
||||
"title": "觸發記錄",
|
||||
"time": "時間",
|
||||
"market": "市場",
|
||||
"direction": "方向",
|
||||
"buyPrice": "買入價",
|
||||
"amount": "金額",
|
||||
"sellPrice": "賣出價",
|
||||
"pnl": "盈虧",
|
||||
"pending": "待結算",
|
||||
"takeProfit": "已止盈",
|
||||
"stopLoss": "已止損"
|
||||
}
|
||||
},
|
||||
"cryptoTailMonitor": {
|
||||
"title": "加密價差策略監控",
|
||||
"selectStrategy": "選擇策略",
|
||||
@@ -1845,14 +1875,5 @@
|
||||
"maxSizeUpdated": "已更新為最大數量",
|
||||
"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": "知道了"
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,7 @@ const AccountDetail: React.FC = () => {
|
||||
<Spin size="small" />
|
||||
) : balance ? (
|
||||
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
|
||||
${formatUSDC(balance)}
|
||||
{formatUSDC(balance)} USDC
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
@@ -274,7 +274,7 @@ const AccountDetail: React.FC = () => {
|
||||
fontWeight: 'bold',
|
||||
color: account.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
|
||||
}}>
|
||||
${formatUSDC(account.totalPnl)}
|
||||
{formatUSDC(account.totalPnl)} USDC
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, ReloadOutlined, EditOutlined, CopyOutlined, EyeOutlined, DeleteOutlined, WalletOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import type { Account } from '../types'
|
||||
@@ -8,7 +8,6 @@ import { useMediaQuery } from 'react-responsive'
|
||||
import { formatUSDC } from '../utils'
|
||||
import AccountImportForm from '../components/AccountImportForm'
|
||||
import AccountSetupStatusBlock from '../components/AccountSetupStatusBlock'
|
||||
import apiService from '../services/api'
|
||||
|
||||
const { Title } = Typography
|
||||
|
||||
@@ -28,57 +27,11 @@ const AccountList: React.FC = () => {
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [accountImportModalVisible, setAccountImportModalVisible] = useState(false)
|
||||
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(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
// 首次进入且有账户时显示迁移引导
|
||||
useEffect(() => {
|
||||
if (!loading && accounts.length > 0 && !localStorage.getItem(ACCOUNT_GUIDE_KEY)) {
|
||||
setMigrationGuideVisible(true)
|
||||
}
|
||||
}, [loading, accounts.length])
|
||||
|
||||
const handleAccountImportSuccess = async () => {
|
||||
message.success(t('accountImport.importSuccess'))
|
||||
setAccountImportModalVisible(false)
|
||||
@@ -372,7 +325,7 @@ const AccountList: React.FC = () => {
|
||||
}
|
||||
const balanceObj = balanceMap[record.id]
|
||||
const balance = balanceObj?.total || record.balance || '-'
|
||||
return balance && balance !== '-' && typeof balance === 'string' ? `$${formatUSDC(balance)}` : '-'
|
||||
return balance && balance !== '-' && typeof balance === 'string' ? `${formatUSDC(balance)} USDC` : '-'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -421,26 +374,6 @@ const AccountList: React.FC = () => {
|
||||
</div>
|
||||
</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
|
||||
title={t('accountList.deleteConfirm')}
|
||||
description={
|
||||
@@ -505,38 +438,6 @@ const AccountList: React.FC = () => {
|
||||
</Tooltip>
|
||||
</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={{
|
||||
margin: isMobile ? '0 -8px' : '0',
|
||||
borderRadius: isMobile ? '0' : undefined
|
||||
@@ -603,7 +504,7 @@ const AccountList: React.FC = () => {
|
||||
{t('accountList.totalBalance')}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
|
||||
{balance?.total && balance.total !== '-' ? `$${formatUSDC(balance.total)}` : '-'}
|
||||
{balance?.total && balance.total !== '-' ? `${formatUSDC(balance.total)} USDC` : '- USDC'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
@@ -667,16 +568,6 @@ const AccountList: React.FC = () => {
|
||||
</div>
|
||||
</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
|
||||
title={t('accountList.deleteConfirm')}
|
||||
description={
|
||||
@@ -837,7 +728,7 @@ const AccountList: React.FC = () => {
|
||||
<Spin size="small" />
|
||||
) : detailBalance ? (
|
||||
<span style={{ fontWeight: 'bold', color: '#1890ff', fontSize: '16px' }}>
|
||||
${formatUSDC(detailBalance.total)}
|
||||
{formatUSDC(detailBalance.total)} USDC
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
@@ -848,7 +739,7 @@ const AccountList: React.FC = () => {
|
||||
<Spin size="small" />
|
||||
) : detailBalance ? (
|
||||
<span style={{ color: '#52c41a' }}>
|
||||
${formatUSDC(detailBalance.available)}
|
||||
{formatUSDC(detailBalance.available)} USDC
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
@@ -859,7 +750,7 @@ const AccountList: React.FC = () => {
|
||||
<Spin size="small" />
|
||||
) : detailBalance ? (
|
||||
<span style={{ color: '#1890ff' }}>
|
||||
${formatUSDC(detailBalance.position)}
|
||||
{formatUSDC(detailBalance.position)} USDC
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
@@ -915,7 +806,7 @@ const AccountList: React.FC = () => {
|
||||
fontWeight: 'bold',
|
||||
color: detailAccount.totalPnl && detailAccount.totalPnl.startsWith('-') ? '#ff4d4f' : '#52c41a'
|
||||
}}>
|
||||
${formatUSDC(detailAccount.totalPnl)}
|
||||
{formatUSDC(detailAccount.totalPnl)} USDC
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface Reactions {
|
||||
|
||||
@@ -10,8 +10,6 @@ interface BacktestChartProps {
|
||||
}[]
|
||||
}
|
||||
|
||||
// Bug #39 Note: This chart currently displays cash balance (balanceAfter), not total equity.
|
||||
// A true equity curve (cash + position value) would require an equityAfter field in the trade records.
|
||||
const BacktestChart: React.FC<BacktestChartProps> = ({ trades }) => {
|
||||
const { t } = useTranslation()
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
@@ -75,9 +73,9 @@ const BacktestChart: React.FC<BacktestChartProps> = ({ trades }) => {
|
||||
return `
|
||||
<div>
|
||||
<div>${t('backtest.tradeTime')}: ${param.name}</div>
|
||||
<div>${t('backtest.balanceAfter')}: $${value}</div>
|
||||
<div>${t('backtest.balanceAfter')}: ${value} USDC</div>
|
||||
<div style="color: ${color}">
|
||||
${t('backtest.profitLoss')}: $${diff} (${diffPercent}%)
|
||||
${t('backtest.profitLoss')}: ${diff} USDC (${diffPercent}%)
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
@@ -121,7 +119,7 @@ const BacktestChart: React.FC<BacktestChartProps> = ({ trades }) => {
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '$',
|
||||
name: 'USDC',
|
||||
nameLocation: 'end',
|
||||
nameGap: 10,
|
||||
axisLabel: {
|
||||
|
||||
@@ -279,14 +279,14 @@ const BacktestDetail: React.FC = () => {
|
||||
render: (value: string) => parseFloat(value).toFixed(4)
|
||||
},
|
||||
{
|
||||
title: t('backtest.amount') + ' ($)',
|
||||
title: t('backtest.amount') + ' (USDC)',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 120,
|
||||
render: (value: string) => formatUSDC(value)
|
||||
},
|
||||
{
|
||||
title: t('backtest.balanceAfter') + ' ($)',
|
||||
title: t('backtest.balanceAfter') + ' (USDC)',
|
||||
dataIndex: 'balanceAfter',
|
||||
key: 'balanceAfter',
|
||||
width: 120,
|
||||
@@ -346,14 +346,14 @@ const BacktestDetail: React.FC = () => {
|
||||
{task.leaderName || task.leaderAddress}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.initialBalance')}>
|
||||
${formatUSDC(task.initialBalance)}
|
||||
{formatUSDC(task.initialBalance)} USDC
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.finalBalance')}>
|
||||
{task.finalBalance ? '$' + formatUSDC(task.finalBalance) : '-'}
|
||||
{task.finalBalance ? formatUSDC(task.finalBalance) + ' USDC' : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.profitAmount')}>
|
||||
<span style={{ color: task.profitAmount && parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>
|
||||
{task.profitAmount ? '$' + formatUSDC(task.profitAmount) : '-'}
|
||||
{task.profitAmount ? formatUSDC(task.profitAmount) + ' USDC' : '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.profitRate')}>
|
||||
|
||||
@@ -461,14 +461,14 @@ const BacktestList: React.FC = () => {
|
||||
render: (value: string) => parseFloat(value).toFixed(4)
|
||||
},
|
||||
{
|
||||
title: t('backtest.amount') + ' ($)',
|
||||
title: t('backtest.amount') + ' (USDC)',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 120,
|
||||
render: (value: string) => formatUSDC(value)
|
||||
},
|
||||
{
|
||||
title: t('backtest.balanceAfter') + ' ($)',
|
||||
title: t('backtest.balanceAfter') + ' (USDC)',
|
||||
dataIndex: 'balanceAfter',
|
||||
key: 'balanceAfter',
|
||||
width: 120,
|
||||
@@ -815,7 +815,7 @@ const BacktestList: React.FC = () => {
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('backtest.profitAmount')}</div>
|
||||
{task.profitAmount != null ? (
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>${formatUSDC(task.profitAmount)}</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: parseFloat(task.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>{formatUSDC(task.profitAmount)} USDC</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
|
||||
)}
|
||||
@@ -997,7 +997,7 @@ const BacktestList: React.FC = () => {
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.initialBalance') + ' ($)'}
|
||||
label={t('backtest.initialBalance') + ' (USDC)'}
|
||||
name="initialBalance"
|
||||
rules={[
|
||||
{ required: true, message: t('backtest.initialBalanceRequired') || '请输入初始资金' },
|
||||
@@ -1082,7 +1082,7 @@ const BacktestList: React.FC = () => {
|
||||
|
||||
{copyMode === 'FIXED' && (
|
||||
<Form.Item
|
||||
label={t('backtest.fixedAmount') + ' ($)'}
|
||||
label={t('backtest.fixedAmount') + ' (USDC)'}
|
||||
name="fixedAmount"
|
||||
rules={[
|
||||
{ required: true, message: t('backtest.fixedAmountRequired') || '请输入固定金额' },
|
||||
@@ -1101,7 +1101,7 @@ const BacktestList: React.FC = () => {
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxOrderSize') + ' ($)'}
|
||||
label={t('backtest.maxOrderSize') + ' (USDC)'}
|
||||
name="maxOrderSize"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
@@ -1110,7 +1110,7 @@ const BacktestList: React.FC = () => {
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.minOrderSize') + ' ($)'}
|
||||
label={t('backtest.minOrderSize') + ' (USDC)'}
|
||||
name="minOrderSize"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
@@ -1122,7 +1122,7 @@ const BacktestList: React.FC = () => {
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} sm={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('backtest.maxDailyLoss') + ' ($)'}
|
||||
label={t('backtest.maxDailyLoss') + ' (USDC)'}
|
||||
name="maxDailyLoss"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
@@ -1141,7 +1141,7 @@ const BacktestList: React.FC = () => {
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label={t('backtest.maxPositionValue') + ' ($)'}
|
||||
label={t('backtest.maxPositionValue') + ' (USDC)'}
|
||||
name="maxPositionValue"
|
||||
>
|
||||
<InputNumber
|
||||
@@ -1316,14 +1316,14 @@ const BacktestList: React.FC = () => {
|
||||
{detailTask.leaderName || `Leader ${detailTask.leaderId}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.initialBalance')}>
|
||||
${formatUSDC(detailTask.initialBalance)}
|
||||
{formatUSDC(detailTask.initialBalance)} USDC
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.finalBalance')}>
|
||||
{detailTask.finalBalance ? '$' + formatUSDC(detailTask.finalBalance) : '-'}
|
||||
{detailTask.finalBalance ? formatUSDC(detailTask.finalBalance) + ' USDC' : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.profitAmount')}>
|
||||
<span style={{ color: detailTask.profitAmount && parseFloat(detailTask.profitAmount) >= 0 ? '#52c41a' : '#ff4d4f' }}>
|
||||
{detailTask.profitAmount ? '$' + formatUSDC(detailTask.profitAmount) : '-'}
|
||||
{detailTask.profitAmount ? formatUSDC(detailTask.profitAmount) + ' USDC' : '-'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.profitRate')}>
|
||||
@@ -1439,17 +1439,17 @@ const BacktestList: React.FC = () => {
|
||||
<Descriptions.Item label={t('backtest.copyMode')}>
|
||||
{detailConfig.copyMode === 'RATIO'
|
||||
? `${t('backtest.copyModeRatio')} ${parseFloat(detailConfig.copyRatio) * 100}%`
|
||||
: `${t('backtest.copyModeFixed')} $${formatUSDC(detailConfig.fixedAmount)}`
|
||||
: `${t('backtest.copyModeFixed')} ${formatUSDC(detailConfig.fixedAmount)} USDC`
|
||||
}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.maxOrderSize')}>
|
||||
{'$' + formatUSDC(detailConfig.maxOrderSize)}
|
||||
{formatUSDC(detailConfig.maxOrderSize)} USDC
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.minOrderSize')}>
|
||||
{'$' + formatUSDC(detailConfig.minOrderSize)}
|
||||
{formatUSDC(detailConfig.minOrderSize)} USDC
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.maxDailyLoss')}>
|
||||
{'$' + formatUSDC(detailConfig.maxDailyLoss)}
|
||||
{formatUSDC(detailConfig.maxDailyLoss)} USDC
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('backtest.maxDailyOrders')}>
|
||||
{detailConfig.maxDailyOrders}
|
||||
@@ -1471,7 +1471,7 @@ const BacktestList: React.FC = () => {
|
||||
)}
|
||||
{detailConfig.maxPositionValue && (
|
||||
<Descriptions.Item label={t('backtest.maxPositionValue')}>
|
||||
{'$' + formatUSDC(detailConfig.maxPositionValue)}
|
||||
{formatUSDC(detailConfig.maxPositionValue)} USDC
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{(detailConfig.minPrice || detailConfig.maxPrice) && (
|
||||
|
||||
@@ -150,7 +150,7 @@ const CopyTradingBuyOrdersPage: React.FC = () => {
|
||||
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
|
||||
return (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`}
|
||||
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -305,7 +305,7 @@ const CopyTradingBuyOrdersPage: React.FC = () => {
|
||||
数量: {formatUSDC(order.quantity)} | 价格: {formatUSDC(order.price)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
|
||||
金额: ${formatUSDC(amount)}
|
||||
金额: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ const CopyTradingList: React.FC = () => {
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{getPnlIcon(stats.totalPnl)}
|
||||
{isMobile ? formatUSDC(stats.totalPnl) : `$${formatUSDC(stats.totalPnl)}`}
|
||||
{isMobile ? formatUSDC(stats.totalPnl) : `${formatUSDC(stats.totalPnl)} USDC`}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div style={{
|
||||
@@ -520,7 +520,7 @@ const CopyTradingList: React.FC = () => {
|
||||
<div style={{ fontSize: '12px', opacity: '0.9' }}>
|
||||
{record.copyMode === 'RATIO'
|
||||
? `${t('copyTradingList.ratioMode') || '比例'} ${(parseFloat(record.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
|
||||
: `${t('copyTradingList.fixedAmountMode') || '固定'} $${formatUSDC(record.fixedAmount || '0')}`
|
||||
: `${t('copyTradingList.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,7 +549,7 @@ const CopyTradingList: React.FC = () => {
|
||||
gap: '4px'
|
||||
}}>
|
||||
{getPnlIcon(stats.totalPnl)}
|
||||
${formatUSDC(stats.totalPnl)}
|
||||
{formatUSDC(stats.totalPnl)} USDC
|
||||
</div>
|
||||
) : loadingStatistics.has(record.id) ? (
|
||||
<Spin size="small" />
|
||||
|
||||
@@ -130,7 +130,7 @@ const CopyTradingMatchedOrdersPage: React.FC = () => {
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`}
|
||||
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -262,7 +262,7 @@ const CopyTradingMatchedOrdersPage: React.FC = () => {
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
${formatUSDC(order.realizedPnl)}
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -518,7 +518,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
value={parseFloat(leaderAssetInfo.total)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Col>
|
||||
@@ -528,7 +528,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
value={parseFloat(leaderAssetInfo.available)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#1890ff', fontSize: '14px' }}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Col>
|
||||
@@ -538,7 +538,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
value={parseFloat(leaderAssetInfo.position)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#722ed1', fontSize: '14px' }}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Col>
|
||||
@@ -606,7 +606,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
|
||||
{copyMode === 'FIXED' && (
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.fixedAmount') || '固定跟单金额 ($)'}
|
||||
label={t('copyTradingAdd.fixedAmount') || '固定跟单金额 (USDC)'}
|
||||
name="fixedAmount"
|
||||
rules={[
|
||||
{ required: true, message: t('copyTradingAdd.fixedAmountRequired') || '请输入固定跟单金额' },
|
||||
@@ -645,7 +645,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
{copyMode === 'RATIO' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.maxOrderSize') || '单笔订单最大金额 ($)'}
|
||||
label={t('copyTradingAdd.maxOrderSize') || '单笔订单最大金额 (USDC)'}
|
||||
name="maxOrderSize"
|
||||
tooltip={t('copyTradingAdd.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
|
||||
>
|
||||
@@ -665,7 +665,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.minOrderSize') || '单笔订单最小金额 ($)'}
|
||||
label={t('copyTradingAdd.minOrderSize') || '单笔订单最小金额 (USDC)'}
|
||||
name="minOrderSize"
|
||||
tooltip={t('copyTradingAdd.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'}
|
||||
rules={[
|
||||
@@ -700,7 +700,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.maxDailyLoss') || '每日最大亏损限制 ($)'}
|
||||
label={t('copyTradingAdd.maxDailyLoss') || '每日最大亏损限制 (USDC)'}
|
||||
name="maxDailyLoss"
|
||||
tooltip={t('copyTradingAdd.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
|
||||
>
|
||||
@@ -709,7 +709,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingAdd.maxDailyLossPlaceholder') || '默认 10000 $(可选)'}
|
||||
placeholder={t('copyTradingAdd.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
@@ -767,7 +767,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.minOrderDepth') || '最小订单深度 ($)'}
|
||||
label={t('copyTradingAdd.minOrderDepth') || '最小订单深度 (USDC)'}
|
||||
name="minOrderDepth"
|
||||
tooltip={t('copyTradingAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
|
||||
>
|
||||
@@ -853,7 +853,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
<Divider>{t('copyTradingAdd.positionLimitFilter') || '最大仓位限制'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingAdd.maxPositionValue') || '最大仓位金额 ($)'}
|
||||
label={t('copyTradingAdd.maxPositionValue') || '最大仓位金额 (USDC)'}
|
||||
name="maxPositionValue"
|
||||
tooltip={t('copyTradingAdd.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
|
||||
>
|
||||
@@ -1082,7 +1082,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
<span>
|
||||
{record.copyMode === 'RATIO'
|
||||
? `${t('copyTradingAdd.ratioMode') || '比例'} ${record.copyRatio}x`
|
||||
: `${t('copyTradingAdd.fixedAmountMode') || '固定'} $${formatUSDC(record.fixedAmount || '0')}`
|
||||
: `${t('copyTradingAdd.fixedAmountMode') || '固定'} ${formatUSDC(record.fixedAmount || '0')} USDC`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -261,7 +261,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
|
||||
return (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`}
|
||||
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -384,7 +384,7 @@ const BuyOrdersTab: React.FC<BuyOrdersTabProps> = ({ copyTradingId, active = fal
|
||||
</div>
|
||||
<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.totalAmount') || '总金额'}: ${formatUSDC(group.stats.totalAmount)}</span>
|
||||
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
|
||||
<span>
|
||||
{t('copyTradingOrders.statusBreakdown') || '状态'}:
|
||||
{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>{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}</div>
|
||||
<div>{t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}</div>
|
||||
<div>{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC</div>
|
||||
<div>{t('copyTradingOrders.matched') || '已匹配'}: {formatUSDC(order.matchedQuantity)} | {t('copyTradingOrders.remaining') || '剩余'}: {formatUSDC(order.remainingQuantity)}</div>
|
||||
<div style={{ color: '#999', marginTop: '4px' }}>{formattedDate}</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)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
|
||||
{t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}
|
||||
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
value={parseFloat(leaderAssetInfo.total)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#52c41a', fontWeight: 'bold', fontSize: '16px' }}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Col>
|
||||
@@ -366,7 +366,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
value={parseFloat(leaderAssetInfo.available)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#1890ff', fontSize: '14px' }}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Col>
|
||||
@@ -376,7 +376,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
value={parseFloat(leaderAssetInfo.position)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#722ed1', fontSize: '14px' }}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Col>
|
||||
@@ -456,7 +456,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
|
||||
{copyMode === 'FIXED' && (
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 ($)'}
|
||||
label={t('copyTradingEdit.fixedAmount') || '固定跟单金额 (USDC)'}
|
||||
name="fixedAmount"
|
||||
rules={[
|
||||
{ required: true, message: t('copyTradingEdit.fixedAmountRequired') || '请输入固定跟单金额' },
|
||||
@@ -495,7 +495,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
{copyMode === 'RATIO' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 ($)'}
|
||||
label={t('copyTradingEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'}
|
||||
name="maxOrderSize"
|
||||
tooltip={t('copyTradingEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限'}
|
||||
>
|
||||
@@ -515,7 +515,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 ($)'}
|
||||
label={t('copyTradingEdit.minOrderSize') || '单笔订单最小金额 (USDC)'}
|
||||
name="minOrderSize"
|
||||
tooltip={t('copyTradingEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,必须 >= 1'}
|
||||
rules={[
|
||||
@@ -550,7 +550,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 ($)'}
|
||||
label={t('copyTradingEdit.maxDailyLoss') || '每日最大亏损限制 (USDC)'}
|
||||
name="maxDailyLoss"
|
||||
tooltip={t('copyTradingEdit.maxDailyLossTooltip') || '限制每日最大亏损金额,用于风险控制'}
|
||||
>
|
||||
@@ -559,7 +559,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 $(可选)'}
|
||||
placeholder={t('copyTradingEdit.maxDailyLossPlaceholder') || '默认 10000 USDC(可选)'}
|
||||
formatter={(value) => {
|
||||
if (!value && value !== 0) return ''
|
||||
const num = parseFloat(value.toString())
|
||||
@@ -617,7 +617,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 ($)'}
|
||||
label={t('copyTradingEdit.minOrderDepth') || '最小订单深度 (USDC)'}
|
||||
name="minOrderDepth"
|
||||
tooltip={t('copyTradingEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
|
||||
>
|
||||
@@ -703,7 +703,7 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
<Divider>{t('copyTradingEdit.positionLimitFilter') || '最大仓位限制'}</Divider>
|
||||
|
||||
<Form.Item
|
||||
label={t('copyTradingEdit.maxPositionValue') || '最大仓位金额 ($)'}
|
||||
label={t('copyTradingEdit.maxPositionValue') || '最大仓位金额 (USDC)'}
|
||||
name="maxPositionValue"
|
||||
tooltip={t('copyTradingEdit.maxPositionValueTooltip') || '限制单个市场的最大仓位金额。如果该市场的当前仓位金额 + 跟单金额超过此限制,则不会下单。不填写则不启用此限制'}
|
||||
>
|
||||
|
||||
@@ -228,7 +228,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`}
|
||||
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -408,7 +408,7 @@ const MatchedOrdersTab: React.FC<MatchedOrdersTabProps> = ({ copyTradingId, acti
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
${formatUSDC(order.realizedPnl)}
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
|
||||
return (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`}
|
||||
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -274,7 +274,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`}
|
||||
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -362,10 +362,10 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
</div>
|
||||
<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.totalAmount') || '总金额'}: ${formatUSDC(group.stats.totalAmount)}</span>
|
||||
<span>{t('copyTradingOrders.totalAmount') || '总金额'}: {formatUSDC(group.stats.totalAmount)} USDC</span>
|
||||
{group.stats.totalPnl && (
|
||||
<span style={{ color: pnlColor, fontWeight: 500 }}>
|
||||
{t('copyTradingOrders.totalPnl') || '总盈亏'}: ${formatUSDC(group.stats.totalPnl)}
|
||||
{t('copyTradingOrders.totalPnl') || '总盈亏'}: {formatUSDC(group.stats.totalPnl)} USDC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -434,9 +434,9 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
<div>{t('copyTradingOrders.quantity') || '数量'}: {formatUSDC(order.quantity)} | {t('copyTradingOrders.price') || '价格'}: {formatUSDC(order.price)}</div>
|
||||
<div>{t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}</div>
|
||||
<div>{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC</div>
|
||||
<div style={{ color: getPnlColor(order.realizedPnl), fontWeight: 500 }}>
|
||||
{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}: ${formatUSDC(order.realizedPnl)}
|
||||
{t('copyTradingOrders.realizedPnl') || '已实现盈亏'}: {formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
<div style={{ color: '#999', marginTop: '4px' }}>{formattedDate}</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)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
|
||||
{t('copyTradingOrders.amount') || '金额'}: ${formatUSDC(amount)}
|
||||
{t('copyTradingOrders.amount') || '金额'}: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -541,7 +541,7 @@ const SellOrdersTab: React.FC<SellOrdersTabProps> = ({ copyTradingId, active = f
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
${formatUSDC(order.realizedPnl)}
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
|
||||
</div>
|
||||
<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' }} />
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalBuyAmount)}</span>
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalBuyAmount)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 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' }} />
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalSellAmount)}</span>
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalSellAmount)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 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)}
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalPnl)}</span>
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalPnl)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 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)}
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalRealizedPnl)}</span>
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalRealizedPnl)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 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)}
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalUnrealizedPnl)}</span>
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>{formatUSDC(statistics.totalUnrealizedPnl)} USDC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,38 +165,43 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalBuyAmount') || '总买入金额'}
|
||||
value={formatUSDC(statistics.totalBuyAmount)}
|
||||
prefix={<><ArrowUpOutlined style={{ color: '#1890ff' }} /> $</>}
|
||||
suffix="USDC"
|
||||
prefix={<ArrowUpOutlined style={{ color: '#1890ff' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalSellAmount') || '总卖出金额'}
|
||||
value={formatUSDC(statistics.totalSellAmount)}
|
||||
prefix={<><ArrowDownOutlined style={{ color: '#ff4d4f' }} /> $</>}
|
||||
suffix="USDC"
|
||||
prefix={<ArrowDownOutlined style={{ color: '#ff4d4f' }} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalPnl') || '总盈亏'}
|
||||
value={formatUSDC(statistics.totalPnl)}
|
||||
suffix="USDC"
|
||||
valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
|
||||
prefix={<>{getPnlIcon(statistics.totalPnl)} $</>}
|
||||
prefix={getPnlIcon(statistics.totalPnl)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalRealizedPnl') || '总已实现盈亏'}
|
||||
value={formatUSDC(statistics.totalRealizedPnl)}
|
||||
suffix="USDC"
|
||||
valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }}
|
||||
prefix={<>{getPnlIcon(statistics.totalRealizedPnl)} $</>}
|
||||
prefix={getPnlIcon(statistics.totalRealizedPnl)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Statistic
|
||||
title={t('copyTradingOrders.totalUnrealizedPnl') || '总未实现盈亏'}
|
||||
value={formatUSDC(statistics.totalUnrealizedPnl)}
|
||||
suffix="USDC"
|
||||
valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }}
|
||||
prefix={<>{getPnlIcon(statistics.totalUnrealizedPnl)} $</>}
|
||||
prefix={getPnlIcon(statistics.totalUnrealizedPnl)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -145,7 +145,7 @@ const CopyTradingSellOrdersPage: React.FC = () => {
|
||||
const amount = (parseFloat(record.quantity) * parseFloat(record.price)).toString()
|
||||
return (
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{isMobile ? formatUSDC(amount) : `$${formatUSDC(amount)}`}
|
||||
{isMobile ? formatUSDC(amount) : `${formatUSDC(amount)} USDC`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -161,7 +161,7 @@ const CopyTradingSellOrdersPage: React.FC = () => {
|
||||
fontWeight: 500,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
{isMobile ? formatUSDC(value) : `$${formatUSDC(value)}`}
|
||||
{isMobile ? formatUSDC(value) : `${formatUSDC(value)} USDC`}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
@@ -277,7 +277,7 @@ const CopyTradingSellOrdersPage: React.FC = () => {
|
||||
数量: {formatUSDC(order.quantity)} | 价格: {formatUSDC(order.price)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginTop: '4px' }}>
|
||||
金额: ${formatUSDC(amount)}
|
||||
金额: {formatUSDC(amount)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -289,7 +289,7 @@ const CopyTradingSellOrdersPage: React.FC = () => {
|
||||
fontWeight: 'bold',
|
||||
color: getPnlColor(order.realizedPnl)
|
||||
}}>
|
||||
${formatUSDC(order.realizedPnl)}
|
||||
{formatUSDC(order.realizedPnl)} USDC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
||||
<Statistic
|
||||
title="总买入金额"
|
||||
value={formatUSDC(statistics.totalBuyAmount)}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
@@ -179,7 +179,7 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
||||
<Statistic
|
||||
title="总卖出金额"
|
||||
value={formatUSDC(statistics.totalSellAmount)}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
@@ -220,7 +220,8 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
||||
title="总已实现盈亏"
|
||||
value={formatUSDC(statistics.totalRealizedPnl)}
|
||||
valueStyle={{ color: getPnlColor(statistics.totalRealizedPnl) }}
|
||||
prefix={<>{getPnlIcon(statistics.totalRealizedPnl)} $</>}
|
||||
prefix={getPnlIcon(statistics.totalRealizedPnl)}
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
@@ -228,7 +229,8 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
||||
title="总未实现盈亏"
|
||||
value={formatUSDC(statistics.totalUnrealizedPnl)}
|
||||
valueStyle={{ color: getPnlColor(statistics.totalUnrealizedPnl) }}
|
||||
prefix={<>{getPnlIcon(statistics.totalUnrealizedPnl)} $</>}
|
||||
prefix={getPnlIcon(statistics.totalUnrealizedPnl)}
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
@@ -236,7 +238,8 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
||||
title="总盈亏"
|
||||
value={formatUSDC(statistics.totalPnl)}
|
||||
valueStyle={{ color: getPnlColor(statistics.totalPnl) }}
|
||||
prefix={<>{getPnlIcon(statistics.totalPnl)} $</>}
|
||||
prefix={getPnlIcon(statistics.totalPnl)}
|
||||
suffix="USDC"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
|
||||
@@ -493,7 +493,7 @@ const CryptoTailMonitor: React.FC = () => {
|
||||
} else {
|
||||
timeStr = '--'
|
||||
}
|
||||
return `<span style="font-size:12px">${timeStr} $${Number(val).toFixed(2)}</span>`
|
||||
return `<span style="font-size:12px">${timeStr} ${Number(val).toFixed(2)} USDC</span>`
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
|
||||
@@ -45,12 +45,12 @@ const CryptoTailPnlCurveModal: React.FC<CryptoTailPnlCurveModalProps> = (props)
|
||||
if (!v) return ''
|
||||
const d = data.curveData.find((p) => p.timestamp === v[0])
|
||||
if (!d) return ''
|
||||
return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '<br/>' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': $' + formatUSDC(d.cumulativePnl)
|
||||
return dayjs(v[0]).format('YYYY-MM-DD HH:mm') + '<br/>' + t('cryptoTailStrategy.pnlCurve.totalPnl') + ': ' + formatUSDC(d.cumulativePnl) + ' USDC'
|
||||
}
|
||||
},
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', top: '10%', containLabel: true },
|
||||
xAxis: { type: 'time' },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: (val: number) => '$' + String(val) } },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: (val: number) => String(val) + ' USDC' } },
|
||||
series: [{
|
||||
name: t('cryptoTailStrategy.pnlCurve.totalPnl'),
|
||||
type: 'line',
|
||||
@@ -108,7 +108,7 @@ const CryptoTailPnlCurveModal: React.FC<CryptoTailPnlCurveModalProps> = (props)
|
||||
<Statistic
|
||||
title={t('cryptoTailStrategy.pnlCurve.totalPnl')}
|
||||
value={data?.totalRealizedPnl != null ? formatUSDC(data.totalRealizedPnl) : '-'}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
valueStyle={{ color: pnlColor(data?.totalRealizedPnl ?? null) }}
|
||||
/>
|
||||
</Col>
|
||||
@@ -125,7 +125,7 @@ const CryptoTailPnlCurveModal: React.FC<CryptoTailPnlCurveModalProps> = (props)
|
||||
<Statistic
|
||||
title={t('cryptoTailStrategy.pnlCurve.maxDrawdown')}
|
||||
value={data?.maxDrawdown != null ? '-' + formatUSDC(data.maxDrawdown) : '-'}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
valueStyle={{ color: data?.maxDrawdown ? '#ff4d4f' : undefined }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -773,7 +773,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>{t('cryptoTailStrategy.list.totalRealizedPnl')}</div>
|
||||
{item.totalRealizedPnl != null ? (
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: pnlColor(item.totalRealizedPnl) }}>${formatUSDC(item.totalRealizedPnl)}</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: pnlColor(item.totalRealizedPnl) }}>{formatUSDC(item.totalRealizedPnl)} USDC</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>-</div>
|
||||
)}
|
||||
@@ -966,7 +966,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="amountValue" label={t('cryptoTailStrategy.form.fixedUsdc')} rules={[{ required: true }]}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} addonBefore="$" stringMode />
|
||||
<InputNumber min={1} style={{ width: '100%' }} addonAfter="USDC" stringMode />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
@@ -1111,7 +1111,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
dataIndex: 'amountUsdc',
|
||||
key: 'amountUsdc',
|
||||
width: 110,
|
||||
render: (v: string) => `$${formatUSDC(v)}`
|
||||
render: (v: string) => `${formatUSDC(v)} USDC`
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.triggerRecords.realizedPnl'),
|
||||
@@ -1186,7 +1186,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
||||
dataIndex: 'amountUsdc',
|
||||
key: 'amountUsdc',
|
||||
width: 110,
|
||||
render: (v: string) => `$${formatUSDC(v)}`
|
||||
render: (v: string) => `${formatUSDC(v)} USDC`
|
||||
},
|
||||
{
|
||||
title: t('cryptoTailStrategy.triggerRecords.failReason'),
|
||||
|
||||
@@ -254,7 +254,7 @@ const LeaderList: React.FC = () => {
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text style={{ color: '#52c41a', fontSize: '14px', fontWeight: '500' }}>
|
||||
{balance.available === '-' ? '-' : `$${formatUSDC(balance.available)}`}
|
||||
{balance.available === '-' ? '-' : `${formatUSDC(balance.available)} USDC`}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('leaderDetail.positionBalance')}: {formatUSDC(balance.position)}
|
||||
@@ -488,7 +488,7 @@ const LeaderList: React.FC = () => {
|
||||
{t('leaderDetail.availableBalance')}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: '#52c41a' }}>
|
||||
{balance?.available && balance.available !== '-' ? `$${formatUSDC(balance.available)}` : '-'}
|
||||
{balance?.available && balance.available !== '-' ? `${formatUSDC(balance.available)} USDC` : '- USDC'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
@@ -700,7 +700,7 @@ const LeaderList: React.FC = () => {
|
||||
value={parseFloat(detailBalance.availableBalance)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Card>
|
||||
@@ -712,7 +712,7 @@ const LeaderList: React.FC = () => {
|
||||
value={parseFloat(detailBalance.positionBalance)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Card>
|
||||
@@ -724,7 +724,7 @@ const LeaderList: React.FC = () => {
|
||||
value={parseFloat(detailBalance.totalBalance)}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#52c41a', fontWeight: 'bold' }}
|
||||
prefix="$"
|
||||
suffix="USDC"
|
||||
formatter={(value) => formatUSDC(value?.toString() || '0')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -118,7 +118,7 @@ const OrderList: React.FC = () => {
|
||||
key: 'pnl',
|
||||
render: (pnl: string | undefined) => pnl ? (
|
||||
<span style={{ color: pnl.startsWith('-') ? 'red' : 'green' }}>
|
||||
${formatUSDC(pnl)}
|
||||
{formatUSDC(pnl)} USDC
|
||||
</span>
|
||||
) : '-'
|
||||
},
|
||||
|
||||
@@ -695,7 +695,7 @@ const PositionList: React.FC = () => {
|
||||
fontWeight: '500',
|
||||
color: isProfit ? '#52c41a' : '#f5222d'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}${formatUSDC(position.pnl)}
|
||||
{pnlNum >= 0 ? '+' : ''}{formatUSDC(position.pnl)} USDC
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -718,7 +718,7 @@ const PositionList: React.FC = () => {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#666' }}>开仓价值</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: '500' }}>
|
||||
${formatUSDC(position.initialValue)}
|
||||
{formatUSDC(position.initialValue)} USDC
|
||||
</span>
|
||||
</div>
|
||||
{positionFilter === 'current' && position.currentPrice && (
|
||||
@@ -732,7 +732,7 @@ const PositionList: React.FC = () => {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#666' }}>当前价值</span>
|
||||
<span style={{ fontSize: '13px', fontWeight: '600' }}>
|
||||
${formatUSDC(position.currentValue)}
|
||||
{formatUSDC(position.currentValue)} USDC
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -775,7 +775,7 @@ const PositionList: React.FC = () => {
|
||||
fontWeight: 'bold',
|
||||
color: isProfit ? '#52c41a' : '#f5222d'
|
||||
}}>
|
||||
{pnlNum >= 0 ? '+' : ''}${formatUSDC(position.pnl)}
|
||||
{pnlNum >= 0 ? '+' : ''}{formatUSDC(position.pnl)} USDC
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
@@ -802,7 +802,7 @@ const PositionList: React.FC = () => {
|
||||
color: parseFloat(position.realizedPnl) >= 0 ? '#52c41a' : '#f5222d',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{parseFloat(position.realizedPnl) >= 0 ? '+' : ''}${formatUSDC(position.realizedPnl)}
|
||||
{parseFloat(position.realizedPnl) >= 0 ? '+' : ''}{formatUSDC(position.realizedPnl)} USDC
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -951,7 +951,7 @@ const PositionList: React.FC = () => {
|
||||
dataIndex: 'initialValue',
|
||||
key: 'initialValue',
|
||||
render: (value: string) => (
|
||||
<span>${formatUSDC(value)}</span>
|
||||
<span>{formatUSDC(value)} USDC</span>
|
||||
),
|
||||
align: 'right' as const,
|
||||
width: 110
|
||||
@@ -971,7 +971,7 @@ const PositionList: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontWeight: '600', marginBottom: '2px' }}>
|
||||
${formatUSDC(record.currentValue)}
|
||||
{formatUSDC(record.currentValue)} USDC
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
@@ -1215,7 +1215,7 @@ const PositionList: React.FC = () => {
|
||||
borderColor: '#52c41a'
|
||||
}}
|
||||
>
|
||||
赎回 ({redeemableSummary.totalCount}个, ${formatUSDC(redeemableSummary.totalValue)})
|
||||
赎回 ({redeemableSummary.totalCount}个, {formatUSDC(redeemableSummary.totalValue)} USDC)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1238,13 +1238,13 @@ const PositionList: React.FC = () => {
|
||||
<span>
|
||||
开仓价值合计:{' '}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
${formatUSDC(positionTotals.totalInitialValue.toString())}
|
||||
{formatUSDC(positionTotals.totalInitialValue.toString())} USDC
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
当前价值合计:{' '}
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
${formatUSDC(positionTotals.totalCurrentValue.toString())}
|
||||
{formatUSDC(positionTotals.totalCurrentValue.toString())} USDC
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
@@ -1256,7 +1256,7 @@ const PositionList: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
{positionTotals.totalPnl >= 0 ? '+' : ''}
|
||||
${formatUSDC(positionTotals.totalPnl.toString())}
|
||||
{formatUSDC(positionTotals.totalPnl.toString())} USDC
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
@@ -1268,7 +1268,7 @@ const PositionList: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
{positionTotals.totalRealizedPnl >= 0 ? '+' : ''}
|
||||
${formatUSDC(positionTotals.totalRealizedPnl.toString())}
|
||||
{formatUSDC(positionTotals.totalRealizedPnl.toString())} USDC
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1532,7 +1532,7 @@ const PositionList: React.FC = () => {
|
||||
color: currentPnl.pnl >= 0 ? '#52c41a' : '#f5222d',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{currentPnl.pnl >= 0 ? '+' : ''}${formatUSDC(currentPnl.pnl)}
|
||||
{currentPnl.pnl >= 0 ? '+' : ''}{formatUSDC(currentPnl.pnl)} USDC
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
@@ -1572,7 +1572,7 @@ const PositionList: React.FC = () => {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="可赎回总价值">
|
||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#52c41a' }}>
|
||||
${formatUSDC(redeemableSummary.totalValue)}
|
||||
{formatUSDC(redeemableSummary.totalValue)} USDC
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="涉及账户">
|
||||
@@ -1625,7 +1625,7 @@ const PositionList: React.FC = () => {
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '价值 ($)',
|
||||
title: '价值 (USDC)',
|
||||
dataIndex: 'value',
|
||||
key: 'value',
|
||||
align: 'right' as const,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space } from 'antd'
|
||||
import { Card, Form, Button, Switch, Input, InputNumber, message, Typography, Space, Alert } from 'antd'
|
||||
import { SaveOutlined, CheckCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { apiService } from '../services/api'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import ProxyCheckResultAlert, { type ProxyCheckResponse } from '../components/ProxyCheckResultAlert'
|
||||
|
||||
const { Title } = Typography
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface ProxyConfig {
|
||||
id?: number
|
||||
@@ -21,6 +20,13 @@ interface ProxyConfig {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface ProxyCheckResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
latency?: number
|
||||
}
|
||||
|
||||
const ProxySettings: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
@@ -205,7 +211,24 @@ const ProxySettings: React.FC = () => {
|
||||
</Form>
|
||||
|
||||
{checkResult && (
|
||||
<ProxyCheckResultAlert result={checkResult} style={{ marginTop: '16px' }} />
|
||||
<Alert
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Select,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Spin,
|
||||
Popconfirm,
|
||||
Empty,
|
||||
Drawer,
|
||||
Row,
|
||||
Col,
|
||||
Typography
|
||||
} from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import { apiService } from '../services/api'
|
||||
import { useAccountStore } from '../store/accountStore'
|
||||
import type {
|
||||
SportsTailStrategyDto,
|
||||
SportsTailStrategyCreateRequest,
|
||||
SportsTailTriggerDto,
|
||||
SportsCategoryDto,
|
||||
SportsMarketDto
|
||||
} from '../types'
|
||||
import { formatUSDC } from '../utils'
|
||||
|
||||
const POLYMARKET_BASE = 'https://polymarket.com/event/'
|
||||
|
||||
const SportsTailStrategyList: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
const { accounts, fetchAccounts } = useAccountStore()
|
||||
const [list, setList] = useState<SportsTailStrategyDto[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filters, setFilters] = useState<{ accountId?: number; sport?: string }>({})
|
||||
const [formModalOpen, setFormModalOpen] = useState(false)
|
||||
const [sportsList, setSportsList] = useState<SportsCategoryDto[]>([])
|
||||
const [marketSearchLoading, setMarketSearchLoading] = useState(false)
|
||||
const [marketSearchResult, setMarketSearchResult] = useState<SportsMarketDto[]>([])
|
||||
const [marketSearchFilters, setMarketSearchFilters] = useState<{
|
||||
sport?: string
|
||||
keyword?: string
|
||||
}>({})
|
||||
const [recordsDrawerOpen, setRecordsDrawerOpen] = useState(false)
|
||||
const [records, setRecords] = useState<SportsTailTriggerDto[]>([])
|
||||
const [recordsTotal, setRecordsTotal] = useState(0)
|
||||
const [recordsLoading, setRecordsLoading] = useState(false)
|
||||
const [recordsPage, setRecordsPage] = useState(1)
|
||||
const [recordsPageSize] = useState(20)
|
||||
const [recordsFilters, setRecordsFilters] = useState<{
|
||||
accountId?: number
|
||||
status?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
}>({})
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
fetchSportsList()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchList()
|
||||
}, [filters])
|
||||
|
||||
const fetchList = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await apiService.sportsTailStrategy.list(filters)
|
||||
if (res.data.code === 0 && res.data.data?.list) {
|
||||
setList(res.data.data.list)
|
||||
} else {
|
||||
message.error(res.data.msg || t('sportsTailStrategy.list.fetchFailed'))
|
||||
}
|
||||
} catch (e) {
|
||||
message.error((e as Error).message || t('sportsTailStrategy.list.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSportsList = async () => {
|
||||
try {
|
||||
const res = await apiService.sportsTailStrategy.sportsList()
|
||||
if (res.data.code === 0 && res.data.data?.list) {
|
||||
setSportsList(res.data.data.list)
|
||||
}
|
||||
} catch {
|
||||
setSportsList([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMarketSearch = async () => {
|
||||
setMarketSearchLoading(true)
|
||||
try {
|
||||
const res = await apiService.sportsTailStrategy.marketSearch({
|
||||
sport: marketSearchFilters.sport || undefined,
|
||||
keyword: marketSearchFilters.keyword || undefined,
|
||||
limit: 50
|
||||
})
|
||||
if (res.data.code === 0 && res.data.data?.list) {
|
||||
setMarketSearchResult(res.data.data.list)
|
||||
} else {
|
||||
setMarketSearchResult([])
|
||||
}
|
||||
} catch {
|
||||
setMarketSearchResult([])
|
||||
} finally {
|
||||
setMarketSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRecords = async (page = 1) => {
|
||||
setRecordsLoading(true)
|
||||
try {
|
||||
const res = await apiService.sportsTailStrategy.triggers({
|
||||
accountId: recordsFilters.accountId,
|
||||
status: recordsFilters.status,
|
||||
startTime: recordsFilters.startTime,
|
||||
endTime: recordsFilters.endTime,
|
||||
page,
|
||||
pageSize: recordsPageSize
|
||||
})
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
setRecords(res.data.data.list)
|
||||
setRecordsTotal(res.data.data.total)
|
||||
setRecordsPage(page)
|
||||
}
|
||||
} catch {
|
||||
setRecords([])
|
||||
setRecordsTotal(0)
|
||||
} finally {
|
||||
setRecordsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ amountMode: 'FIXED' })
|
||||
setFormModalOpen(true)
|
||||
setMarketSearchResult([])
|
||||
setMarketSearchFilters({})
|
||||
fetchSportsList()
|
||||
}
|
||||
|
||||
const handleFormSubmit = async () => {
|
||||
try {
|
||||
const v = await form.validateFields()
|
||||
const payload: SportsTailStrategyCreateRequest = {
|
||||
accountId: v.accountId,
|
||||
conditionId: v.conditionId,
|
||||
marketTitle: v.marketTitle,
|
||||
eventSlug: v.eventSlug || undefined,
|
||||
triggerPrice: String(v.triggerPrice),
|
||||
amountMode: v.amountMode,
|
||||
amountValue: String(v.amountValue),
|
||||
takeProfitPrice: v.takeProfitPrice != null ? String(v.takeProfitPrice) : undefined,
|
||||
stopLossPrice: v.stopLossPrice != null ? String(v.stopLossPrice) : undefined
|
||||
}
|
||||
const res = await apiService.sportsTailStrategy.create(payload)
|
||||
if (res.data.code === 0) {
|
||||
message.success(t('sportsTailStrategy.form.createSuccess'))
|
||||
setFormModalOpen(false)
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg || t('sportsTailStrategy.form.createFailed'))
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && typeof (e as { errorFields?: unknown }).errorFields === 'undefined') {
|
||||
message.error((e as Error).message || t('sportsTailStrategy.form.createFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const res = await apiService.sportsTailStrategy.delete({ id })
|
||||
if (res.data.code === 0) {
|
||||
message.success(t('message.success'))
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(res.data.msg)
|
||||
}
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const openRecordsDrawer = () => {
|
||||
setRecordsDrawerOpen(true)
|
||||
setRecordsFilters({})
|
||||
fetchRecords(1)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (recordsDrawerOpen) {
|
||||
fetchRecords(recordsPage)
|
||||
}
|
||||
}, [recordsDrawerOpen, recordsFilters])
|
||||
|
||||
const renderAmount = (row: SportsTailStrategyDto) => {
|
||||
if (row.amountMode === 'FIXED') {
|
||||
return `${formatUSDC(row.amountValue)} USDC`
|
||||
}
|
||||
return `${row.amountValue}%`
|
||||
}
|
||||
|
||||
const renderTakeProfitStopLoss = (row: SportsTailStrategyDto) => {
|
||||
const a = row.takeProfitPrice != null ? formatUSDC(row.takeProfitPrice) : '-'
|
||||
const b = row.stopLossPrice != null ? formatUSDC(row.stopLossPrice) : '-'
|
||||
return `${a} / ${b}`
|
||||
}
|
||||
|
||||
const renderFilledOrRealtime = (row: SportsTailStrategyDto) => {
|
||||
if (row.filled && row.filledPrice != null && row.filledOutcomeName != null && row.filledShares != null) {
|
||||
return `${formatUSDC(row.filledPrice)} ${row.filledOutcomeName} | ${formatUSDC(row.filledShares)} ${t('sportsTailStrategy.list.shares')}`
|
||||
}
|
||||
const yes = row.realtimeYesPrice != null ? formatUSDC(row.realtimeYesPrice) : '-'
|
||||
const no = row.realtimeNoPrice != null ? formatUSDC(row.realtimeNoPrice) : '-'
|
||||
return `${t('sportsTailStrategy.list.realtimePrice')}: ${yes} / ${no}`
|
||||
}
|
||||
|
||||
const renderPnl = (row: SportsTailStrategyDto) => {
|
||||
if (row.sold && row.realizedPnl != null) {
|
||||
const n = parseFloat(row.realizedPnl)
|
||||
const prefix = n >= 0 ? '+' : ''
|
||||
return `${prefix}${formatUSDC(row.realizedPnl)} USDC`
|
||||
}
|
||||
if (row.filled && !row.sold) return t('sportsTailStrategy.list.pending')
|
||||
return '-'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('sportsTailStrategy.list.triggerPrice'),
|
||||
dataIndex: 'triggerPrice',
|
||||
key: 'triggerPrice',
|
||||
render: (v: string) => `>= ${formatUSDC(v)}`
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.list.amount'),
|
||||
key: 'amount',
|
||||
render: (_: unknown, row: SportsTailStrategyDto) => renderAmount(row)
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.list.takeProfitStopLoss'),
|
||||
key: 'tpSl',
|
||||
render: (_: unknown, row: SportsTailStrategyDto) => renderTakeProfitStopLoss(row)
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.list.filledPrice'),
|
||||
key: 'filled',
|
||||
render: (_: unknown, row: SportsTailStrategyDto) => renderFilledOrRealtime(row)
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.list.pnl'),
|
||||
key: 'pnl',
|
||||
render: (_: unknown, row: SportsTailStrategyDto) => renderPnl(row)
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
render: (_: unknown, row: SportsTailStrategyDto) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={openRecordsDrawer}>
|
||||
{t('sportsTailStrategy.list.viewRecords')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('sportsTailStrategy.list.deleteConfirm')}
|
||||
onConfirm={() => handleDelete(row.id)}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />}>
|
||||
{t('sportsTailStrategy.list.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const recordColumns = [
|
||||
{
|
||||
title: t('sportsTailStrategy.records.time'),
|
||||
dataIndex: 'triggeredAt',
|
||||
key: 'triggeredAt',
|
||||
render: (v: number) => dayjs(v).format('MM-DD HH:mm')
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.records.market'),
|
||||
dataIndex: 'marketTitle',
|
||||
key: 'marketTitle'
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.records.direction'),
|
||||
dataIndex: 'outcomeName',
|
||||
key: 'outcomeName'
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.records.buyPrice'),
|
||||
dataIndex: 'buyPrice',
|
||||
key: 'buyPrice',
|
||||
render: (v: string) => formatUSDC(v)
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.records.amount'),
|
||||
dataIndex: 'buyAmount',
|
||||
key: 'buyAmount',
|
||||
render: (v: string) => formatUSDC(v)
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.records.sellPrice'),
|
||||
dataIndex: 'sellPrice',
|
||||
key: 'sellPrice',
|
||||
render: (v: string | null) => (v != null ? formatUSDC(v) : '-')
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.records.pnl'),
|
||||
dataIndex: 'realizedPnl',
|
||||
key: 'realizedPnl',
|
||||
render: (v: string | null) => {
|
||||
if (v == null) return t('sportsTailStrategy.records.pending')
|
||||
const n = parseFloat(v)
|
||||
const prefix = n >= 0 ? '+' : ''
|
||||
return `${prefix}${formatUSDC(v)} USDC`
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{t('sportsTailStrategy.list.title')}
|
||||
</Typography.Title>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAddModal}>
|
||||
{t('sportsTailStrategy.list.addStrategy')}
|
||||
</Button>
|
||||
<Space>
|
||||
<Select
|
||||
placeholder={t('sportsTailStrategy.list.filter.account')}
|
||||
allowClear
|
||||
style={{ minWidth: 140 }}
|
||||
value={filters.accountId}
|
||||
onChange={(v) => setFilters((prev) => ({ ...prev, accountId: v }))}
|
||||
options={accounts.map((a) => ({ label: a.accountName || a.proxyAddress?.slice(0, 8) + '...', value: a.id }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('sportsTailStrategy.list.filter.category')}
|
||||
allowClear
|
||||
style={{ minWidth: 120 }}
|
||||
value={filters.sport}
|
||||
onChange={(v) => setFilters((prev) => ({ ...prev, sport: v }))}
|
||||
options={[{ label: t('sportsTailStrategy.list.filter.allCategory'), value: undefined }, ...sportsList.map((s) => ({ label: s.name || s.sport, value: s.sport }))]}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{isMobile ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{list.length === 0 ? (
|
||||
<Empty description={t('common.noData')} />
|
||||
) : (
|
||||
list.map((row) => (
|
||||
<Card key={row.id} size="small" title={row.marketTitle}>
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col span={24}>{t('sportsTailStrategy.list.filter.account')}: {row.accountName}</Col>
|
||||
<Col span={24}>{t('sportsTailStrategy.list.triggerPrice')}: >= {formatUSDC(row.triggerPrice)} | {t('sportsTailStrategy.list.amount')}: {renderAmount(row)}</Col>
|
||||
<Col span={24}>{t('sportsTailStrategy.list.takeProfitStopLoss')}: {renderTakeProfitStopLoss(row)}</Col>
|
||||
<Col span={24}>{renderFilledOrRealtime(row)}</Col>
|
||||
<Col span={24}>{t('sportsTailStrategy.list.pnl')}: {renderPnl(row)}</Col>
|
||||
<Col span={24}>
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={openRecordsDrawer}>{t('sportsTailStrategy.list.viewRecords')}</Button>
|
||||
<Popconfirm
|
||||
title={t('sportsTailStrategy.list.deleteConfirm')}
|
||||
onConfirm={() => handleDelete(row.id)}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button type="link" danger size="small">{t('sportsTailStrategy.list.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Space>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={list}
|
||||
columns={[
|
||||
{
|
||||
title: t('sportsTailStrategy.records.market'),
|
||||
dataIndex: 'marketTitle',
|
||||
key: 'marketTitle',
|
||||
ellipsis: true,
|
||||
render: (text: string, row: SportsTailStrategyDto) => {
|
||||
const url = row.eventSlug ? `${POLYMARKET_BASE}${row.eventSlug}` : null
|
||||
if (url) {
|
||||
return <a href={url} target="_blank" rel="noopener noreferrer">{text}</a>
|
||||
}
|
||||
return text
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('sportsTailStrategy.list.filter.account'),
|
||||
dataIndex: 'accountName',
|
||||
key: 'accountName',
|
||||
width: 100
|
||||
},
|
||||
...columns
|
||||
]}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('common.noData') }}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
title={t('sportsTailStrategy.form.title')}
|
||||
open={formModalOpen}
|
||||
onCancel={() => setFormModalOpen(false)}
|
||||
onOk={handleFormSubmit}
|
||||
width={isMobile ? '100%' : 560}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false}>
|
||||
<Form.Item name="accountId" label={t('sportsTailStrategy.form.account')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder={t('sportsTailStrategy.form.selectAccount')}
|
||||
options={accounts.map((a) => ({ label: a.accountName || a.proxyAddress, value: a.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('sportsTailStrategy.form.selectMarket')} required>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder={t('sportsTailStrategy.marketSearch.sport')}
|
||||
allowClear
|
||||
style={{ minWidth: 120 }}
|
||||
value={marketSearchFilters.sport}
|
||||
onChange={(v) => setMarketSearchFilters((prev) => ({ ...prev, sport: v }))}
|
||||
options={[{ label: t('sportsTailStrategy.marketSearch.all'), value: undefined }, ...sportsList.map((s) => ({ label: s.name || s.sport, value: s.sport }))]}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('sportsTailStrategy.marketSearch.keyword')}
|
||||
style={{ width: 160 }}
|
||||
value={marketSearchFilters.keyword}
|
||||
onChange={(e) => setMarketSearchFilters((prev) => ({ ...prev, keyword: e.target.value }))}
|
||||
/>
|
||||
<Button onClick={fetchMarketSearch} loading={marketSearchLoading}>{t('sportsTailStrategy.marketSearch.search')}</Button>
|
||||
</Space>
|
||||
<div style={{ maxHeight: 200, overflow: 'auto', border: '1px solid #d9d9d9', borderRadius: 6, padding: 8 }}>
|
||||
{marketSearchLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 16 }}><Spin /></div>
|
||||
) : marketSearchResult.length === 0 ? (
|
||||
<Empty description={t('common.noData')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<Radio.Group
|
||||
style={{ width: '100%' }}
|
||||
onChange={(e) => {
|
||||
const c = e.target.value as SportsMarketDto
|
||||
form.setFieldsValue({
|
||||
conditionId: c.conditionId,
|
||||
marketTitle: c.question,
|
||||
eventSlug: c.eventSlug ?? undefined
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{marketSearchResult.map((m) => (
|
||||
<Radio key={m.conditionId} value={m}>
|
||||
<div>
|
||||
<div>{m.question}</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{m.outcomes?.[0]}: {m.outcomePrices?.[0] ?? '-'} | {m.outcomes?.[1]}: {m.outcomePrices?.[1] ?? '-'} | {t('sportsTailStrategy.marketSearch.liquidity')}: {formatUSDC(m.liquidity)} USDC
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="conditionId" hidden rules={[{ required: true, message: t('sportsTailStrategy.form.selectMarket') }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="marketTitle" hidden><Input /></Form.Item>
|
||||
<Form.Item name="eventSlug" hidden><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="triggerPrice"
|
||||
label={t('sportsTailStrategy.form.triggerCondition')}
|
||||
rules={[{ required: true }, { type: 'number', min: 0.01, max: 1 }]}
|
||||
extra={t('sportsTailStrategy.form.triggerPriceHelp')}
|
||||
>
|
||||
<InputNumber min={0.01} max={1} step={0.01} style={{ width: '100%' }} placeholder="0.90" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amountMode" label={t('sportsTailStrategy.form.amount')} rules={[{ required: true }]}>
|
||||
<Radio.Group>
|
||||
<Radio value="FIXED">{t('sportsTailStrategy.form.fixedAmount')} (USDC)</Radio>
|
||||
<Radio value="RATIO">{t('sportsTailStrategy.form.ratio')} (%)</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amountValue"
|
||||
rules={[{ required: true }]}
|
||||
noStyle
|
||||
>
|
||||
<InputNumber min={0.01} step={0.1} style={{ width: 160 }} placeholder="10" />
|
||||
</Form.Item>
|
||||
<Form.Item name="takeProfitPrice" label={t('sportsTailStrategy.form.takeProfitPrice')} extra={t('sportsTailStrategy.form.takeProfitHelp')}>
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} placeholder="0.98" />
|
||||
</Form.Item>
|
||||
<Form.Item name="stopLossPrice" label={t('sportsTailStrategy.form.stopLossPrice')} extra={t('sportsTailStrategy.form.stopLossHelp')}>
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} placeholder="0.85" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={t('sportsTailStrategy.records.title')}
|
||||
open={recordsDrawerOpen}
|
||||
onClose={() => setRecordsDrawerOpen(false)}
|
||||
width={isMobile ? '100%' : 720}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder={t('sportsTailStrategy.list.filter.account')}
|
||||
allowClear
|
||||
style={{ minWidth: 160 }}
|
||||
value={recordsFilters.accountId}
|
||||
onChange={(v) => setRecordsFilters((prev) => ({ ...prev, accountId: v }))}
|
||||
options={accounts.map((a) => ({ label: a.accountName || a.proxyAddress?.slice(0, 8) + '...', value: a.id }))}
|
||||
/>
|
||||
<Button onClick={() => fetchRecords(1)} loading={recordsLoading}>{t('common.refresh')}</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={records}
|
||||
columns={recordColumns}
|
||||
loading={recordsLoading}
|
||||
pagination={{
|
||||
current: recordsPage,
|
||||
pageSize: recordsPageSize,
|
||||
total: recordsTotal,
|
||||
showSizeChanger: false,
|
||||
onChange: (p) => fetchRecords(p)
|
||||
}}
|
||||
size="small"
|
||||
locale={{ emptyText: t('common.noData') }}
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SportsTailStrategyList
|
||||
@@ -101,8 +101,9 @@ const Statistics: React.FC = () => {
|
||||
<Statistic
|
||||
title={t('statistics.totalPnl') || '总盈亏'}
|
||||
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' }}
|
||||
suffix="USDC"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
@@ -123,8 +124,9 @@ const Statistics: React.FC = () => {
|
||||
<Statistic
|
||||
title={t('statistics.avgPnl') || '平均盈亏'}
|
||||
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' }}
|
||||
suffix="USDC"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
@@ -134,8 +136,9 @@ const Statistics: React.FC = () => {
|
||||
<Statistic
|
||||
title={t('statistics.maxProfit') || '最大盈利'}
|
||||
value={formatUSDC(stats?.maxProfit || '0')}
|
||||
prefix={<><ArrowUpOutlined /> $</>}
|
||||
prefix={<ArrowUpOutlined />}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
suffix="USDC"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
@@ -145,8 +148,9 @@ const Statistics: React.FC = () => {
|
||||
<Statistic
|
||||
title={t('statistics.maxLoss') || '最大亏损'}
|
||||
value={formatUSDC(stats?.maxLoss || '0')}
|
||||
prefix={<><ArrowDownOutlined /> $</>}
|
||||
prefix={<ArrowDownOutlined />}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
suffix="USDC"
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMediaQuery } from 'react-responsive'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SystemConfig, BuilderApiKeyUpdateRequest } from '../types'
|
||||
import SystemUpdate from './SystemUpdate'
|
||||
import ProxyCheckResultAlert, { type ProxyCheckResponse } from '../components/ProxyCheckResultAlert'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
interface ProxyConfig {
|
||||
@@ -23,6 +23,13 @@ interface ProxyConfig {
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface ProxyCheckResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
latency?: number
|
||||
}
|
||||
|
||||
const SystemSettings: React.FC = () => {
|
||||
const { t, i18n: i18nInstance } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
@@ -221,12 +228,7 @@ const SystemSettings: React.FC = () => {
|
||||
const result = response.data.data
|
||||
setProxyCheckResult(result)
|
||||
if (result.success) {
|
||||
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}`)
|
||||
message.success(`代理检查成功:${result.message}${result.responseTime ? ` (响应时间: ${result.responseTime}ms)` : ''}`)
|
||||
} else {
|
||||
message.warning(`代理检查失败:${result.message}`)
|
||||
}
|
||||
@@ -574,7 +576,24 @@ const SystemSettings: React.FC = () => {
|
||||
</Form>
|
||||
|
||||
{proxyCheckResult && (
|
||||
<ProxyCheckResultAlert result={proxyCheckResult} style={{ marginTop: '16px' }} />
|
||||
<Alert
|
||||
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>
|
||||
|
||||
@@ -154,7 +154,7 @@ const TemplateAdd: React.FC = () => {
|
||||
|
||||
{copyMode === 'FIXED' && (
|
||||
<Form.Item
|
||||
label={t('templateAdd.fixedAmount') || '固定跟单金额 ($)'}
|
||||
label={t('templateAdd.fixedAmount') || '固定跟单金额 (USDC)'}
|
||||
name="fixedAmount"
|
||||
rules={[
|
||||
{ required: true, message: t('templateAdd.fixedAmountRequired') || '请输入固定跟单金额' },
|
||||
@@ -193,9 +193,9 @@ const TemplateAdd: React.FC = () => {
|
||||
{copyMode === 'RATIO' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('templateAdd.maxOrderSize') || '单笔订单最大金额 ($)'}
|
||||
label={t('templateAdd.maxOrderSize') || '单笔订单最大金额 (USDC)'}
|
||||
name="maxOrderSize"
|
||||
tooltip={t('templateAdd.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 $1000。'}
|
||||
tooltip={t('templateAdd.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 1000 USDC。'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.0001}
|
||||
@@ -213,9 +213,9 @@ const TemplateAdd: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('templateAdd.minOrderSize') || '单笔订单最小金额 ($)'}
|
||||
label={t('templateAdd.minOrderSize') || '单笔订单最小金额 (USDC)'}
|
||||
name="minOrderSize"
|
||||
tooltip={t('templateAdd.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= $1。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'}
|
||||
tooltip={t('templateAdd.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
@@ -282,7 +282,7 @@ const TemplateAdd: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('templateAdd.minOrderDepth') || '最小订单深度 ($)'}
|
||||
label={t('templateAdd.minOrderDepth') || '最小订单深度 (USDC)'}
|
||||
name="minOrderDepth"
|
||||
tooltip={t('templateAdd.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
|
||||
>
|
||||
|
||||
@@ -189,9 +189,9 @@ const TemplateEdit: React.FC = () => {
|
||||
|
||||
{copyMode === 'FIXED' && (
|
||||
<Form.Item
|
||||
label={t('templateEdit.fixedAmount') || '固定跟单金额 ($)'}
|
||||
label={t('templateEdit.fixedAmount') || '固定跟单金额 (USDC)'}
|
||||
name="fixedAmount"
|
||||
tooltip={t('templateEdit.fixedAmountTooltip') || '固定金额模式下,每次跟单的固定金额,不随 Leader 订单大小变化。必须 >= $1。例如:设置为 10,则无论 Leader 买入多少,跟单金额始终为 $10。'}
|
||||
tooltip={t('templateEdit.fixedAmountTooltip') || '固定金额模式下,每次跟单的固定金额,不随 Leader 订单大小变化。必须 >= 1 USDC。例如:设置为 10,则无论 Leader 买入多少,跟单金额始终为 10 USDC。'}
|
||||
rules={[
|
||||
{ required: true, message: t('templateEdit.fixedAmountRequired') || '请输入固定跟单金额' },
|
||||
{
|
||||
@@ -229,9 +229,9 @@ const TemplateEdit: React.FC = () => {
|
||||
{copyMode === 'RATIO' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('templateEdit.maxOrderSize') || '单笔订单最大金额 ($)'}
|
||||
label={t('templateEdit.maxOrderSize') || '单笔订单最大金额 (USDC)'}
|
||||
name="maxOrderSize"
|
||||
tooltip={t('templateEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 $1000。'}
|
||||
tooltip={t('templateEdit.maxOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 1000 USDC。'}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.0001}
|
||||
@@ -249,9 +249,9 @@ const TemplateEdit: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('templateEdit.minOrderSize') || '单笔订单最小金额 ($)'}
|
||||
label={t('templateEdit.minOrderSize') || '单笔订单最小金额 (USDC)'}
|
||||
name="minOrderSize"
|
||||
tooltip={t('templateEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= $1。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'}
|
||||
tooltip={t('templateEdit.minOrderSizeTooltip') || '比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。'}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
@@ -318,7 +318,7 @@ const TemplateEdit: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('templateEdit.minOrderDepth') || '最小订单深度 ($)'}
|
||||
label={t('templateEdit.minOrderDepth') || '最小订单深度 (USDC)'}
|
||||
name="minOrderDepth"
|
||||
tooltip={t('templateEdit.minOrderDepthTooltip') || '检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤'}
|
||||
>
|
||||
|
||||
@@ -176,7 +176,7 @@ const TemplateList: React.FC = () => {
|
||||
if (record.copyMode === 'RATIO') {
|
||||
return `${t('templateList.ratio') || '比例'} ${record.copyRatio}x`
|
||||
} else if (record.copyMode === 'FIXED' && record.fixedAmount) {
|
||||
return `$${formatUSDC(record.fixedAmount)}`
|
||||
return `${t('templateList.fixedAmount') || '固定'} ${formatUSDC(record.fixedAmount)} USDC`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
@@ -350,7 +350,7 @@ const TemplateList: React.FC = () => {
|
||||
<div style={{ fontSize: '12px', opacity: '0.9' }}>
|
||||
{template.copyMode === 'RATIO'
|
||||
? `${t('templateList.ratioMode') || '比例模式'} ${(parseFloat(template.copyRatio || '0') * 100).toFixed(0).replace(/\.0+$/, '')}%`
|
||||
: `$${formatUSDC(template.fixedAmount || '0')}`
|
||||
: `${t('templateList.fixedAmountMode') || '固定金额'} ${formatUSDC(template.fixedAmount || '0')} USDC`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,11 +396,11 @@ const TemplateList: React.FC = () => {
|
||||
}}>
|
||||
<span style={{ color: '#d48806' }}>{t('templateList.amountLimit') || '金额限制'}: </span>
|
||||
{template.maxOrderSize && (
|
||||
<span>{t('templateList.max') || '最大'} ${formatUSDC(template.maxOrderSize)}</span>
|
||||
<span>{t('templateList.max') || '最大'} {formatUSDC(template.maxOrderSize)} USDC</span>
|
||||
)}
|
||||
{template.maxOrderSize && template.minOrderSize && <span> | </span>}
|
||||
{template.minOrderSize && (
|
||||
<span>{t('templateList.min') || '最小'} ${formatUSDC(template.minOrderSize)}</span>
|
||||
<span>{t('templateList.min') || '最小'} {formatUSDC(template.minOrderSize)} USDC</span>
|
||||
)}
|
||||
{!template.maxOrderSize && !template.minOrderSize && <span style={{ color: '#bfbfbf' }}>{t('templateList.notSet') || '未设置'}</span>}
|
||||
</div>
|
||||
@@ -551,7 +551,7 @@ const TemplateList: React.FC = () => {
|
||||
|
||||
{copyMode === 'FIXED' && (
|
||||
<Form.Item
|
||||
label="固定跟单金额 ($)"
|
||||
label="固定跟单金额 (USDC)"
|
||||
name="fixedAmount"
|
||||
rules={[
|
||||
{ required: true, message: '请输入固定跟单金额' },
|
||||
@@ -589,9 +589,9 @@ const TemplateList: React.FC = () => {
|
||||
{copyMode === 'RATIO' && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="单笔订单最大金额 ($)"
|
||||
label="单笔订单最大金额 (USDC)"
|
||||
name="maxOrderSize"
|
||||
tooltip="比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 $1000。"
|
||||
tooltip="比例模式下,限制单笔跟单订单的最大金额上限,用于防止跟单金额过大,控制风险。例如:设置为 1000,即使计算出的跟单金额超过 1000,也会限制为 1000 USDC。"
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
@@ -609,9 +609,9 @@ const TemplateList: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="单笔订单最小金额 ($)"
|
||||
label="单笔订单最小金额 (USDC)"
|
||||
name="minOrderSize"
|
||||
tooltip="比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= $1。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。"
|
||||
tooltip="比例模式下,限制单笔跟单订单的最小金额下限,用于过滤掉金额过小的订单,避免频繁小额交易。如果填写,必须 >= 1 USDC。例如:设置为 10,如果计算出的跟单金额小于 10,则跳过该订单。"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
@@ -698,7 +698,7 @@ const TemplateList: React.FC = () => {
|
||||
<Divider>过滤条件(可选)</Divider>
|
||||
|
||||
<Form.Item
|
||||
label="最小订单深度 ($)"
|
||||
label="最小订单深度 (USDC)"
|
||||
name="minOrderDepth"
|
||||
tooltip="检查订单簿的总订单金额(买盘+卖盘),确保市场有足够的流动性。不填写则不启用此过滤"
|
||||
>
|
||||
|
||||
@@ -284,21 +284,9 @@ export const apiService = {
|
||||
/**
|
||||
* 赎回仓位
|
||||
*/
|
||||
redeemPositions: (data: any) =>
|
||||
redeemPositions: (data: any) =>
|
||||
apiClient.post<ApiResponse<any>>('/accounts/positions/redeem', data),
|
||||
|
||||
/**
|
||||
* 将 USDC.e wrap 为 pUSD(V2 迁移)
|
||||
*/
|
||||
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 }),
|
||||
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -526,6 +514,26 @@ export const apiService = {
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailManualOrderResponse>>('/crypto-tail-strategy/manual-order', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 体育尾盘策略 API
|
||||
*/
|
||||
sportsTailStrategy: {
|
||||
list: (data: { accountId?: number; sport?: string } = {}) =>
|
||||
apiClient.post<ApiResponse<import('../types').SportsTailStrategyListResponse>>('/sports-tail-strategy/list', data),
|
||||
create: (data: import('../types').SportsTailStrategyCreateRequest) =>
|
||||
apiClient.post<ApiResponse<{ id: number }>>('/sports-tail-strategy/create', data),
|
||||
delete: (data: { id: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/sports-tail-strategy/delete', data),
|
||||
triggers: (data: import('../types').SportsTailTriggerListRequest) =>
|
||||
apiClient.post<ApiResponse<import('../types').SportsTailTriggerListResponse>>('/sports-tail-strategy/triggers', data),
|
||||
sportsList: () =>
|
||||
apiClient.post<ApiResponse<{ list: import('../types').SportsCategoryDto[] }>>('/sports-tail-strategy/sports-list', {}),
|
||||
marketSearch: (data: import('../types').SportsMarketSearchRequest) =>
|
||||
apiClient.post<ApiResponse<{ list: import('../types').SportsMarketDto[] }>>('/sports-tail-strategy/market-search', data),
|
||||
marketDetail: (data: { conditionId: string }) =>
|
||||
apiClient.post<ApiResponse<import('../types').SportsMarketDto>>('/sports-tail-strategy/market-detail', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 订单管理 API
|
||||
*/
|
||||
@@ -625,15 +633,6 @@ export const apiService = {
|
||||
success: boolean
|
||||
message: string
|
||||
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', {}),
|
||||
|
||||
/**
|
||||
@@ -654,20 +653,7 @@ export const apiService = {
|
||||
message: string
|
||||
responseTime?: number
|
||||
}>
|
||||
}>>('/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', {})
|
||||
}>>('/system/proxy/api-health-check', {})
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1261,6 +1261,128 @@ export interface ManualOrderDetails {
|
||||
totalAmount: string
|
||||
}
|
||||
|
||||
// ==================== 体育尾盘策略相关类型 ====================
|
||||
|
||||
/** 体育尾盘策略 DTO */
|
||||
export interface SportsTailStrategyDto {
|
||||
id: number
|
||||
accountId: number
|
||||
accountName: string
|
||||
conditionId: string
|
||||
marketTitle: string
|
||||
eventSlug: string | null
|
||||
triggerPrice: string
|
||||
amountMode: 'FIXED' | 'RATIO'
|
||||
amountValue: string
|
||||
takeProfitPrice: string | null
|
||||
stopLossPrice: string | null
|
||||
filled: boolean
|
||||
filledPrice: string | null
|
||||
filledOutcomeIndex: number | null
|
||||
filledOutcomeName: string | null
|
||||
filledAmount: string | null
|
||||
filledShares: string | null
|
||||
filledAt: number | null
|
||||
sold: boolean
|
||||
sellPrice: string | null
|
||||
sellType: string | null
|
||||
sellAmount: string | null
|
||||
realizedPnl: string | null
|
||||
soldAt: number | null
|
||||
realtimeYesPrice: string | null
|
||||
realtimeNoPrice: string | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** 体育尾盘策略创建请求 */
|
||||
export interface SportsTailStrategyCreateRequest {
|
||||
accountId: number
|
||||
conditionId: string
|
||||
marketTitle: string
|
||||
eventSlug?: string
|
||||
triggerPrice: string
|
||||
amountMode: 'FIXED' | 'RATIO'
|
||||
amountValue: string
|
||||
takeProfitPrice?: string
|
||||
stopLossPrice?: string
|
||||
}
|
||||
|
||||
/** 体育尾盘策略列表响应 */
|
||||
export interface SportsTailStrategyListResponse {
|
||||
list: SportsTailStrategyDto[]
|
||||
}
|
||||
|
||||
/** 体育尾盘策略触发记录 DTO */
|
||||
export interface SportsTailTriggerDto {
|
||||
id: number
|
||||
strategyId: number
|
||||
marketTitle: string
|
||||
conditionId: string
|
||||
buyPrice: string
|
||||
outcomeIndex: number
|
||||
outcomeName: string | null
|
||||
buyAmount: string
|
||||
buyShares: string | null
|
||||
buyStatus: string
|
||||
sellPrice: string | null
|
||||
sellType: string | null
|
||||
sellAmount: string | null
|
||||
sellStatus: string | null
|
||||
realizedPnl: string | null
|
||||
triggeredAt: number
|
||||
soldAt: number | null
|
||||
}
|
||||
|
||||
/** 体育尾盘策略触发记录列表请求 */
|
||||
export interface SportsTailTriggerListRequest {
|
||||
accountId?: number
|
||||
status?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/** 体育尾盘策略触发记录列表响应 */
|
||||
export interface SportsTailTriggerListResponse {
|
||||
total: number
|
||||
list: SportsTailTriggerDto[]
|
||||
}
|
||||
|
||||
/** 体育类别 DTO */
|
||||
export interface SportsCategoryDto {
|
||||
sport: string
|
||||
image: string
|
||||
tagId: number
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 体育市场 DTO */
|
||||
export interface SportsMarketDto {
|
||||
conditionId: string
|
||||
question: string
|
||||
outcomes: string[]
|
||||
outcomePrices: string[]
|
||||
endDate: string
|
||||
liquidity: string
|
||||
bestBid: number | null
|
||||
bestAsk: number | null
|
||||
yesTokenId: string
|
||||
noTokenId: string
|
||||
eventSlug?: string | null
|
||||
}
|
||||
|
||||
/** 体育市场搜索请求 */
|
||||
export interface SportsMarketSearchRequest {
|
||||
sport?: string
|
||||
endDateMin?: string
|
||||
endDateMax?: string
|
||||
minLiquidity?: string
|
||||
keyword?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// ==================== 消息模板相关类型 ====================
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user