Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2056417749 | ||
|
|
748c871af4 | ||
|
|
53f1381c3b | ||
|
|
4a3ebb674e | ||
|
|
2b570a432a | ||
|
|
5412a0eb49 | ||
|
|
7b4e702da9 | ||
|
|
e32697e7ee | ||
|
|
d8a75fc8dd | ||
|
|
915d4570df | ||
|
|
48d6e82f43 | ||
|
|
0fb015f6d3 | ||
|
|
c5d59dfdf9 | ||
|
|
4b277eaeab | ||
|
|
f35bad78d4 | ||
|
|
0740abcf16 | ||
|
|
377da4fff6 | ||
|
|
84c79d8812 | ||
|
|
a0c2d7995b | ||
|
|
d3196a783f | ||
|
|
4c989a48c4 | ||
|
|
7c1f8df590 |
@@ -0,0 +1,183 @@
|
|||||||
|
---
|
||||||
|
name: 🤖 Bug Report for AI Fix / AI Bug 报告
|
||||||
|
description: Bug 报告模板
|
||||||
|
title: '[Bug] / '
|
||||||
|
assignees: []
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Bug Report Template 🐛
|
||||||
|
|
||||||
|
本模板用于报告 PolyHermes 项目的 bug。
|
||||||
|
|
||||||
|
⚠️ **Important / 重要提示**:
|
||||||
|
- 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 / 其他任何上下文信息...
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.wrbug.polymarketbot.config
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||||
|
import jakarta.annotation.PostConstruct
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控服务配置
|
||||||
|
* 处理 WebSocketSubscriptionService 和 CryptoTailMonitorService 之间的循环依赖
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
class MonitorServiceConfig(
|
||||||
|
private val webSocketSubscriptionService: WebSocketSubscriptionService,
|
||||||
|
private val cryptoTailMonitorService: CryptoTailMonitorService
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
fun init() {
|
||||||
|
// 在所有 Bean 初始化后设置引用
|
||||||
|
webSocketSubscriptionService.setCryptoTailMonitorService(cryptoTailMonitorService)
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-11
@@ -11,9 +11,12 @@ import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
|
|||||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
|
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
|
||||||
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
|
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
|
||||||
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse
|
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.context.MessageSource
|
import org.springframework.context.MessageSource
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
@@ -26,6 +29,7 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
@RequestMapping("/api/crypto-tail-strategy")
|
@RequestMapping("/api/crypto-tail-strategy")
|
||||||
class CryptoTailStrategyController(
|
class CryptoTailStrategyController(
|
||||||
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
||||||
|
private val cryptoTailMonitorService: CryptoTailMonitorService,
|
||||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||||
private val messageSource: MessageSource
|
private val messageSource: MessageSource
|
||||||
) {
|
) {
|
||||||
@@ -39,12 +43,12 @@ class CryptoTailStrategyController(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("查询尾盘策略列表失败: ${e.message}", e)
|
logger.error("查询加密价差策略列表失败: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("查询尾盘策略列表异常: ${e.message}", e)
|
logger.error("查询加密价差策略列表异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +60,7 @@ class CryptoTailStrategyController(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("创建尾盘策略失败: ${e.message}", e)
|
logger.error("创建加密价差策略失败: ${e.message}", e)
|
||||||
val code = when (e.message) {
|
val code = when (e.message) {
|
||||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||||
@@ -68,7 +72,7 @@ class CryptoTailStrategyController(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("创建尾盘策略异常: ${e.message}", e)
|
logger.error("创建加密价差策略异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +87,7 @@ class CryptoTailStrategyController(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("更新尾盘策略失败: ${e.message}", e)
|
logger.error("更新加密价差策略失败: ${e.message}", e)
|
||||||
val code = when (e.message) {
|
val code = when (e.message) {
|
||||||
ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
||||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||||
@@ -95,7 +99,7 @@ class CryptoTailStrategyController(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新尾盘策略异常: ${e.message}", e)
|
logger.error("更新加密价差策略异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,12 +115,12 @@ class CryptoTailStrategyController(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("删除尾盘策略失败: ${e.message}", e)
|
logger.error("删除加密价差策略失败: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("删除尾盘策略异常: ${e.message}", e)
|
logger.error("删除加密价差策略异常: ${e.message}", e)
|
||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,7 +150,13 @@ class CryptoTailStrategyController(
|
|||||||
return try {
|
return try {
|
||||||
val options = listOf(
|
val options = listOf(
|
||||||
CryptoTailMarketOptionDto(slug = "btc-updown-5m", title = "Bitcoin Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
CryptoTailMarketOptionDto(slug = "btc-updown-5m", title = "Bitcoin Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
|
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "eth-updown-5m", title = "Ethereum Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "eth-updown-15m", title = "Ethereum Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "sol-updown-5m", title = "Solana Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "sol-updown-15m", title = "Solana Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "xrp-updown-5m", title = "XRP Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "xrp-updown-15m", title = "XRP Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
|
||||||
)
|
)
|
||||||
ResponseEntity.ok(ApiResponse.success(options))
|
ResponseEntity.ok(ApiResponse.success(options))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -167,8 +177,10 @@ class CryptoTailStrategyController(
|
|||||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
|
||||||
}
|
}
|
||||||
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
|
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
|
||||||
?: (System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds
|
?: ((System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds)
|
||||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
// 默认使用 BTC 市场(向后兼容)
|
||||||
|
val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown"
|
||||||
|
val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||||
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
|
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
|
||||||
val body = CryptoTailAutoMinSpreadResponse(
|
val body = CryptoTailAutoMinSpreadResponse(
|
||||||
minSpreadUp = pair.first.toPlainString(),
|
minSpreadUp = pair.first.toPlainString(),
|
||||||
@@ -180,4 +192,28 @@ class CryptoTailStrategyController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化加密价差策略监控
|
||||||
|
* 返回策略信息、开盘价、tokenIds等初始化数据
|
||||||
|
*/
|
||||||
|
@PostMapping("/monitor/init")
|
||||||
|
fun initMonitor(@RequestBody request: CryptoTailMonitorInitRequest): ResponseEntity<ApiResponse<CryptoTailMonitorInitResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.strategyId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = cryptoTailMonitorService.initMonitor(request)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("初始化加密价差策略监控失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("初始化加密价差策略监控异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控初始化请求
|
||||||
|
*/
|
||||||
|
data class CryptoTailMonitorInitRequest(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控初始化响应
|
||||||
|
*/
|
||||||
|
data class CryptoTailMonitorInitResponse(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 策略名称 */
|
||||||
|
val name: String = "",
|
||||||
|
/** 账户ID */
|
||||||
|
val accountId: Long = 0L,
|
||||||
|
/** 账户名称 */
|
||||||
|
val accountName: String = "",
|
||||||
|
/** 市场 slug 前缀 */
|
||||||
|
val marketSlugPrefix: String = "",
|
||||||
|
/** 市场标题 */
|
||||||
|
val marketTitle: String = "",
|
||||||
|
/** 周期秒数 (300=5m, 900=15m) */
|
||||||
|
val intervalSeconds: Int = 300,
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
/** 时间窗口开始秒数 */
|
||||||
|
val windowStartSeconds: Int = 0,
|
||||||
|
/** 时间窗口结束秒数 */
|
||||||
|
val windowEndSeconds: Int = 0,
|
||||||
|
/** 最低价格 */
|
||||||
|
val minPrice: String = "0",
|
||||||
|
/** 最高价格 */
|
||||||
|
val maxPrice: String = "1",
|
||||||
|
/** 最小价差模式: NONE, FIXED, AUTO */
|
||||||
|
val minSpreadMode: String = "NONE",
|
||||||
|
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
|
||||||
|
val spreadDirection: String = "MIN",
|
||||||
|
/** 最小价差数值 (FIXED 时有值) */
|
||||||
|
val minSpreadValue: String? = null,
|
||||||
|
/** 自动计算的最小价差 (Up方向) */
|
||||||
|
val autoMinSpreadUp: String? = null,
|
||||||
|
/** 自动计算的最小价差 (Down方向) */
|
||||||
|
val autoMinSpreadDown: String? = null,
|
||||||
|
/** BTC 开盘价 USDC(来自币安 K 线 open) */
|
||||||
|
val openPriceBtc: String? = null,
|
||||||
|
/** Up tokenId */
|
||||||
|
val tokenIdUp: String? = null,
|
||||||
|
/** Down tokenId */
|
||||||
|
val tokenIdDown: String? = null,
|
||||||
|
/** 当前时间 (毫秒时间戳) */
|
||||||
|
val currentTimestamp: Long = System.currentTimeMillis(),
|
||||||
|
/** 是否启用 */
|
||||||
|
val enabled: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控实时推送数据
|
||||||
|
*/
|
||||||
|
data class CryptoTailMonitorPushData(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 推送时间 (毫秒时间戳) */
|
||||||
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
/** 当前周期市场标题(周期切换时更新) */
|
||||||
|
val marketTitle: String = "",
|
||||||
|
/** 当前价格 (Up方向,来自订单簿) */
|
||||||
|
val currentPriceUp: String? = null,
|
||||||
|
/** 当前价格 (Down方向,来自订单簿) */
|
||||||
|
val currentPriceDown: String? = null,
|
||||||
|
/** 当前价差 (Up方向: 1 - currentPriceUp) */
|
||||||
|
val spreadUp: String? = null,
|
||||||
|
/** 当前价差 (Down方向: currentPriceUp) */
|
||||||
|
val spreadDown: String? = null,
|
||||||
|
/** 最小价差线 (Up方向) */
|
||||||
|
val minSpreadLineUp: String? = null,
|
||||||
|
/** 最小价差线 (Down方向,USDC 价差) */
|
||||||
|
val minSpreadLineDown: String? = null,
|
||||||
|
/** BTC 开盘价 USDC(币安 K 线 open) */
|
||||||
|
val openPriceBtc: String? = null,
|
||||||
|
/** BTC 最新价 USDC(币安 K 线 close,当前周期实时) */
|
||||||
|
val currentPriceBtc: String? = null,
|
||||||
|
/** BTC 价差 USDC(currentPriceBtc - openPriceBtc) */
|
||||||
|
val spreadBtc: String? = null,
|
||||||
|
/** 周期剩余秒数 */
|
||||||
|
val remainingSeconds: Int = 0,
|
||||||
|
/** 是否在时间窗口内 */
|
||||||
|
val inTimeWindow: Boolean = false,
|
||||||
|
/** 是否在价格区间内 (Up方向) */
|
||||||
|
val inPriceRangeUp: Boolean = false,
|
||||||
|
/** 是否在价格区间内 (Down方向) */
|
||||||
|
val inPriceRangeDown: Boolean = false,
|
||||||
|
/** 是否已触发 */
|
||||||
|
val triggered: Boolean = false,
|
||||||
|
/** 触发方向: UP, DOWN, null */
|
||||||
|
val triggerDirection: String? = null,
|
||||||
|
/** 周期是否已结束 */
|
||||||
|
val periodEnded: Boolean = false
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.dto
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略创建请求
|
* 加密价差策略创建请求
|
||||||
* 金额与价格使用 String,后端转为 BigDecimal
|
* 金额与价格使用 String,后端转为 BigDecimal
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyCreateRequest(
|
data class CryptoTailStrategyCreateRequest(
|
||||||
@@ -25,7 +25,7 @@ data class CryptoTailStrategyCreateRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略更新请求
|
* 加密价差策略更新请求
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyUpdateRequest(
|
data class CryptoTailStrategyUpdateRequest(
|
||||||
val strategyId: Long = 0L,
|
val strategyId: Long = 0L,
|
||||||
@@ -46,7 +46,7 @@ data class CryptoTailStrategyUpdateRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略列表请求
|
* 加密价差策略列表请求
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyListRequest(
|
data class CryptoTailStrategyListRequest(
|
||||||
val accountId: Long? = null,
|
val accountId: Long? = null,
|
||||||
@@ -54,7 +54,7 @@ data class CryptoTailStrategyListRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略 DTO(列表与详情)
|
* 加密价差策略 DTO(列表与详情)
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyDto(
|
data class CryptoTailStrategyDto(
|
||||||
val id: Long = 0L,
|
val id: Long = 0L,
|
||||||
@@ -90,14 +90,14 @@ data class CryptoTailStrategyDto(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略列表响应
|
* 加密价差策略列表响应
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyListResponse(
|
data class CryptoTailStrategyListResponse(
|
||||||
val list: List<CryptoTailStrategyDto> = emptyList()
|
val list: List<CryptoTailStrategyDto> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略删除请求
|
* 加密价差策略删除请求
|
||||||
*/
|
*/
|
||||||
data class CryptoTailStrategyDeleteRequest(
|
data class CryptoTailStrategyDeleteRequest(
|
||||||
val strategyId: Long = 0L
|
val strategyId: Long = 0L
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import jakarta.persistence.*
|
|||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加密市场尾盘策略实体
|
* 加密价差策略实体
|
||||||
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
|
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import java.math.BigDecimal
|
|||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略触发记录
|
* 加密价差策略触发记录
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "crypto_tail_strategy_trigger")
|
@Table(name = "crypto_tail_strategy_trigger")
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ enum class ErrorCode(
|
|||||||
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
||||||
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
||||||
|
|
||||||
// 尾盘策略 (4710-4729)
|
// 加密价差策略 (4710-4729)
|
||||||
CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "尾盘策略不存在", "error.crypto_tail_strategy_not_found"),
|
CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "加密价差策略不存在", "error.crypto_tail_strategy_not_found"),
|
||||||
CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"),
|
CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"),
|
||||||
CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED(4712, "时间区间不能超过周期长度", "error.crypto_tail_strategy_window_exceed"),
|
CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED(4712, "时间区间不能超过周期长度", "error.crypto_tail_strategy_window_exceed"),
|
||||||
CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID(4713, "周期仅支持 300 或 900 秒", "error.crypto_tail_strategy_interval_invalid"),
|
CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID(4713, "周期仅支持 300 或 900 秒", "error.crypto_tail_strategy_interval_invalid"),
|
||||||
@@ -259,11 +259,11 @@ enum class ErrorCode(
|
|||||||
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
|
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
|
||||||
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed"),
|
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed"),
|
||||||
|
|
||||||
// 尾盘策略服务 (5620-5629)
|
// 加密价差策略服务 (5620-5629)
|
||||||
SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建尾盘策略失败", "error.server.crypto_tail_strategy_create_failed"),
|
SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建加密价差策略失败", "error.server.crypto_tail_strategy_create_failed"),
|
||||||
SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新尾盘策略失败", "error.server.crypto_tail_strategy_update_failed"),
|
SERVER_CRYPTO_TAIL_STRATEGY_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_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_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");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ package com.wrbug.polymarketbot.event
|
|||||||
import org.springframework.context.ApplicationEvent
|
import org.springframework.context.ApplicationEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
|
* 加密价差策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
|
||||||
*/
|
*/
|
||||||
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
|
|||||||
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
|
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
|
||||||
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
||||||
|
|
||||||
/** 根据订单 ID 查询尾盘触发记录 */
|
/** 根据订单 ID 查询加密价差策略触发记录 */
|
||||||
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
|
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
|
||||||
|
|
||||||
/** 轮询发 TG:status=success、orderId 非空、未发过通知,按创建时间正序 */
|
/** 轮询发 TG:status=success、orderId 非空、未发过通知,按创建时间正序 */
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ class AccountService(
|
|||||||
|
|
||||||
// 7. 加密敏感信息
|
// 7. 加密敏感信息
|
||||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||||
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
|
val encryptedApiSecret = apiKeyCreds.secret.let { cryptoUtils.encrypt(it) }
|
||||||
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
|
val encryptedApiPassphrase = apiKeyCreds.passphrase.let { cryptoUtils.encrypt(it) }
|
||||||
|
|
||||||
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
||||||
val accountName = if (request.accountName.isNullOrBlank()) {
|
val accountName = if (request.accountName.isNullOrBlank()) {
|
||||||
@@ -518,8 +518,8 @@ class AccountService(
|
|||||||
}
|
}
|
||||||
val creds = result.getOrNull()
|
val creds = result.getOrNull()
|
||||||
?: return Result.failure(IllegalStateException("API Key 返回为空"))
|
?: return Result.failure(IllegalStateException("API Key 返回为空"))
|
||||||
val encryptedSecret = creds.secret?.let { cryptoUtils.encrypt(it) }
|
val encryptedSecret = creds.secret.let { cryptoUtils.encrypt(it) }
|
||||||
val encryptedPassphrase = creds.passphrase?.let { cryptoUtils.encrypt(it) }
|
val encryptedPassphrase = creds.passphrase.let { cryptoUtils.encrypt(it) }
|
||||||
val updated = account.copy(
|
val updated = account.copy(
|
||||||
apiKey = creds.apiKey,
|
apiKey = creds.apiKey,
|
||||||
apiSecret = encryptedSecret,
|
apiSecret = encryptedSecret,
|
||||||
@@ -1128,7 +1128,7 @@ class AccountService(
|
|||||||
|
|
||||||
// 3. 验证仓位是否存在并获取原始数量
|
// 3. 验证仓位是否存在并获取原始数量
|
||||||
val positionsResult = getAllPositions()
|
val positionsResult = getAllPositions()
|
||||||
val (position, originalQuantity) = positionsResult.fold(
|
val (_, originalQuantity) = positionsResult.fold(
|
||||||
onSuccess = { positionListResponse ->
|
onSuccess = { positionListResponse ->
|
||||||
val position = positionListResponse.currentPositions.find {
|
val position = positionListResponse.currentPositions.find {
|
||||||
it.accountId == request.accountId &&
|
it.accountId == request.accountId &&
|
||||||
@@ -1161,7 +1161,7 @@ class AccountService(
|
|||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
return Result.failure(Exception("查询仓位失败: ${e.message}"))
|
return Result.failure(Exception("查询仓位失败: ${e.message}"))
|
||||||
}
|
}
|
||||||
) ?: return Result.failure(IllegalArgumentException("仓位不存在"))
|
)
|
||||||
|
|
||||||
// 4. 计算实际卖出数量
|
// 4. 计算实际卖出数量
|
||||||
val sellQuantity = if (percentDecimal != null) {
|
val sellQuantity = if (percentDecimal != null) {
|
||||||
@@ -1280,7 +1280,7 @@ class AccountService(
|
|||||||
|
|
||||||
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
|
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
|
||||||
order = signedOrder,
|
order = signedOrder,
|
||||||
owner = account.apiKey!!, // API Key
|
owner = account.apiKey, // API Key
|
||||||
orderType = orderType,
|
orderType = orderType,
|
||||||
deferExec = false
|
deferExec = false
|
||||||
)
|
)
|
||||||
@@ -1300,7 +1300,7 @@ class AccountService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val clobApi = retrofitFactory.createClobApi(
|
val clobApi = retrofitFactory.createClobApi(
|
||||||
account.apiKey!!,
|
account.apiKey,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
apiPassphrase,
|
apiPassphrase,
|
||||||
account.walletAddress
|
account.walletAddress
|
||||||
|
|||||||
+1
-1
@@ -396,7 +396,7 @@ class PositionCheckService(
|
|||||||
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
||||||
|
|
||||||
for ((accountId, positions) in positionsByAccount) {
|
for ((accountId, positions) in positionsByAccount) {
|
||||||
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如尾盘策略账户也会执行赎回)
|
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如加密价差策略账户也会执行赎回)
|
||||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||||
.filter { it.enabled }
|
.filter { it.enabled }
|
||||||
|
|
||||||
|
|||||||
+21
-4
@@ -408,10 +408,25 @@ class BacktestExecutionService(
|
|||||||
val cost = actualSellQuantity.multiply(position.avgPrice)
|
val cost = actualSellQuantity.multiply(position.avgPrice)
|
||||||
val profitLoss = netAmount.subtract(cost)
|
val profitLoss = netAmount.subtract(cost)
|
||||||
|
|
||||||
// 更新余额和持仓
|
// Bug #39 Fix: correctly reduce position quantity after sell
|
||||||
currentBalance += netAmount
|
currentBalance += netAmount
|
||||||
if (position.quantity <= BigDecimal.ZERO) {
|
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) {
|
||||||
positions.remove(positionKey)
|
positions.remove(positionKey)
|
||||||
|
} else {
|
||||||
|
positions[positionKey] = position.copy(
|
||||||
|
quantity = remainingQuantity,
|
||||||
|
leaderBuyQuantity = remainingLeaderBuyQuantity
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录交易到当前页列表
|
// 记录交易到当前页列表
|
||||||
@@ -632,7 +647,8 @@ class BacktestExecutionService(
|
|||||||
val settlementPrice = avgPrice
|
val settlementPrice = avgPrice
|
||||||
|
|
||||||
val settlementValue = quantity.multiply(settlementPrice)
|
val settlementValue = quantity.multiply(settlementPrice)
|
||||||
val profitLoss = settlementValue.negate()
|
// Bug #39 Fix: profitLoss for closed settlement at avgPrice should be ~0
|
||||||
|
val profitLoss = settlementValue.subtract(quantity.multiply(avgPrice))
|
||||||
|
|
||||||
balance += settlementValue
|
balance += settlementValue
|
||||||
|
|
||||||
@@ -702,7 +718,8 @@ class BacktestExecutionService(
|
|||||||
if (balance > peakBalance) {
|
if (balance > peakBalance) {
|
||||||
peakBalance = balance
|
peakBalance = balance
|
||||||
}
|
}
|
||||||
val drawdown = peakBalance - runningBalance
|
// Bug #39 Fix: use current balance, not runningBalance from previous iteration
|
||||||
|
val drawdown = peakBalance - balance
|
||||||
if (drawdown > maxDrawdown) {
|
if (drawdown > maxDrawdown) {
|
||||||
maxDrawdown = drawdown
|
maxDrawdown = drawdown
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-12
@@ -9,7 +9,7 @@ import java.math.RoundingMode
|
|||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (interval, period)。
|
* 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (marketSlugPrefix, interval, period)。
|
||||||
* 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。
|
* 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -19,29 +19,68 @@ class BinanceKlineAutoSpreadService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
|
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
|
||||||
|
|
||||||
private val symbol = "BTCUSDC"
|
/** 市场 slug 前缀 -> Binance 交易对映射 */
|
||||||
|
private val marketToSymbol = mapOf(
|
||||||
|
"btc-updown" to "BTCUSDC",
|
||||||
|
"eth-updown" to "ETHUSDC",
|
||||||
|
"sol-updown" to "SOLUSDC",
|
||||||
|
"xrp-updown" to "XRPUSDC"
|
||||||
|
)
|
||||||
|
|
||||||
private val historyLimit = 20
|
private val historyLimit = 20
|
||||||
private val minSamplesAfterIqr = 3
|
private val minSamplesAfterIqr = 3
|
||||||
|
|
||||||
/** (intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
||||||
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||||
|
|
||||||
private fun cacheKey(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
|
/** 缓存保留时间(秒),超过则清理,防止无界增长 */
|
||||||
|
private val cacheExpireSeconds = 3600L
|
||||||
|
|
||||||
|
/** 从市场 slug 前缀获取 Binance 交易对;支持完整 slug(如 eth-updown-5m)或前缀(如 eth-updown) */
|
||||||
|
private fun getSymbol(marketSlugPrefix: String): String? {
|
||||||
|
val base = marketSlugPrefix.lowercase().removeSuffix("-15m").removeSuffix("-5m")
|
||||||
|
return marketToSymbol[base]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheKey(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
|
||||||
|
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理已过期的价差缓存,避免内存泄漏 */
|
||||||
|
private fun cleanExpiredCache() {
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val expireThreshold = nowSeconds - cacheExpireSeconds
|
||||||
|
val keysToRemove = cache.keys.filter { key ->
|
||||||
|
// key 格式: marketSlugPrefix-intervalSeconds-periodStartUnix
|
||||||
|
val parts = key.split('-')
|
||||||
|
if (parts.size >= 3) {
|
||||||
|
parts.last().toLongOrNull()?.let { it < expireThreshold } ?: false
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach { cache.remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
|
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
|
||||||
fun getAutoMinSpreadBase(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
fun getAutoMinSpreadBase(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
||||||
val key = cacheKey(intervalSeconds, periodStartUnix)
|
val key = cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||||
val (up, down) = cache[key] ?: run {
|
val (up, down) = cache[key] ?: run {
|
||||||
computeAndCache(intervalSeconds, periodStartUnix) ?: return null
|
computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix) ?: return null
|
||||||
}
|
}
|
||||||
return if (outcomeIndex == 0) up else down
|
return if (outcomeIndex == 0) up else down
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
|
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
|
||||||
fun computeAndCache(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
fun computeAndCache(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||||
|
cleanExpiredCache()
|
||||||
|
val symbol = getSymbol(marketSlugPrefix) ?: run {
|
||||||
|
logger.warn("不支持的市场 slug 前缀: $marketSlugPrefix")
|
||||||
|
return null
|
||||||
|
}
|
||||||
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
|
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
|
||||||
val endTimeMs = periodStartUnix * 1000L
|
val endTimeMs = periodStartUnix * 1000L
|
||||||
val klines = fetchKlines(intervalStr, historyLimit, endTime = endTimeMs) ?: return null
|
val klines = fetchKlines(symbol, intervalStr, historyLimit, endTime = endTimeMs) ?: return null
|
||||||
val spreadsUp = mutableListOf<BigDecimal>()
|
val spreadsUp = mutableListOf<BigDecimal>()
|
||||||
val spreadsDown = mutableListOf<BigDecimal>()
|
val spreadsDown = mutableListOf<BigDecimal>()
|
||||||
for (k in klines) {
|
for (k in klines) {
|
||||||
@@ -53,16 +92,16 @@ class BinanceKlineAutoSpreadService(
|
|||||||
}
|
}
|
||||||
val baseUp = averageAfterIqr(spreadsUp).setScale(8, RoundingMode.HALF_UP)
|
val baseUp = averageAfterIqr(spreadsUp).setScale(8, RoundingMode.HALF_UP)
|
||||||
val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP)
|
val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP)
|
||||||
cache[cacheKey(intervalSeconds, periodStartUnix)] = baseUp to baseDown
|
cache[cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)] = baseUp to baseDown
|
||||||
logger.info(
|
logger.info(
|
||||||
"尾盘自动价差已计算并缓存(100%基准): interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
|
"加密价差策略自动价差已计算并缓存(100%基准): market=$marketSlugPrefix symbol=$symbol interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
|
||||||
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
|
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
|
||||||
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
|
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
|
||||||
)
|
)
|
||||||
return baseUp to baseDown
|
return baseUp to baseDown
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchKlines(interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
|
private fun fetchKlines(symbol: String, interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
|
||||||
return try {
|
return try {
|
||||||
val api = retrofitFactory.createBinanceApi()
|
val api = retrofitFactory.createBinanceApi()
|
||||||
val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime)
|
val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime)
|
||||||
|
|||||||
+106
-66
@@ -16,10 +16,11 @@ import org.springframework.stereotype.Service
|
|||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 币安 K 线 WebSocket:订阅 BTCUSDC 5m/15m,维护当前周期 (open, close),供尾盘策略价差校验使用。
|
* 币安 K 线 WebSocket:按需订阅加密价差策略使用的币种 5m/15m,维护当前周期 (open, close),供价差校验使用。
|
||||||
|
* 仅当存在启用策略且策略使用到某市场时才订阅对应币种,无策略时不建立连接。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class BinanceKlineService {
|
class BinanceKlineService {
|
||||||
@@ -28,91 +29,133 @@ class BinanceKlineService {
|
|||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
private val wsBase = "wss://stream.binance.com:9443"
|
private val wsBase = "wss://stream.binance.com:9443"
|
||||||
private val client = createClient().build()
|
private val client by lazy {
|
||||||
|
createClient().build()
|
||||||
|
}
|
||||||
|
|
||||||
/** (intervalSeconds, periodStartUnix) -> (open, close) */
|
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (open, close) */
|
||||||
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||||
private var ws5m: WebSocket? = null
|
|
||||||
private var ws15m: WebSocket? = null
|
/** 市场 slug 前缀(如 btc-updown)-> Binance 交易对映射 */
|
||||||
private var reconnectJob: Job? = null
|
private val marketToSymbol = mapOf(
|
||||||
private val connected5m = AtomicBoolean(false)
|
"btc-updown" to "BTCUSDC",
|
||||||
private val connected15m = AtomicBoolean(false)
|
"eth-updown" to "ETHUSDC",
|
||||||
|
"sol-updown" to "SOLUSDC",
|
||||||
init {
|
"xrp-updown" to "XRPUSDC"
|
||||||
connectAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun key(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
|
|
||||||
|
|
||||||
fun getCurrentOpenClose(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
|
||||||
return openCloseByPeriod[key(intervalSeconds, periodStartUnix)]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 供 API 健康检查使用:5m / 15m 连接是否正常 */
|
|
||||||
fun getConnectionStatuses(): Map<String, Boolean> = mapOf(
|
|
||||||
"5m" to connected5m.get(),
|
|
||||||
"15m" to connected15m.get()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 已连接的 WebSocket: wsKey (symbol-interval) -> WebSocket */
|
||||||
|
private val connectedWebSockets = ConcurrentHashMap<String, WebSocket>()
|
||||||
|
/** 当前需要订阅的完整市场集合(如 btc-updown-5m、btc-updown-15m),由加密价差策略刷新时更新 */
|
||||||
|
private val requiredMarketPrefixes = AtomicReference<Set<String>>(emptySet())
|
||||||
|
private val subscriptionLock = Any()
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
|
||||||
private fun connectAll() {
|
/** 解析完整市场 slug(如 btc-updown-5m)为 (basePrefix, interval),不支持则返回 null */
|
||||||
if (ws5m != null && ws15m != null) return
|
private fun parseMarketSlug(full: String): Pair<String, String>? {
|
||||||
connectStream("btcusdc@kline_5m") { intervalSec, tMs, openP, closeP ->
|
val lower = full.lowercase()
|
||||||
val periodSec = tMs / 1000
|
return when {
|
||||||
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
|
lower.endsWith("-5m") -> Pair(lower.removeSuffix("-5m"), "5m")
|
||||||
}.also { ws5m = it }
|
lower.endsWith("-15m") -> Pair(lower.removeSuffix("-15m"), "15m")
|
||||||
connectStream("btcusdc@kline_15m") { intervalSec, tMs, openP, closeP ->
|
else -> null
|
||||||
val periodSec = tMs / 1000
|
}
|
||||||
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
|
}
|
||||||
}.also { ws15m = it }
|
|
||||||
|
/** 从市场 base 前缀(如 btc-updown)获取 Binance 交易对 */
|
||||||
|
private fun getSymbol(basePrefix: String): String? = marketToSymbol[basePrefix]
|
||||||
|
|
||||||
|
private fun key(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
|
||||||
|
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentOpenClose(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||||
|
return openCloseByPeriod[key(marketSlugPrefix, intervalSeconds, periodStartUnix)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供 API 健康检查使用:各币种各周期的连接状态 */
|
||||||
|
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||||
|
return connectedWebSockets.keys.associateWith { connectedWebSockets[it] != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按需更新订阅:仅订阅策略用到的 (币种, 周期),例如只开 btc 5min 则只建 btc 5min K 线连接。
|
||||||
|
* 由 CryptoTailOrderbookWsService 在刷新订阅时根据启用策略的 marketSlugPrefix 调用。
|
||||||
|
* @param marketPrefixes 当前启用策略用到的完整市场集合,如 ["btc-updown-5m"] 或 ["btc-updown-5m", "eth-updown-15m"];空集合时关闭所有连接
|
||||||
|
*/
|
||||||
|
fun updateSubscriptions(marketPrefixes: Set<String>) {
|
||||||
|
val normalized = marketPrefixes.map { it.lowercase() }.toSet()
|
||||||
|
|
||||||
|
val parsed = normalized.mapNotNull { full ->
|
||||||
|
parseMarketSlug(full)?.let { (base, interval) ->
|
||||||
|
getSymbol(base)?.let { symbol -> Triple(full, symbol, interval) }
|
||||||
|
}
|
||||||
|
}.toSet()
|
||||||
|
val wsKeysNeeded = parsed.map { (_, symbol, interval) -> "$symbol-$interval" }.toSet()
|
||||||
|
if (normalized == requiredMarketPrefixes.get()) return
|
||||||
|
requiredMarketPrefixes.set(normalized)
|
||||||
|
synchronized(subscriptionLock) {
|
||||||
|
connectedWebSockets.keys.toList().forEach { wsKey ->
|
||||||
|
if (wsKey !in wsKeysNeeded) {
|
||||||
|
connectedWebSockets.remove(wsKey)?.close(1000, "subscription_update")
|
||||||
|
logger.info("币安 K 线 WS 已关闭(无策略使用): $wsKey")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed.forEach { (fullPrefix, symbol, interval) ->
|
||||||
|
connectStream(symbol, interval, fullPrefix) { marketPrefixParam, intervalSec, tMs, openP, closeP ->
|
||||||
|
val periodSec = tMs / 1000
|
||||||
|
openCloseByPeriod[key(marketPrefixParam, intervalSec, periodSec)] = openP to closeP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connectStream(
|
private fun connectStream(
|
||||||
streamName: String,
|
symbol: String,
|
||||||
onKline: (intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
|
interval: String,
|
||||||
): WebSocket {
|
marketPrefix: String,
|
||||||
|
onKline: (marketPrefix: String, intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
|
||||||
|
) {
|
||||||
|
val streamName = "${symbol.lowercase()}@kline_$interval"
|
||||||
|
val wsKey = "$symbol-$interval"
|
||||||
|
if (connectedWebSockets[wsKey] != null) return
|
||||||
|
|
||||||
val url = "$wsBase/ws/$streamName"
|
val url = "$wsBase/ws/$streamName"
|
||||||
val intervalSeconds = when {
|
val intervalSeconds = when (interval) {
|
||||||
streamName.contains("kline_5m") -> 300
|
"5m" -> 300
|
||||||
streamName.contains("kline_15m") -> 900
|
"15m" -> 900
|
||||||
else -> 300
|
else -> 300
|
||||||
}
|
}
|
||||||
val request = Request.Builder().url(url).build()
|
val request = Request.Builder().url(url).build()
|
||||||
val connectedFlag = when {
|
client.newWebSocket(request, object : WebSocketListener() {
|
||||||
streamName.contains("kline_5m") -> connected5m
|
|
||||||
streamName.contains("kline_15m") -> connected15m
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val ws = client.newWebSocket(request, object : WebSocketListener() {
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
connectedFlag?.set(true)
|
connectedWebSockets[wsKey] = webSocket
|
||||||
|
logger.info("币安 K 线 WS 已连接: $streamName")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
parseKlineMessage(text, intervalSeconds)?.let { (tMs, o, c) ->
|
parseKlineMessage(text)?.let { (tMs, o, c) ->
|
||||||
onKline(intervalSeconds, tMs, o, c)
|
onKline(marketPrefix, intervalSeconds, tMs, o, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
connectedFlag?.set(false)
|
connectedWebSockets.remove(wsKey)
|
||||||
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
|
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
connectedFlag?.set(false)
|
connectedWebSockets.remove(wsKey)
|
||||||
if (code != 1000) scheduleReconnect()
|
if (code != 1000) scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
connectedFlag?.set(false)
|
connectedWebSockets.remove(wsKey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
logger.info("币安 K 线 WS 已连接: $streamName")
|
|
||||||
return ws
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseKlineMessage(text: String, intervalSeconds: Int): Triple<Long, BigDecimal, BigDecimal>? {
|
private fun parseKlineMessage(text: String): Triple<Long, BigDecimal, BigDecimal>? {
|
||||||
return try {
|
return try {
|
||||||
val json = com.google.gson.JsonParser.parseString(text).asJsonObject
|
val json = com.google.gson.JsonParser.parseString(text).asJsonObject
|
||||||
if (json.get("e")?.asString != "kline") return null
|
if (json.get("e")?.asString != "kline") return null
|
||||||
@@ -132,23 +175,20 @@ class BinanceKlineService {
|
|||||||
reconnectJob = scope.launch {
|
reconnectJob = scope.launch {
|
||||||
delay(3_000)
|
delay(3_000)
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
ws5m?.close(1000, "reconnect")
|
val current = requiredMarketPrefixes.get()
|
||||||
ws15m?.close(1000, "reconnect")
|
connectedWebSockets.values.forEach { it.close(1000, "reconnect") }
|
||||||
ws5m = null
|
connectedWebSockets.clear()
|
||||||
ws15m = null
|
|
||||||
connected5m.set(false)
|
|
||||||
connected15m.set(false)
|
|
||||||
logger.info("币安 K 线 WS 尝试重连")
|
logger.info("币安 K 线 WS 尝试重连")
|
||||||
connectAll()
|
// 清空 requiredMarketPrefixes,否则 updateSubscriptions(current) 内会因 normalized == requiredMarketPrefixes.get() 直接 return,不会重新 connectStream
|
||||||
|
requiredMarketPrefixes.set(emptySet())
|
||||||
|
updateSubscriptions(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
ws5m?.close(1000, "shutdown")
|
connectedWebSockets.values.forEach { it.close(1000, "shutdown") }
|
||||||
ws15m?.close(1000, "shutdown")
|
connectedWebSockets.clear()
|
||||||
ws5m = null
|
|
||||||
ws15m = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-10
@@ -1,11 +1,13 @@
|
|||||||
package com.wrbug.polymarketbot.service.common
|
package com.wrbug.polymarketbot.service.common
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
|
||||||
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
||||||
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
||||||
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
||||||
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
||||||
import com.wrbug.polymarketbot.service.accounts.PositionPushService
|
import com.wrbug.polymarketbot.service.accounts.PositionPushService
|
||||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -38,28 +40,47 @@ class WebSocketSubscriptionService(
|
|||||||
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
|
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
|
||||||
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
|
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
|
||||||
|
|
||||||
|
// 存储加密价差策略监控频道的订阅回调:sessionId -> (strategyId -> callback)
|
||||||
|
private val monitorChannelCallbacks = ConcurrentHashMap<String, MutableMap<Long, (CryptoTailMonitorPushData) -> Unit>>()
|
||||||
|
|
||||||
|
// 加密价差策略监控服务(延迟注入,避免循环依赖)
|
||||||
|
private var cryptoTailMonitorService: CryptoTailMonitorService? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置加密价差策略监控服务(由 Spring 在初始化后调用)
|
||||||
|
*/
|
||||||
|
fun setCryptoTailMonitorService(service: CryptoTailMonitorService) {
|
||||||
|
cryptoTailMonitorService = service
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册会话
|
* 注册会话
|
||||||
*/
|
*/
|
||||||
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
|
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
|
||||||
sessionCallbacks[sessionId] = callback
|
sessionCallbacks[sessionId] = callback
|
||||||
sessionSubscriptions[sessionId] = mutableSetOf()
|
sessionSubscriptions[sessionId] = mutableSetOf()
|
||||||
|
monitorChannelCallbacks[sessionId] = mutableMapOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注销会话
|
* 注销会话
|
||||||
*/
|
*/
|
||||||
fun unregisterSession(sessionId: String) {
|
fun unregisterSession(sessionId: String) {
|
||||||
|
|
||||||
// 取消所有订阅
|
// 取消所有订阅
|
||||||
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
|
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
|
||||||
channels.forEach { channel ->
|
channels.forEach { channel ->
|
||||||
unsubscribe(sessionId, channel)
|
unsubscribe(sessionId, channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理 order 频道的回调
|
// 清理 order 频道的回调
|
||||||
orderChannelCallbacks.remove(sessionId)
|
orderChannelCallbacks.remove(sessionId)
|
||||||
|
|
||||||
|
// 清理加密价差策略监控频道的回调
|
||||||
|
val monitorCallbacks = monitorChannelCallbacks.remove(sessionId)
|
||||||
|
monitorCallbacks?.keys?.forEach { strategyId ->
|
||||||
|
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
|
||||||
|
}
|
||||||
|
|
||||||
sessionCallbacks.remove(sessionId)
|
sessionCallbacks.remove(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +104,8 @@ class WebSocketSubscriptionService(
|
|||||||
sendSubscribeAck(sessionId, channel, true)
|
sendSubscribeAck(sessionId, channel, true)
|
||||||
|
|
||||||
// 根据频道类型启动推送服务
|
// 根据频道类型启动推送服务
|
||||||
when (channel) {
|
when {
|
||||||
"position" -> {
|
channel == "position" -> {
|
||||||
positionPushService.subscribe(sessionId) { message ->
|
positionPushService.subscribe(sessionId) { message ->
|
||||||
pushData(sessionId, channel, message)
|
pushData(sessionId, channel, message)
|
||||||
}
|
}
|
||||||
@@ -97,7 +118,7 @@ class WebSocketSubscriptionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"order" -> {
|
channel == "order" -> {
|
||||||
// 订单推送:自动订阅所有启用的账户
|
// 订单推送:自动订阅所有启用的账户
|
||||||
val callback: (OrderPushMessage) -> Unit = { message ->
|
val callback: (OrderPushMessage) -> Unit = { message ->
|
||||||
pushData(sessionId, channel, message)
|
pushData(sessionId, channel, message)
|
||||||
@@ -105,6 +126,20 @@ class WebSocketSubscriptionService(
|
|||||||
orderChannelCallbacks[sessionId] = callback
|
orderChannelCallbacks[sessionId] = callback
|
||||||
orderPushService.subscribeAllEnabled(callback)
|
orderPushService.subscribeAllEnabled(callback)
|
||||||
}
|
}
|
||||||
|
channel.startsWith("crypto_tail_monitor_") -> {
|
||||||
|
// 加密价差策略监控频道
|
||||||
|
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
|
||||||
|
if (strategyId != null && cryptoTailMonitorService != null) {
|
||||||
|
val callback: (CryptoTailMonitorPushData) -> Unit = { message ->
|
||||||
|
pushData(sessionId, channel, message)
|
||||||
|
}
|
||||||
|
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
|
||||||
|
cryptoTailMonitorService!!.subscribe(sessionId, strategyId, callback)
|
||||||
|
} else {
|
||||||
|
logger.warn("无效的加密价差策略监控频道或服务未初始化: $channel")
|
||||||
|
sendSubscribeAck(sessionId, channel, false, "无效的策略ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("未知的频道: $channel")
|
logger.warn("未知的频道: $channel")
|
||||||
sendSubscribeAck(sessionId, channel, false, "未知的频道")
|
sendSubscribeAck(sessionId, channel, false, "未知的频道")
|
||||||
@@ -122,15 +157,58 @@ class WebSocketSubscriptionService(
|
|||||||
channelSubscriptions[channel]?.remove(sessionId)
|
channelSubscriptions[channel]?.remove(sessionId)
|
||||||
|
|
||||||
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
|
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
|
||||||
when (channel) {
|
when {
|
||||||
"position" -> positionPushService.unsubscribe(sessionId)
|
channel == "position" -> positionPushService.unsubscribe(sessionId)
|
||||||
"order" -> {
|
channel == "order" -> {
|
||||||
// 取消订阅所有账户的订单推送
|
// 取消订阅所有账户的订单推送
|
||||||
val callback = orderChannelCallbacks.remove(sessionId)
|
val callback = orderChannelCallbacks.remove(sessionId)
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
orderPushService.unsubscribeAll(callback)
|
orderPushService.unsubscribeAll(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
channel.startsWith("crypto_tail_monitor_") -> {
|
||||||
|
// 取消加密价差策略监控订阅
|
||||||
|
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
|
||||||
|
if (strategyId != null) {
|
||||||
|
monitorChannelCallbacks[sessionId]?.remove(strategyId)
|
||||||
|
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册加密价差策略监控回调(由 CryptoTailMonitorService 调用)
|
||||||
|
*/
|
||||||
|
fun registerMonitorCallback(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) {
|
||||||
|
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销加密价差策略监控回调(由 CryptoTailMonitorService 调用)
|
||||||
|
*/
|
||||||
|
fun unregisterMonitorCallback(sessionId: String, strategyId: Long) {
|
||||||
|
monitorChannelCallbacks[sessionId]?.remove(strategyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送加密价差策略监控数据(由 CryptoTailMonitorService 调用)
|
||||||
|
*/
|
||||||
|
fun pushMonitorData(strategyId: Long, data: CryptoTailMonitorPushData) {
|
||||||
|
val channel = "crypto_tail_monitor_$strategyId"
|
||||||
|
val sessionIds = channelSubscriptions[channel] ?: return
|
||||||
|
|
||||||
|
for (sessionId in sessionIds) {
|
||||||
|
val callback = sessionCallbacks[sessionId]
|
||||||
|
if (callback != null) {
|
||||||
|
val message = WsMessage(
|
||||||
|
type = WebSocketMessageType.DATA.value,
|
||||||
|
channel = channel,
|
||||||
|
payload = data,
|
||||||
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
callback(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,4 +246,3 @@ class WebSocketSubscriptionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+840
@@ -0,0 +1,840 @@
|
|||||||
|
package com.wrbug.polymarketbot.service.cryptotail
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
|
||||||
|
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||||
|
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||||
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||||
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||||
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||||
|
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
|
||||||
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
|
import com.wrbug.polymarketbot.util.fromJson
|
||||||
|
import com.wrbug.polymarketbot.util.toJson
|
||||||
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
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 kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控服务
|
||||||
|
* 负责实时推送监控数据到前端
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class CryptoTailMonitorService(
|
||||||
|
private val strategyRepository: CryptoTailStrategyRepository,
|
||||||
|
private val accountRepository: AccountRepository,
|
||||||
|
private val retrofitFactory: RetrofitFactory,
|
||||||
|
private val binanceKlineService: BinanceKlineService,
|
||||||
|
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||||
|
private val webSocketSubscriptionService: WebSocketSubscriptionService
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(CryptoTailMonitorService::class.java)
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
|
/** 当前周期 token 映射 */
|
||||||
|
private val currentPeriodTokenToStrategy = AtomicReference<Map<String, List<MonitorEntry>>>(emptyMap())
|
||||||
|
|
||||||
|
/** 下一周期 token 映射 */
|
||||||
|
private val nextPeriodTokenToStrategy = AtomicReference<Map<String, List<MonitorEntry>>>(emptyMap())
|
||||||
|
|
||||||
|
/** strategyId -> 当前价格数据 */
|
||||||
|
private val strategyPriceData = ConcurrentHashMap<Long, StrategyPriceData>()
|
||||||
|
|
||||||
|
/** strategyId -> 订阅者数量 */
|
||||||
|
private val strategySubscribers = ConcurrentHashMap<Long, Int>()
|
||||||
|
|
||||||
|
private var currentPeriodWebSocket: WebSocket? = null
|
||||||
|
private var nextPeriodWebSocket: WebSocket? = null
|
||||||
|
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
|
||||||
|
|
||||||
|
private val client by lazy {
|
||||||
|
createClient().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val reconnectDelayMs = 3_000L
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
private val closedForNoSubscribers = AtomicBoolean(false)
|
||||||
|
private val connectLock = Any()
|
||||||
|
|
||||||
|
/** 防止 refreshSubscription 并发执行(周期结束时定时器与消息可能同时触发) */
|
||||||
|
private val refreshSubscriptionMutex = Mutex()
|
||||||
|
|
||||||
|
/** 周期结束倒计时 Job */
|
||||||
|
private var periodEndCountdownJob: Job? = null
|
||||||
|
|
||||||
|
/** 定时推送 Job(每 1.5 秒推送一次,保证 BTC 价格和分时图持续更新) */
|
||||||
|
private var periodicPushJob: Job? = null
|
||||||
|
private val pushIntervalMs = 1_500L
|
||||||
|
|
||||||
|
/** 策略推送历史(用于中途进入时补全分时图,最多保留 300 条) */
|
||||||
|
private val strategyPushHistory = ConcurrentHashMap<Long, MutableList<CryptoTailMonitorPushData>>()
|
||||||
|
private val strategyHistoryPeriod = ConcurrentHashMap<Long, Long>()
|
||||||
|
private val maxHistorySize = 300
|
||||||
|
|
||||||
|
/** price_change 推送节流:每策略最近一次推送时间,1s 内不重复推送 */
|
||||||
|
private val lastPriceChangePushTime = ConcurrentHashMap<Long, Long>()
|
||||||
|
private val priceChangePushThrottleMs = 1_000L
|
||||||
|
|
||||||
|
/** 当前周期/下一周期构建时缓存的市场标题,key = "strategyId-periodStartUnix",供推送携带 */
|
||||||
|
private val marketTitleByStrategyPeriod = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
data class MonitorEntry(
|
||||||
|
val strategyId: Long,
|
||||||
|
val strategy: CryptoTailStrategy,
|
||||||
|
val periodStartUnix: Long,
|
||||||
|
val outcomeIndex: Int,
|
||||||
|
val tokenId: String,
|
||||||
|
/** 是否为下一个周期(用于预先订阅) */
|
||||||
|
val isNextPeriod: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StrategyPriceData(
|
||||||
|
val currentPriceUp: BigDecimal? = null,
|
||||||
|
val currentPriceDown: BigDecimal? = null,
|
||||||
|
/** BTC 开盘价 USDC(币安 K 线 open) */
|
||||||
|
val openPriceBtc: BigDecimal? = null,
|
||||||
|
val spreadUp: BigDecimal? = null,
|
||||||
|
val spreadDown: BigDecimal? = null,
|
||||||
|
val minSpreadLineUp: BigDecimal? = null,
|
||||||
|
val minSpreadLineDown: BigDecimal? = null,
|
||||||
|
val triggered: Boolean = false,
|
||||||
|
val triggerDirection: String? = null,
|
||||||
|
val lastUpdateTime: Long = System.currentTimeMillis(),
|
||||||
|
/** 当前周期开始时间(用于双连接周期切换) */
|
||||||
|
val periodStartUnix: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
fun init() {
|
||||||
|
// 服务启动时不主动连接,等待前端订阅
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化监控数据
|
||||||
|
*/
|
||||||
|
fun initMonitor(request: CryptoTailMonitorInitRequest): Result<CryptoTailMonitorInitResponse> {
|
||||||
|
return try {
|
||||||
|
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
|
||||||
|
if (strategy == null) {
|
||||||
|
return Result.failure(IllegalArgumentException("策略不存在"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val account = accountRepository.findById(strategy.accountId).orElse(null)
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||||
|
|
||||||
|
// 获取市场信息
|
||||||
|
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||||
|
val event = fetchEventBySlug(slug).getOrNull()
|
||||||
|
val market = event?.markets?.firstOrNull()
|
||||||
|
val tokenIds = parseClobTokenIds(market?.clobTokenIds)
|
||||||
|
|
||||||
|
// 获取开盘价(币安 K 线 open = BTC 价格 USDC)
|
||||||
|
val openClose = binanceKlineService.getCurrentOpenClose(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
val openPriceBtc = openClose?.first
|
||||||
|
|
||||||
|
// 获取自动计算的最小价差
|
||||||
|
var autoMinSpreadUp: BigDecimal? = null
|
||||||
|
var autoMinSpreadDown: BigDecimal? = null
|
||||||
|
if (strategy.spreadMode.name.uppercase() == "AUTO") {
|
||||||
|
val autoSpreads = binanceKlineAutoSpreadService.computeAndCache(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
autoMinSpreadUp = autoSpreads?.first
|
||||||
|
autoMinSpreadDown = autoSpreads?.second
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存价格数据到缓存
|
||||||
|
val priceData = StrategyPriceData(
|
||||||
|
openPriceBtc = openPriceBtc,
|
||||||
|
minSpreadLineUp = autoMinSpreadUp ?: strategy.spreadValue?.toSafeBigDecimal(),
|
||||||
|
minSpreadLineDown = autoMinSpreadDown ?: strategy.spreadValue?.toSafeBigDecimal(),
|
||||||
|
periodStartUnix = periodStartUnix
|
||||||
|
)
|
||||||
|
strategyPriceData[strategy.id!!] = priceData
|
||||||
|
|
||||||
|
val response = CryptoTailMonitorInitResponse(
|
||||||
|
strategyId = strategy.id!!,
|
||||||
|
name = strategy.name ?: "",
|
||||||
|
accountId = strategy.accountId,
|
||||||
|
accountName = account?.accountName ?: "",
|
||||||
|
marketSlugPrefix = strategy.marketSlugPrefix,
|
||||||
|
marketTitle = event?.title ?: strategy.marketSlugPrefix,
|
||||||
|
intervalSeconds = strategy.intervalSeconds,
|
||||||
|
periodStartUnix = periodStartUnix,
|
||||||
|
windowStartSeconds = strategy.windowStartSeconds,
|
||||||
|
windowEndSeconds = strategy.windowEndSeconds,
|
||||||
|
minPrice = strategy.minPrice.toPlainString(),
|
||||||
|
maxPrice = strategy.maxPrice.toPlainString(),
|
||||||
|
minSpreadMode = strategy.spreadMode.name,
|
||||||
|
spreadDirection = strategy.spreadDirection.name,
|
||||||
|
minSpreadValue = strategy.spreadValue?.toPlainString(),
|
||||||
|
autoMinSpreadUp = autoMinSpreadUp?.toPlainString(),
|
||||||
|
autoMinSpreadDown = autoMinSpreadDown?.toPlainString(),
|
||||||
|
openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
tokenIdUp = tokenIds.getOrNull(0),
|
||||||
|
tokenIdDown = tokenIds.getOrNull(1),
|
||||||
|
currentTimestamp = System.currentTimeMillis(),
|
||||||
|
enabled = strategy.enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
Result.success(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("初始化监控失败: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅策略监控
|
||||||
|
*/
|
||||||
|
fun subscribe(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) {
|
||||||
|
// 增加订阅计数
|
||||||
|
val count = strategySubscribers.merge(strategyId, 1) { old, inc -> old + inc } ?: 1
|
||||||
|
|
||||||
|
// 注册推送回调
|
||||||
|
webSocketSubscriptionService.registerMonitorCallback(sessionId, strategyId, callback)
|
||||||
|
|
||||||
|
// 如果是第一个订阅者,启动 WebSocket 和定时推送
|
||||||
|
if (count == 1) {
|
||||||
|
scope.launch {
|
||||||
|
refreshSubscription()
|
||||||
|
}
|
||||||
|
startPeriodicPush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即发送当前数据
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
sendCurrentData(sessionId, strategyId, callback)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("发送当前监控数据失败: $sessionId, ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订阅策略监控
|
||||||
|
*/
|
||||||
|
fun unsubscribe(sessionId: String, strategyId: Long) {
|
||||||
|
// 减少订阅计数
|
||||||
|
val currentCount = strategySubscribers[strategyId] ?: 0
|
||||||
|
val newCount = (currentCount - 1).coerceAtLeast(0)
|
||||||
|
|
||||||
|
if (newCount == 0) {
|
||||||
|
strategySubscribers.remove(strategyId)
|
||||||
|
} else {
|
||||||
|
strategySubscribers[strategyId] = newCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除回调
|
||||||
|
webSocketSubscriptionService.unregisterMonitorCallback(sessionId, strategyId)
|
||||||
|
|
||||||
|
// 如果没有订阅者,关闭 WebSocket 和定时推送
|
||||||
|
if (newCount == 0) {
|
||||||
|
scope.launch {
|
||||||
|
refreshSubscription()
|
||||||
|
}
|
||||||
|
stopPeriodicPush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPeriodicPush() {
|
||||||
|
if (periodicPushJob?.isActive == true) return
|
||||||
|
periodicPushJob = scope.launch {
|
||||||
|
while (strategySubscribers.isNotEmpty() && strategySubscribers.values.any { (it ?: 0) > 0 }) {
|
||||||
|
delay(pushIntervalMs)
|
||||||
|
if (closedForNoSubscribers.get()) continue
|
||||||
|
val ids = strategySubscribers.filter { (it.value ?: 0) > 0 }.keys.toList()
|
||||||
|
for (strategyId in ids) {
|
||||||
|
try {
|
||||||
|
val strategy = strategyRepository.findById(strategyId).orElse(null) ?: continue
|
||||||
|
val priceData = strategyPriceData[strategyId] ?: continue
|
||||||
|
val pushData = buildPushData(strategy, priceData)
|
||||||
|
addToHistoryAndPush(strategyId, pushData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.debug("定时推送失败 strategyId=$strategyId: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPeriodicPush() {
|
||||||
|
if (strategySubscribers.isEmpty() || strategySubscribers.values.all { (it ?: 0) <= 0 }) {
|
||||||
|
periodicPushJob?.cancel()
|
||||||
|
periodicPushJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送当前数据(含历史补全,用于中途进入时填充分时图)
|
||||||
|
*/
|
||||||
|
private suspend fun sendCurrentData(
|
||||||
|
sessionId: String,
|
||||||
|
strategyId: Long,
|
||||||
|
callback: (CryptoTailMonitorPushData) -> Unit
|
||||||
|
) {
|
||||||
|
val strategy = strategyRepository.findById(strategyId).orElse(null) ?: return
|
||||||
|
val priceData = strategyPriceData[strategyId] ?: StrategyPriceData()
|
||||||
|
|
||||||
|
val history = strategyPushHistory[strategyId]?.let { list ->
|
||||||
|
synchronized(list) { list.toList() }
|
||||||
|
} ?: emptyList()
|
||||||
|
for (item in history) {
|
||||||
|
callback(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pushData = buildPushData(strategy, priceData)
|
||||||
|
callback(pushData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新订阅:双连接模式。当前周期连接 + 下一周期连接;周期切换时关闭过期连接,下一连接晋升为当前,并新建下一周期连接。
|
||||||
|
* 使用 Mutex 防止周期结束时 scheduleRefreshAtPeriodEnd 与 maybeRefreshSubscriptionIfPeriodChanged 同时触发导致重复执行。
|
||||||
|
*/
|
||||||
|
private suspend fun refreshSubscription() {
|
||||||
|
if (!refreshSubscriptionMutex.tryLock()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
refreshSubscriptionInternal()
|
||||||
|
} finally {
|
||||||
|
refreshSubscriptionMutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshSubscriptionInternal() {
|
||||||
|
periodEndCountdownJob?.cancel()
|
||||||
|
periodEndCountdownJob = null
|
||||||
|
|
||||||
|
val subscribedStrategyIds = strategySubscribers.keys.filter { (strategySubscribers[it] ?: 0) > 0 }
|
||||||
|
if (subscribedStrategyIds.isEmpty()) {
|
||||||
|
closeAllWebSockets()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val strategies = strategyRepository.findAllById(subscribedStrategyIds).filter { it.enabled && it.id != null }
|
||||||
|
if (strategies.isEmpty()) {
|
||||||
|
closeAllWebSockets()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val isSwitch = currentPeriodWebSocket != null
|
||||||
|
|
||||||
|
if (isSwitch) {
|
||||||
|
// 周期切换:关闭当前周期连接,下一晋升为当前,新建下一周期连接
|
||||||
|
closeCurrentPeriodWebSocket()
|
||||||
|
currentPeriodWebSocket = nextPeriodWebSocket
|
||||||
|
nextPeriodWebSocket = null
|
||||||
|
val nextMap = nextPeriodTokenToStrategy.get()
|
||||||
|
currentPeriodTokenToStrategy.set(nextMap)
|
||||||
|
val nextPeriodByStrategy =
|
||||||
|
nextMap.values.flatten().distinctBy { it.strategyId }.associate { it.strategyId to it.periodStartUnix }
|
||||||
|
logger.info("周期切换:下一周期连接晋升为当前")
|
||||||
|
for ((strategyId, periodStartUnix) in nextPeriodByStrategy) {
|
||||||
|
updateStrategyPriceDataForPeriod(listOf(strategyId), periodStartUnix, pushDefault = true)
|
||||||
|
}
|
||||||
|
val (newNextTokenIds, newNextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds)
|
||||||
|
nextPeriodTokenToStrategy.set(newNextMap)
|
||||||
|
if (newNextTokenIds.isNotEmpty()) {
|
||||||
|
connectNextPeriod(newNextTokenIds, newNextMap)
|
||||||
|
} else {
|
||||||
|
logger.info("下一周期市场尚未创建,仅建立空连接以便周期切换时复用")
|
||||||
|
connectNextPeriod(emptyList(), emptyMap())
|
||||||
|
}
|
||||||
|
scheduleRefreshAtPeriodEnd(if (newNextMap.isNotEmpty()) newNextMap else nextMap)
|
||||||
|
} else {
|
||||||
|
// 首次:建立当前周期连接 + 下一周期连接
|
||||||
|
val (currentTokenIds, currentMap) = buildSubscriptionMapForCurrentPeriod(subscribedStrategyIds)
|
||||||
|
currentPeriodTokenToStrategy.set(currentMap)
|
||||||
|
for (entry in currentMap.values.flatten().distinctBy { it.strategyId }) {
|
||||||
|
updateStrategyPriceDataForPeriod(listOf(entry.strategyId), entry.periodStartUnix, pushDefault = false)
|
||||||
|
}
|
||||||
|
if (currentTokenIds.isEmpty()) {
|
||||||
|
closeAllWebSockets()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectCurrentPeriod(currentTokenIds, currentMap)
|
||||||
|
val (nextTokenIds, nextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds)
|
||||||
|
nextPeriodTokenToStrategy.set(nextMap)
|
||||||
|
if (nextTokenIds.isNotEmpty()) {
|
||||||
|
connectNextPeriod(nextTokenIds, nextMap)
|
||||||
|
} else {
|
||||||
|
logger.info("下一周期市场尚未创建,先建立空连接,周期切换时会重新订阅")
|
||||||
|
connectNextPeriod(emptyList(), emptyMap())
|
||||||
|
}
|
||||||
|
scheduleRefreshAtPeriodEnd(currentMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建当前周期订阅(每个策略按自己的 interval 算当前周期) */
|
||||||
|
private suspend fun buildSubscriptionMapForCurrentPeriod(strategyIds: List<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
|
||||||
|
val strategies = strategyRepository.findAllById(strategyIds)
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val tokenIdSet = mutableSetOf<String>()
|
||||||
|
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
|
||||||
|
|
||||||
|
for (strategy in strategies) {
|
||||||
|
if (!strategy.enabled || strategy.id == null) continue
|
||||||
|
val strategyPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||||
|
val slug = "${strategy.marketSlugPrefix}-$strategyPeriod"
|
||||||
|
val event = fetchEventBySlug(slug).getOrNull() ?: continue
|
||||||
|
marketTitleByStrategyPeriod["${strategy.id!!}-$strategyPeriod"] = event.title ?: strategy.marketSlugPrefix
|
||||||
|
val market = event.markets?.firstOrNull() ?: continue
|
||||||
|
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||||
|
if (tokenIds.size < 2) continue
|
||||||
|
for (i in tokenIds.indices) {
|
||||||
|
tokenIdSet.add(tokenIds[i])
|
||||||
|
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||||
|
MonitorEntry(strategy.id!!, strategy, strategyPeriod, i, tokenIds[i], false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Pair(tokenIdSet.toList(), map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建下一周期订阅(每个策略按自己的 interval 算下一周期) */
|
||||||
|
private suspend fun buildSubscriptionMapForNextPeriod(strategyIds: List<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
|
||||||
|
val strategies = strategyRepository.findAllById(strategyIds)
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val tokenIdSet = mutableSetOf<String>()
|
||||||
|
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
|
||||||
|
|
||||||
|
for (strategy in strategies) {
|
||||||
|
if (!strategy.enabled || strategy.id == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val currentPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||||
|
val nextPeriod = currentPeriod + strategy.intervalSeconds
|
||||||
|
val slug = "${strategy.marketSlugPrefix}-$nextPeriod"
|
||||||
|
val event = fetchEventBySlug(slug).getOrNull()
|
||||||
|
if (event == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
marketTitleByStrategyPeriod["${strategy.id!!}-$nextPeriod"] = event.title ?: strategy.marketSlugPrefix
|
||||||
|
val market = event.markets?.firstOrNull()
|
||||||
|
if (market == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||||
|
if (tokenIds.size < 2) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (i in tokenIds.indices) {
|
||||||
|
tokenIdSet.add(tokenIds[i])
|
||||||
|
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||||
|
MonitorEntry(strategy.id!!, strategy, nextPeriod, i, tokenIds[i], true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Pair(tokenIdSet.toList(), map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新策略价格数据为指定周期(开盘价、价差线等),可选是否推送默认 0.5 */
|
||||||
|
private suspend fun updateStrategyPriceDataForPeriod(
|
||||||
|
strategyIds: List<Long>,
|
||||||
|
periodStartUnix: Long,
|
||||||
|
pushDefault: Boolean
|
||||||
|
) {
|
||||||
|
val strategies = strategyRepository.findAllById(strategyIds)
|
||||||
|
for (strategy in strategies) {
|
||||||
|
if (strategy.id == null) continue
|
||||||
|
val openClose = binanceKlineService.getCurrentOpenClose(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
val openPriceBtc = openClose?.first
|
||||||
|
var minSpreadLineUp: BigDecimal? = null
|
||||||
|
var minSpreadLineDown: BigDecimal? = null
|
||||||
|
when (strategy.spreadMode.name.uppercase()) {
|
||||||
|
"FIXED" -> {
|
||||||
|
minSpreadLineUp = strategy.spreadValue?.toSafeBigDecimal()
|
||||||
|
minSpreadLineDown = strategy.spreadValue?.toSafeBigDecimal()
|
||||||
|
}
|
||||||
|
|
||||||
|
"AUTO" -> {
|
||||||
|
val autoSpreads = binanceKlineAutoSpreadService.computeAndCache(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
minSpreadLineUp = autoSpreads?.first
|
||||||
|
minSpreadLineDown = autoSpreads?.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val existingData = strategyPriceData[strategy.id] ?: StrategyPriceData()
|
||||||
|
val periodChanged = existingData.periodStartUnix != null && existingData.periodStartUnix != periodStartUnix
|
||||||
|
val newData = StrategyPriceData(
|
||||||
|
currentPriceUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceUp,
|
||||||
|
currentPriceDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceDown,
|
||||||
|
spreadUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadUp,
|
||||||
|
spreadDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadDown,
|
||||||
|
openPriceBtc = openPriceBtc,
|
||||||
|
minSpreadLineUp = minSpreadLineUp,
|
||||||
|
minSpreadLineDown = minSpreadLineDown,
|
||||||
|
periodStartUnix = periodStartUnix
|
||||||
|
)
|
||||||
|
strategyPriceData[strategy.id!!] = newData
|
||||||
|
if (periodChanged && pushDefault) {
|
||||||
|
val pushData = buildPushData(strategy, newData)
|
||||||
|
addToHistoryAndPush(strategy.id!!, pushData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectCurrentPeriod(tokenIds: List<String>, map: Map<String, List<MonitorEntry>>) {
|
||||||
|
if (currentPeriodWebSocket != null) return
|
||||||
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
|
currentPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
|
closedForNoSubscribers.set(false)
|
||||||
|
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||||
|
try {
|
||||||
|
webSocket.send(msg)
|
||||||
|
logger.info("加密价差策略监控 WebSocket(当前周期)已连接并订阅: ${tokenIds.size} 个 token")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("发送当前周期订阅失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
handleMessage(webSocket, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) {
|
||||||
|
this@CryptoTailMonitorService.currentPeriodWebSocket = null
|
||||||
|
if (!closedForNoSubscribers.get()) scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
|
if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) {
|
||||||
|
this@CryptoTailMonitorService.currentPeriodWebSocket = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectNextPeriod(tokenIds: List<String>, map: Map<String, List<MonitorEntry>>) {
|
||||||
|
if (nextPeriodWebSocket != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
|
nextPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
|
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||||
|
try {
|
||||||
|
webSocket.send(msg)
|
||||||
|
if (tokenIds.isEmpty()) {
|
||||||
|
logger.info("加密价差策略监控 WebSocket(下一周期)已连接,暂无 token 订阅,等待周期切换后更新")
|
||||||
|
} else {
|
||||||
|
logger.info("加密价差策略监控 WebSocket(下一周期)已连接并订阅: ${tokenIds.size} 个 token")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("发送下一周期订阅失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
handleMessage(webSocket, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) {
|
||||||
|
this@CryptoTailMonitorService.nextPeriodWebSocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
|
if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) {
|
||||||
|
this@CryptoTailMonitorService.nextPeriodWebSocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeCurrentPeriodWebSocket() {
|
||||||
|
currentPeriodWebSocket?.close(1000, "period_ended")
|
||||||
|
currentPeriodWebSocket = null
|
||||||
|
logger.info("加密价差策略监控 WebSocket(当前周期)已关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeAllWebSockets() {
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
closedForNoSubscribers.set(true)
|
||||||
|
currentPeriodWebSocket?.close(1000, "no_subscribers")
|
||||||
|
currentPeriodWebSocket = null
|
||||||
|
nextPeriodWebSocket?.close(1000, "no_subscribers")
|
||||||
|
nextPeriodWebSocket = null
|
||||||
|
logger.info("加密价差策略监控 WebSocket 已全部关闭(无订阅者)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMessage(webSocket: WebSocket, text: String) {
|
||||||
|
if (text == "pong" || text.isEmpty()) return
|
||||||
|
if (closedForNoSubscribers.get()) return
|
||||||
|
|
||||||
|
maybeRefreshSubscriptionIfPeriodChanged()
|
||||||
|
|
||||||
|
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
|
||||||
|
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||||
|
val map = currentPeriodTokenToStrategy.get()
|
||||||
|
|
||||||
|
when (eventType) {
|
||||||
|
"price_change" -> {
|
||||||
|
val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return
|
||||||
|
for (i in 0 until priceChanges.size()) {
|
||||||
|
val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue
|
||||||
|
val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue
|
||||||
|
val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString
|
||||||
|
val bestBid = bestBidStr?.toSafeBigDecimal()
|
||||||
|
if (bestBid != null) onPriceUpdate(assetId, bestBid, map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPriceUpdate(tokenId: String, bestBid: BigDecimal, map: Map<String, List<MonitorEntry>>) {
|
||||||
|
if (closedForNoSubscribers.get()) return
|
||||||
|
val entries = map[tokenId] ?: return
|
||||||
|
|
||||||
|
for (entry in entries) {
|
||||||
|
val strategy = entry.strategy
|
||||||
|
val priceData = strategyPriceData[strategy.id!!] ?: StrategyPriceData()
|
||||||
|
|
||||||
|
// 根据方向更新价格
|
||||||
|
val newPriceData = if (entry.outcomeIndex == 0) {
|
||||||
|
// Up 方向
|
||||||
|
priceData.copy(
|
||||||
|
currentPriceUp = bestBid,
|
||||||
|
currentPriceDown = BigDecimal.ONE.subtract(bestBid),
|
||||||
|
spreadUp = BigDecimal.ONE.subtract(bestBid),
|
||||||
|
spreadDown = bestBid,
|
||||||
|
lastUpdateTime = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Down 方向
|
||||||
|
priceData.copy(
|
||||||
|
currentPriceDown = bestBid,
|
||||||
|
currentPriceUp = BigDecimal.ONE.subtract(bestBid),
|
||||||
|
spreadUp = bestBid,
|
||||||
|
spreadDown = BigDecimal.ONE.subtract(bestBid),
|
||||||
|
lastUpdateTime = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
strategyPriceData[strategy.id!!] = newPriceData
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val last = lastPriceChangePushTime[strategy.id!!] ?: 0L
|
||||||
|
if (now - last >= priceChangePushThrottleMs) {
|
||||||
|
lastPriceChangePushTime[strategy.id!!] = now
|
||||||
|
val pushData = buildPushData(strategy, newPriceData)
|
||||||
|
addToHistoryAndPush(strategy.id!!, pushData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addToHistoryAndPush(strategyId: Long, pushData: CryptoTailMonitorPushData) {
|
||||||
|
addToHistory(strategyId, pushData)
|
||||||
|
webSocketSubscriptionService.pushMonitorData(strategyId, pushData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addToHistory(strategyId: Long, pushData: CryptoTailMonitorPushData) {
|
||||||
|
val list = strategyPushHistory.getOrPut(strategyId) {
|
||||||
|
Collections.synchronizedList(mutableListOf<CryptoTailMonitorPushData>())
|
||||||
|
}
|
||||||
|
synchronized(list) {
|
||||||
|
val lastPeriod = strategyHistoryPeriod[strategyId]
|
||||||
|
if (lastPeriod != null && lastPeriod != pushData.periodStartUnix) {
|
||||||
|
list.clear()
|
||||||
|
}
|
||||||
|
strategyHistoryPeriod[strategyId] = pushData.periodStartUnix
|
||||||
|
list.add(pushData)
|
||||||
|
while (list.size > maxHistorySize) {
|
||||||
|
list.removeAt(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建推送数据
|
||||||
|
* 最新价、价差使用币安 K 线的 BTC 价格(open/close)
|
||||||
|
*/
|
||||||
|
private fun buildPushData(strategy: CryptoTailStrategy, priceData: StrategyPriceData): CryptoTailMonitorPushData {
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||||
|
val periodEndUnix = periodStartUnix + strategy.intervalSeconds
|
||||||
|
val remainingSeconds = (periodEndUnix - nowSeconds).toInt().coerceAtLeast(0)
|
||||||
|
|
||||||
|
val windowStart = periodStartUnix + strategy.windowStartSeconds
|
||||||
|
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
||||||
|
val inTimeWindow = nowSeconds >= windowStart && nowSeconds < windowEnd
|
||||||
|
|
||||||
|
// 币安 K 线:open = 周期开盘价,close = 当前最新价(实时更新)
|
||||||
|
val openClose = binanceKlineService.getCurrentOpenClose(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
val openPriceBtc = priceData.openPriceBtc ?: openClose?.first
|
||||||
|
val currentPriceBtc = openClose?.second
|
||||||
|
// K 线数据回来后更新缓存,供后续使用
|
||||||
|
if (openPriceBtc != null && priceData.openPriceBtc == null && strategy.id != null) {
|
||||||
|
strategyPriceData[strategy.id] = priceData.copy(openPriceBtc = openPriceBtc)
|
||||||
|
}
|
||||||
|
val spreadBtc = if (openPriceBtc != null && currentPriceBtc != null) {
|
||||||
|
currentPriceBtc.subtract(openPriceBtc)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
// 判断价格区间(Polymarket 0-1)
|
||||||
|
val currentUp = priceData.currentPriceUp
|
||||||
|
val currentDown = priceData.currentPriceDown
|
||||||
|
val inPriceRangeUp = currentUp != null &&
|
||||||
|
currentUp >= strategy.minPrice && currentUp <= strategy.maxPrice
|
||||||
|
val inPriceRangeDown = currentDown != null &&
|
||||||
|
currentDown >= strategy.minPrice && currentDown <= strategy.maxPrice
|
||||||
|
|
||||||
|
val marketTitle = marketTitleByStrategyPeriod["${strategy.id!!}-$periodStartUnix"] ?: strategy.marketSlugPrefix
|
||||||
|
|
||||||
|
return CryptoTailMonitorPushData(
|
||||||
|
strategyId = strategy.id!!,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
periodStartUnix = periodStartUnix,
|
||||||
|
marketTitle = marketTitle,
|
||||||
|
currentPriceUp = priceData.currentPriceUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
currentPriceDown = priceData.currentPriceDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
spreadUp = priceData.spreadUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
spreadDown = priceData.spreadDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
minSpreadLineUp = priceData.minSpreadLineUp?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
minSpreadLineDown = priceData.minSpreadLineDown?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
currentPriceBtc = currentPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
spreadBtc = spreadBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
remainingSeconds = remainingSeconds,
|
||||||
|
inTimeWindow = inTimeWindow,
|
||||||
|
inPriceRangeUp = inPriceRangeUp,
|
||||||
|
inPriceRangeDown = inPriceRangeDown,
|
||||||
|
triggered = priceData.triggered,
|
||||||
|
triggerDirection = priceData.triggerDirection,
|
||||||
|
periodEnded = remainingSeconds <= 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maybeRefreshSubscriptionIfPeriodChanged() {
|
||||||
|
val subscribed = currentPeriodTokenToStrategy.get().values.flatten().distinctBy { it.strategyId }
|
||||||
|
.associate { it.strategyId to it.periodStartUnix }
|
||||||
|
if (subscribed.isEmpty()) return
|
||||||
|
|
||||||
|
val strategies = strategyRepository.findAllById(subscribed.keys)
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
|
||||||
|
for (s in strategies) {
|
||||||
|
if (s.id == null) continue
|
||||||
|
val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds
|
||||||
|
val subPeriod = subscribed[s.id] ?: continue
|
||||||
|
if (currentPeriod != subPeriod) {
|
||||||
|
scope.launch { refreshSubscription() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRefreshAtPeriodEnd(newMap: Map<String, List<MonitorEntry>>) {
|
||||||
|
val entries = newMap.values.flatten()
|
||||||
|
if (entries.isEmpty()) return
|
||||||
|
|
||||||
|
val nextPeriodEndSeconds = entries.minOf { it.periodStartUnix + it.strategy.intervalSeconds }
|
||||||
|
val delayMs = (nextPeriodEndSeconds * 1000) - System.currentTimeMillis() + 2000
|
||||||
|
if (delayMs <= 0) return
|
||||||
|
|
||||||
|
periodEndCountdownJob = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
periodEndCountdownJob = null
|
||||||
|
refreshSubscription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeWebSocketForNoSubscribers() {
|
||||||
|
closeAllWebSockets()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (reconnectJob?.isActive == true) return
|
||||||
|
reconnectJob = scope.launch {
|
||||||
|
delay(reconnectDelayMs)
|
||||||
|
reconnectJob = null
|
||||||
|
if (strategySubscribers.isNotEmpty()) {
|
||||||
|
logger.info("加密价差策略监控 WebSocket 尝试重连")
|
||||||
|
refreshSubscription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||||
|
return try {
|
||||||
|
val api = retrofitFactory.createGammaApi()
|
||||||
|
val response = runBlocking { api.getEventBySlug(slug) }
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.success(response.body()!!)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||||
|
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||||
|
return clobTokenIds.fromJson<List<String>>() ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
periodEndCountdownJob?.cancel()
|
||||||
|
periodicPushJob?.cancel()
|
||||||
|
currentPeriodWebSocket?.close(1000, "shutdown")
|
||||||
|
currentPeriodWebSocket = null
|
||||||
|
nextPeriodWebSocket?.close(1000, "shutdown")
|
||||||
|
nextPeriodWebSocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-9
@@ -19,9 +19,10 @@ import org.springframework.context.ApplicationContextAware
|
|||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略订单 TG 通知轮询服务(与跟单一致)
|
* 加密价差策略订单 TG 通知轮询服务(与跟单一致)
|
||||||
* 定时查询「下单成功且未发 TG」的触发记录,通过 CLOB getOrder 获取订单详情后发送 TG 并标记已发。
|
* 定时查询「下单成功且未发 TG」的触发记录,通过 CLOB getOrder 获取订单详情后发送 TG 并标记已发。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -36,7 +37,8 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
) : ApplicationContextAware {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationPollingService::class.java)
|
private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationPollingService::class.java)
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scopeJob = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + scopeJob)
|
||||||
|
|
||||||
private var applicationContext: ApplicationContext? = null
|
private var applicationContext: ApplicationContext? = null
|
||||||
|
|
||||||
@@ -55,14 +57,14 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
@Scheduled(fixedDelay = 5000)
|
@Scheduled(fixedDelay = 5000)
|
||||||
fun scheduledSendPendingNotifications() {
|
fun scheduledSendPendingNotifications() {
|
||||||
if (notificationJob != null && notificationJob!!.isActive) {
|
if (notificationJob != null && notificationJob!!.isActive) {
|
||||||
logger.debug("上一轮尾盘 TG 通知任务仍在执行,跳过本次")
|
logger.debug("上一轮加密价差策略 TG 通知任务仍在执行,跳过本次")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
notificationJob = scope.launch {
|
notificationJob = scope.launch {
|
||||||
try {
|
try {
|
||||||
getSelf().sendPendingNotifications()
|
getSelf().sendPendingNotifications()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("尾盘 TG 通知轮询异常: ${e.message}", e)
|
logger.error("加密价差策略 TG 通知轮询异常: ${e.message}", e)
|
||||||
} finally {
|
} finally {
|
||||||
notificationJob = null
|
notificationJob = null
|
||||||
}
|
}
|
||||||
@@ -86,7 +88,7 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
triggerRepository.save(trigger)
|
triggerRepository.save(trigger)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("尾盘 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e)
|
logger.warn("加密价差策略 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,16 +102,16 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val apiSecret = try {
|
val apiSecret = try {
|
||||||
cryptoUtils.decrypt(account.apiSecret) ?: return false
|
cryptoUtils.decrypt(account.apiSecret)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("解密 API Secret 失败: accountId=${account.id}", e)
|
logger.warn("解密 API Secret 失败: accountId=${account.id}", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val apiPassphrase = try {
|
val apiPassphrase = try {
|
||||||
cryptoUtils.decrypt(account.apiPassphrase) ?: ""
|
cryptoUtils.decrypt(account.apiPassphrase)
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) { "" }
|
||||||
val clobApi = retrofitFactory.createClobApi(
|
val clobApi = retrofitFactory.createClobApi(
|
||||||
account.apiKey!!,
|
account.apiKey,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
apiPassphrase,
|
apiPassphrase,
|
||||||
account.walletAddress
|
account.walletAddress
|
||||||
@@ -140,7 +142,14 @@ class CryptoTailOrderNotificationPollingService(
|
|||||||
walletAddress = account.walletAddress,
|
walletAddress = account.walletAddress,
|
||||||
orderTime = orderTimeMs
|
orderTime = orderTimeMs
|
||||||
)
|
)
|
||||||
logger.info("尾盘订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
|
logger.info("加密价差策略订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
notificationJob?.cancel()
|
||||||
|
notificationJob = null
|
||||||
|
scopeJob.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-69
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.enums.SpreadMode
|
|||||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||||
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import com.wrbug.polymarketbot.util.fromJson
|
import com.wrbug.polymarketbot.util.fromJson
|
||||||
@@ -28,24 +29,27 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.context.event.EventListener
|
import org.springframework.context.event.EventListener
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import jakarta.annotation.PostConstruct
|
import jakarta.annotation.PostConstruct
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。
|
* 加密价差策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class CryptoTailOrderbookWsService(
|
class CryptoTailOrderbookWsService(
|
||||||
private val strategyRepository: CryptoTailStrategyRepository,
|
private val strategyRepository: CryptoTailStrategyRepository,
|
||||||
private val executionService: CryptoTailStrategyExecutionService,
|
private val executionService: CryptoTailStrategyExecutionService,
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService
|
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||||
|
private val binanceKlineService: BinanceKlineService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
|
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scopeJob = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default + scopeJob)
|
||||||
|
|
||||||
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
|
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
|
||||||
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
|
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
|
||||||
@@ -66,6 +70,12 @@ class CryptoTailOrderbookWsService(
|
|||||||
/** 保护 connect() 的互斥锁,避免多线程并发创建连接 */
|
/** 保护 connect() 的互斥锁,避免多线程并发创建连接 */
|
||||||
private val connectLock = Any()
|
private val connectLock = Any()
|
||||||
|
|
||||||
|
/** 保护 refreshAndSubscribe() 的互斥锁,避免多线程并发刷新订阅 */
|
||||||
|
private val refreshLock = Any()
|
||||||
|
|
||||||
|
/** 标记是否正在刷新订阅,避免重复调用 */
|
||||||
|
private val isRefreshing = AtomicBoolean(false)
|
||||||
|
|
||||||
data class WsBookEntry(
|
data class WsBookEntry(
|
||||||
val strategy: CryptoTailStrategy,
|
val strategy: CryptoTailStrategy,
|
||||||
val periodStartUnix: Long,
|
val periodStartUnix: Long,
|
||||||
@@ -79,6 +89,26 @@ class CryptoTailOrderbookWsService(
|
|||||||
if (strategyRepository.findAllByEnabledTrue().isNotEmpty()) connect()
|
if (strategyRepository.findAllByEnabledTrue().isNotEmpty()) connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
periodEndCountdownJob?.cancel()
|
||||||
|
periodEndCountdownJob = null
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
synchronized(precomputeJobs) {
|
||||||
|
precomputeJobs.forEach { it.cancel() }
|
||||||
|
precomputeJobs.clear()
|
||||||
|
}
|
||||||
|
closedForNoStrategies.set(true)
|
||||||
|
try {
|
||||||
|
webSocket?.close(1000, "shutdown")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
|
||||||
|
}
|
||||||
|
webSocket = null
|
||||||
|
scopeJob.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
private fun connect() {
|
private fun connect() {
|
||||||
synchronized(connectLock) {
|
synchronized(connectLock) {
|
||||||
if (webSocket != null) return
|
if (webSocket != null) return
|
||||||
@@ -86,7 +116,7 @@ class CryptoTailOrderbookWsService(
|
|||||||
val request = Request.Builder().url(wsUrl).build()
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
logger.info("尾盘策略订单簿 WebSocket 已连接")
|
logger.info("加密价差策略订单簿 WebSocket 已连接")
|
||||||
refreshAndSubscribe(fromConnect = true)
|
refreshAndSubscribe(fromConnect = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,13 +130,13 @@ class CryptoTailOrderbookWsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
logger.warn("尾盘策略订单簿 WebSocket 异常: ${t.message}")
|
logger.warn("加密价差策略订单簿 WebSocket 异常: ${t.message}")
|
||||||
this@CryptoTailOrderbookWsService.webSocket = null
|
this@CryptoTailOrderbookWsService.webSocket = null
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("尾盘策略订单簿 WebSocket 连接失败: ${e.message}", e)
|
logger.error("加密价差策略订单簿 WebSocket 连接失败: ${e.message}", e)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +150,7 @@ class CryptoTailOrderbookWsService(
|
|||||||
delay(reconnectDelayMs)
|
delay(reconnectDelayMs)
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
if (strategyRepository.findAllByEnabledTrue().isEmpty()) return@launch
|
if (strategyRepository.findAllByEnabledTrue().isEmpty()) return@launch
|
||||||
logger.info("尾盘策略订单簿 WebSocket 尝试重连")
|
logger.info("加密价差策略订单簿 WebSocket 尝试重连")
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,16 +201,14 @@ class CryptoTailOrderbookWsService(
|
|||||||
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
|
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
runBlocking {
|
executionService.tryTriggerWithPriceFromWs(
|
||||||
executionService.tryTriggerWithPriceFromWs(
|
strategy = e.strategy,
|
||||||
strategy = e.strategy,
|
periodStartUnix = e.periodStartUnix,
|
||||||
periodStartUnix = e.periodStartUnix,
|
marketTitle = e.marketTitle,
|
||||||
marketTitle = e.marketTitle,
|
tokenIds = e.tokenIds,
|
||||||
tokenIds = e.tokenIds,
|
outcomeIndex = e.outcomeIndex,
|
||||||
outcomeIndex = e.outcomeIndex,
|
bestBid = bestBid
|
||||||
bestBid = bestBid
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
||||||
}
|
}
|
||||||
@@ -213,42 +241,56 @@ class CryptoTailOrderbookWsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
|
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
|
||||||
periodEndCountdownJob?.cancel()
|
synchronized(refreshLock) {
|
||||||
periodEndCountdownJob = null
|
// 如果正在刷新,直接返回,避免重复调用
|
||||||
val oldTokenIds = tokenToEntries.get().keys.toSet()
|
if (isRefreshing.get()) {
|
||||||
val (tokenIds, newMap) = buildSubscriptionMap()
|
logger.debug("加密价差策略订阅刷新已在进行中,跳过本次调用")
|
||||||
tokenToEntries.set(newMap)
|
|
||||||
if (tokenIds.isEmpty()) {
|
|
||||||
closeWebSocketForNoStrategies()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!fromConnect) {
|
|
||||||
if (webSocket == null) {
|
|
||||||
connect()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (oldTokenIds == tokenIds.toSet()) {
|
isRefreshing.set(true)
|
||||||
scheduleRefreshAtPeriodEnd(newMap)
|
|
||||||
precomputeAutoSpreadForCurrentPeriods(newMap)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
closeWebSocketAndReconnect()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
val marketSlugs = newMap.values.asSequence().flatten()
|
|
||||||
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
|
||||||
.map { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
|
||||||
.toList()
|
|
||||||
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
|
||||||
try {
|
try {
|
||||||
webSocket?.send(msg)
|
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||||
logger.info("尾盘策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
|
binanceKlineService.updateSubscriptions(strategies.map { it.marketSlugPrefix }.toSet())
|
||||||
} catch (e: Exception) {
|
periodEndCountdownJob?.cancel()
|
||||||
logger.warn("发送订阅失败: ${e.message}")
|
periodEndCountdownJob = null
|
||||||
return
|
val oldTokenIds = tokenToEntries.get().keys.toSet()
|
||||||
|
val (tokenIds, newMap) = buildSubscriptionMap()
|
||||||
|
tokenToEntries.set(newMap)
|
||||||
|
if (tokenIds.isEmpty()) {
|
||||||
|
closeWebSocketForNoStrategies()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!fromConnect) {
|
||||||
|
if (webSocket == null) {
|
||||||
|
connect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (oldTokenIds == tokenIds.toSet()) {
|
||||||
|
scheduleRefreshAtPeriodEnd(newMap)
|
||||||
|
precomputeAutoSpreadForCurrentPeriods(newMap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeWebSocketAndReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val marketSlugs = newMap.values.asSequence().flatten()
|
||||||
|
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||||
|
.map { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||||
|
.toList()
|
||||||
|
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||||
|
try {
|
||||||
|
webSocket?.send(msg)
|
||||||
|
logger.info("加密价差策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("发送订阅失败: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleRefreshAtPeriodEnd(newMap)
|
||||||
|
precomputeAutoSpreadForCurrentPeriods(newMap)
|
||||||
|
} finally {
|
||||||
|
isRefreshing.set(false)
|
||||||
}
|
}
|
||||||
scheduleRefreshAtPeriodEnd(newMap)
|
|
||||||
precomputeAutoSpreadForCurrentPeriods(newMap)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,37 +303,45 @@ class CryptoTailOrderbookWsService(
|
|||||||
try {
|
try {
|
||||||
ws.close(1000, "subscription_change")
|
ws.close(1000, "subscription_change")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}")
|
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
|
||||||
}
|
}
|
||||||
logger.info("尾盘策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
|
logger.info("加密价差策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 跟踪预计算价差的协程 Job,用于在关闭时取消 */
|
||||||
|
private val precomputeJobs = mutableSetOf<Job>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期价差,触发时直接用缓存。
|
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期价差,触发时直接用缓存。
|
||||||
*/
|
*/
|
||||||
private fun precomputeAutoSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
private fun precomputeAutoSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
||||||
val autoPeriods = newMap.values.asSequence().flatten()
|
val autoPeriods = newMap.values.asSequence().flatten()
|
||||||
.filter { it.strategy.spreadMode == SpreadMode.AUTO }
|
.filter { it.strategy.spreadMode == SpreadMode.AUTO }
|
||||||
.distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
|
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
|
||||||
.map { it.strategy.intervalSeconds to it.periodStartUnix }
|
.map { Triple(it.strategy.marketSlugPrefix, it.strategy.intervalSeconds, it.periodStartUnix) }
|
||||||
.toList()
|
.toList()
|
||||||
if (autoPeriods.isEmpty()) return
|
if (autoPeriods.isEmpty()) return
|
||||||
scope.launch {
|
val job = scope.launch {
|
||||||
for ((intervalSeconds, periodStartUnix) in autoPeriods) {
|
for ((marketPrefix, intervalSeconds, periodStartUnix) in autoPeriods) {
|
||||||
try {
|
try {
|
||||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
val pair = binanceKlineAutoSpreadService.computeAndCache(marketPrefix, intervalSeconds, periodStartUnix)
|
||||||
if (pair != null) {
|
if (pair != null) {
|
||||||
logger.info(
|
logger.info(
|
||||||
"周期开始初始价差: interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
|
"周期开始初始价差: market=$marketPrefix interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
|
||||||
"baseSpreadUp=${pair.first.toPlainString()} baseSpreadDown=${pair.second.toPlainString()}"
|
"baseSpreadUp=${pair.first.toPlainString()} baseSpreadDown=${pair.second.toPlainString()}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("周期开始预计算 AUTO 价差失败: interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
|
logger.warn("周期开始预计算 AUTO 价差失败: market=$marketPrefix interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
synchronized(precomputeJobs) {
|
||||||
|
precomputeJobs.add(job)
|
||||||
|
// 清理已完成的 Job,避免集合无限增长
|
||||||
|
precomputeJobs.removeIf { !it.isActive }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -307,9 +357,9 @@ class CryptoTailOrderbookWsService(
|
|||||||
try {
|
try {
|
||||||
ws.close(1000, "no_enabled_strategies")
|
ws.close(1000, "no_enabled_strategies")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}")
|
logger.debug("关闭加密价差策略 WebSocket 时异常: ${e.message}")
|
||||||
}
|
}
|
||||||
logger.info("尾盘策略订单簿 WebSocket 已关闭(无启用策略)")
|
logger.info("加密价差策略订单簿 WebSocket 已关闭(无启用策略)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +377,7 @@ class CryptoTailOrderbookWsService(
|
|||||||
periodEndCountdownJob = null
|
periodEndCountdownJob = null
|
||||||
refreshAndSubscribe()
|
refreshAndSubscribe()
|
||||||
}
|
}
|
||||||
logger.debug("尾盘策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新")
|
logger.debug("加密价差策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSubscriptionMap(): Pair<List<String>, Map<String, List<WsBookEntry>>> {
|
private fun buildSubscriptionMap(): Pair<List<String>, Map<String, List<WsBookEntry>>> {
|
||||||
@@ -341,23 +391,23 @@ class CryptoTailOrderbookWsService(
|
|||||||
val periodStartUnix = (nowSeconds / interval) * interval
|
val periodStartUnix = (nowSeconds / interval) * interval
|
||||||
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
||||||
if (nowSeconds >= windowEnd) {
|
if (nowSeconds >= windowEnd) {
|
||||||
logger.debug("尾盘策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd")
|
logger.debug("加密价差策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||||
val event = fetchEventBySlugWithRetry(slug).getOrNull()
|
val event = runBlocking { fetchEventBySlugWithRetry(slug).getOrNull() }
|
||||||
if (event == null) {
|
if (event == null) {
|
||||||
logger.warn("尾盘策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试")
|
logger.warn("加密价差策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val market = event.markets?.firstOrNull()
|
val market = event.markets?.firstOrNull()
|
||||||
if (market == null) {
|
if (market == null) {
|
||||||
logger.warn("尾盘策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug")
|
logger.warn("加密价差策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||||
if (tokenIds.size < 2) {
|
if (tokenIds.size < 2) {
|
||||||
logger.warn("尾盘策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}")
|
logger.warn("加密价差策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tokenIdSet.addAll(tokenIds)
|
tokenIdSet.addAll(tokenIds)
|
||||||
@@ -372,21 +422,21 @@ class CryptoTailOrderbookWsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 拉取事件,失败时重试最多 2 次(间隔 1s),避免瞬时失败导致多策略只订阅到其中一个 */
|
/** 拉取事件,失败时重试最多 2 次(间隔 1s),避免瞬时失败导致多策略只订阅到其中一个 */
|
||||||
private fun fetchEventBySlugWithRetry(slug: String, maxAttempts: Int = 3): Result<GammaEventBySlugResponse> {
|
private suspend fun fetchEventBySlugWithRetry(slug: String, maxAttempts: Int = 3): Result<GammaEventBySlugResponse> {
|
||||||
var lastFailure: Exception? = null
|
var lastFailure: Exception? = null
|
||||||
repeat(maxAttempts) { attempt ->
|
repeat(maxAttempts) { attempt ->
|
||||||
val result = fetchEventBySlug(slug)
|
val result = fetchEventBySlug(slug)
|
||||||
if (result.isSuccess) return result
|
if (result.isSuccess) return result
|
||||||
lastFailure = result.exceptionOrNull() as? Exception
|
lastFailure = result.exceptionOrNull() as? Exception
|
||||||
if (attempt < maxAttempts - 1) runBlocking { delay(1000L) }
|
if (attempt < maxAttempts - 1) delay(1000L)
|
||||||
}
|
}
|
||||||
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
|
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||||
return try {
|
return try {
|
||||||
val api = retrofitFactory.createGammaApi()
|
val api = retrofitFactory.createGammaApi()
|
||||||
val response = runBlocking { api.getEventBySlug(slug) }
|
val response = api.getEventBySlug(slug)
|
||||||
if (response.isSuccessful && response.body() != null) {
|
if (response.isSuccessful && response.body() != null) {
|
||||||
Result.success(response.body()!!)
|
Result.success(response.body()!!)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+24
-15
@@ -22,11 +22,12 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略结算轮询服务
|
* 加密价差策略结算轮询服务
|
||||||
* 定时扫描「状态成功但未结算」的触发记录,通过 Gamma 获取 conditionId、链上查询结算结果,计算收益并回写。
|
* 定时扫描「状态成功但未结算」的触发记录,通过 Gamma 获取 conditionId、链上查询结算结果,计算收益并回写。
|
||||||
* 实际成交价与成交量使用 Data API 的 activity 接口获取(getUserActivity),比 CLOB getOrder 更准确;失败时回退为触发时的 amountUsdc + 固定价 0.99。
|
* 实际成交价与成交量使用 Data API 的 activity 接口获取(getUserActivity),比 CLOB getOrder 更准确;失败时回退为触发时的 amountUsdc + 固定价 0.99。
|
||||||
*/
|
*/
|
||||||
@@ -44,7 +45,8 @@ class CryptoTailSettlementService(
|
|||||||
private val triggerFixedPrice = BigDecimal("0.99")
|
private val triggerFixedPrice = BigDecimal("0.99")
|
||||||
private val pnlScale = 8
|
private val pnlScale = 8
|
||||||
|
|
||||||
private val settlementScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val settlementScopeJob = SupervisorJob()
|
||||||
|
private val settlementScope = CoroutineScope(Dispatchers.IO + settlementScopeJob)
|
||||||
|
|
||||||
/** 跟踪上一轮结算任务的 Job,防止并发执行(与 OrderStatusUpdateService 一致) */
|
/** 跟踪上一轮结算任务的 Job,防止并发执行(与 OrderStatusUpdateService 一致) */
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -58,14 +60,14 @@ class CryptoTailSettlementService(
|
|||||||
fun scheduledPollAndSettle() {
|
fun scheduledPollAndSettle() {
|
||||||
val previousJob = settlementJob
|
val previousJob = settlementJob
|
||||||
if (previousJob != null && previousJob.isActive) {
|
if (previousJob != null && previousJob.isActive) {
|
||||||
logger.debug("上一轮尾盘结算任务仍在执行,跳过本次调度")
|
logger.debug("上一轮加密价差策略结算任务仍在执行,跳过本次调度")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
settlementJob = settlementScope.launch {
|
settlementJob = settlementScope.launch {
|
||||||
try {
|
try {
|
||||||
doPollAndSettle()
|
doPollAndSettle()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("尾盘策略结算定时任务异常: ${e.message}", e)
|
logger.error("加密价差策略结算定时任务异常: ${e.message}", e)
|
||||||
} finally {
|
} finally {
|
||||||
settlementJob = null
|
settlementJob = null
|
||||||
}
|
}
|
||||||
@@ -89,11 +91,11 @@ class CryptoTailSettlementService(
|
|||||||
try {
|
try {
|
||||||
if (settleOne(trigger)) settledCount++
|
if (settleOne(trigger)) settledCount++
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("尾盘结算单条失败: triggerId=${trigger.id}, ${e.message}", e)
|
logger.warn("加密价差策略结算单条失败: triggerId=${trigger.id}, ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (settledCount > 0) {
|
if (settledCount > 0) {
|
||||||
logger.info("尾盘策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount")
|
logger.info("加密价差策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount")
|
||||||
}
|
}
|
||||||
return settledCount
|
return settledCount
|
||||||
}
|
}
|
||||||
@@ -152,7 +154,7 @@ class CryptoTailSettlementService(
|
|||||||
settledAt = now
|
settledAt = now
|
||||||
)
|
)
|
||||||
triggerRepository.save(updated)
|
triggerRepository.save(updated)
|
||||||
logger.debug("尾盘结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
|
logger.debug("加密价差策略结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +201,7 @@ class CryptoTailSettlementService(
|
|||||||
conditionId: String
|
conditionId: String
|
||||||
): ActivityFill? {
|
): ActivityFill? {
|
||||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||||
logger.warn("尾盘结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}")
|
logger.warn("加密价差策略结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val user = account.proxyAddress
|
val user = account.proxyAddress
|
||||||
@@ -218,7 +220,7 @@ class CryptoTailSettlementService(
|
|||||||
sortDirection = "DESC"
|
sortDirection = "DESC"
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful || response.body() == null) {
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
logger.warn("尾盘结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}")
|
logger.warn("加密价差策略结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val activities = response.body()!!
|
val activities = response.body()!!
|
||||||
@@ -226,13 +228,13 @@ class CryptoTailSettlementService(
|
|||||||
val match = activities.firstOrNull { a ->
|
val match = activities.firstOrNull { a ->
|
||||||
a.type == "TRADE" &&
|
a.type == "TRADE" &&
|
||||||
a.conditionId == conditionId &&
|
a.conditionId == conditionId &&
|
||||||
a.outcomeIndex != null && a.outcomeIndex!! in 0..1 &&
|
a.outcomeIndex != null && a.outcomeIndex in 0..1 &&
|
||||||
a.outcomeIndex == trigger.outcomeIndex &&
|
a.outcomeIndex == trigger.outcomeIndex &&
|
||||||
a.side?.uppercase() == "BUY" &&
|
a.side?.uppercase() == "BUY" &&
|
||||||
a.price != null && a.price!! > 0 &&
|
a.price != null && a.price > 0 &&
|
||||||
a.size != null && a.size!! > 0
|
a.size != null && a.size > 0
|
||||||
} ?: run {
|
} ?: run {
|
||||||
logger.debug("尾盘结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}")
|
logger.debug("加密价差策略结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val price = match.price!!.toSafeBigDecimal()
|
val price = match.price!!.toSafeBigDecimal()
|
||||||
@@ -241,11 +243,11 @@ class CryptoTailSettlementService(
|
|||||||
if (price.gt(BigDecimal.ZERO) && size.gt(BigDecimal.ZERO)) {
|
if (price.gt(BigDecimal.ZERO) && size.gt(BigDecimal.ZERO)) {
|
||||||
ActivityFill(price = price, size = size, usdcSize = usdcSize)
|
ActivityFill(price = price, size = size, usdcSize = usdcSize)
|
||||||
} else {
|
} else {
|
||||||
logger.debug("尾盘结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size")
|
logger.debug("加密价差策略结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("尾盘结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}")
|
logger.warn("加密价差策略结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,4 +275,11 @@ class CryptoTailSettlementService(
|
|||||||
amountUsdc.negate()
|
amountUsdc.negate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
settlementJob?.cancel()
|
||||||
|
settlementJob = null
|
||||||
|
settlementScopeJob.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+243
-49
@@ -28,12 +28,13 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
/** 加密价差策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
||||||
private const val TRIGGER_FIXED_PRICE = "0.99"
|
private const val TRIGGER_FIXED_PRICE = "0.99"
|
||||||
|
|
||||||
/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */
|
/** 最大价差模式(MAX)时,买入价格调整系数(加在触发价格上) */
|
||||||
@@ -42,6 +43,9 @@ private const val SPREAD_MAX_PRICE_ADJUSTMENT = "0.02"
|
|||||||
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
||||||
private const val SIZE_DECIMAL_SCALE = 2
|
private const val SIZE_DECIMAL_SCALE = 2
|
||||||
|
|
||||||
|
/** 单笔下单最小 USDC 金额(平台限制),RATIO 模式计算值低于此值时按此值下单 */
|
||||||
|
private val MIN_ORDER_USDC = BigDecimal("1")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 周期内预置上下文:账户、解密凭证、费率、签名类型、CLOB 客户端;不含预签订单。
|
* 周期内预置上下文:账户、解密凭证、费率、签名类型、CLOB 客户端;不含预签订单。
|
||||||
* 触发时 FIXED/RATIO 均按 outcomeIndex 计算 size 并签名提交。
|
* 触发时 FIXED/RATIO 均按 outcomeIndex 计算 size 并签名提交。
|
||||||
@@ -61,7 +65,7 @@ private data class PeriodContext(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。
|
* 加密价差策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。
|
||||||
* 周期开始预置账户、解密、费率、签名类型、CLOB 客户端;触发时按 outcomeIndex 计算 size 并签名提交。
|
* 周期开始预置账户、解密、费率、签名类型、CLOB 客户端;触发时按 outcomeIndex 计算 size 并签名提交。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -83,10 +87,25 @@ class CryptoTailStrategyExecutionService(
|
|||||||
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
|
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
|
||||||
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
|
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||||
|
|
||||||
|
/** 过期锁 key 保留时间(秒),超过则清理,防止 map 无界增长 */
|
||||||
|
private val triggerMutexExpireSeconds = 3600L
|
||||||
|
|
||||||
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
|
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
|
||||||
|
|
||||||
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex =
|
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex {
|
||||||
triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
|
cleanExpiredTriggerMutexKeys()
|
||||||
|
return triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理已过期的 (strategyId, periodStartUnix) 锁,避免内存泄漏 */
|
||||||
|
private fun cleanExpiredTriggerMutexKeys() {
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val expireThreshold = nowSeconds - triggerMutexExpireSeconds
|
||||||
|
val keysToRemove = triggerMutexMap.keys.filter { key ->
|
||||||
|
key.substringAfterLast('-').toLongOrNull()?.let { it < expireThreshold } ?: false
|
||||||
|
}
|
||||||
|
keysToRemove.forEach { triggerMutexMap.remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/** 周期预置上下文缓存:(strategyId-periodStartUnix) -> PeriodContext,过期周期在读取时剔除 */
|
/** 周期预置上下文缓存:(strategyId-periodStartUnix) -> PeriodContext,过期周期在读取时剔除 */
|
||||||
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
|
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
|
||||||
@@ -115,23 +134,27 @@ class CryptoTailStrategyExecutionService(
|
|||||||
val decryptedKey = try {
|
val decryptedKey = try {
|
||||||
cryptoUtils.decrypt(account.privateKey) ?: return null
|
cryptoUtils.decrypt(account.privateKey) ?: return null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("尾盘策略周期上下文解密私钥失败: accountId=${account.id}", e)
|
logger.warn("加密价差策略周期上下文解密私钥失败: accountId=${account.id}", e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val apiSecret = try {
|
val apiSecret = try {
|
||||||
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: ""
|
account.apiSecret.let { cryptoUtils.decrypt(it) }
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
val apiPassphrase = try {
|
val apiPassphrase = try {
|
||||||
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: ""
|
account.apiPassphrase.let { cryptoUtils.decrypt(it) }
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||||
val feeRateByTokenId = tokenIds.associate { tokenId ->
|
val feeRateByTokenId = tokenIds.associate { tokenId ->
|
||||||
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
|
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
|
||||||
}
|
}
|
||||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||||
|
|
||||||
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < BigDecimal("1")) return null
|
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < MIN_ORDER_USDC) return null
|
||||||
|
|
||||||
val ctx = PeriodContext(
|
val ctx = PeriodContext(
|
||||||
strategy = strategy,
|
strategy = strategy,
|
||||||
@@ -165,11 +188,20 @@ class CryptoTailStrategyExecutionService(
|
|||||||
val ctx = periodContextCache[key] ?: return null
|
val ctx = periodContextCache[key] ?: return null
|
||||||
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
|
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
|
||||||
periodContextCache.remove(key)
|
periodContextCache.remove(key)
|
||||||
|
cleanExpiredPeriodContextCache(nowSeconds)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清理已过期的周期上下文缓存,避免内存泄漏 */
|
||||||
|
private fun cleanExpiredPeriodContextCache(nowSeconds: Long) {
|
||||||
|
val keysToRemove = periodContextCache.entries
|
||||||
|
.filter { (_, ctx) -> ctx.periodStartUnix + ctx.strategy.intervalSeconds <= nowSeconds }
|
||||||
|
.map { it.key }
|
||||||
|
keysToRemove.forEach { periodContextCache.remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 由订单簿 WebSocket 触发:当收到某 token 的 bestBid 且满足区间时调用,若本周期未触发则下单。
|
* 由订单簿 WebSocket 触发:当收到某 token 的 bestBid 且满足区间时调用,若本周期未触发则下单。
|
||||||
*/
|
*/
|
||||||
@@ -186,20 +218,28 @@ class CryptoTailStrategyExecutionService(
|
|||||||
|
|
||||||
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
|
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(strategy.id!!, periodStartUnix) != null) return@withLock
|
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(
|
||||||
|
strategy.id!!,
|
||||||
|
periodStartUnix
|
||||||
|
) != null
|
||||||
|
) return@withLock
|
||||||
val logKey = triggerLockKey(strategy.id!!, periodStartUnix)
|
val logKey = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||||
if (conditionLoggedCache.getIfPresent(logKey) == null) {
|
if (conditionLoggedCache.getIfPresent(logKey) == null) {
|
||||||
conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds)
|
conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds)
|
||||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
|
val oc = binanceKlineService.getCurrentOpenClose(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
val openPrice = oc?.first?.toPlainString() ?: "-"
|
val openPrice = oc?.first?.toPlainString() ?: "-"
|
||||||
val closePrice = oc?.second?.toPlainString() ?: "-"
|
val closePrice = oc?.second?.toPlainString() ?: "-"
|
||||||
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}"
|
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "加密价差策略-${strategy.marketSlugPrefix}"
|
||||||
val direction = if (outcomeIndex == 0) "Up" else "Down"
|
val direction = if (outcomeIndex == 0) "Up" else "Down"
|
||||||
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
|
val modeStr = if (strategy.spreadDirection == SpreadDirection.MAX) "最大价差" else "最小价差"
|
||||||
logger.info(
|
logger.info(
|
||||||
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
|
"加密价差策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
|
||||||
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
|
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
|
||||||
"direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr"
|
"direction=$direction, outcomeIndex=$outcomeIndex, spreadMode=$modeStr"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
if (!passSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
||||||
@@ -210,23 +250,29 @@ class CryptoTailStrategyExecutionService(
|
|||||||
|
|
||||||
private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
private fun passSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
||||||
if (strategy.spreadMode == SpreadMode.NONE) return true
|
if (strategy.spreadMode == SpreadMode.NONE) return true
|
||||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
|
val oc = binanceKlineService.getCurrentOpenClose(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
?: return false
|
?: return false
|
||||||
val (openP, closeP) = oc
|
val (openP, closeP) = oc
|
||||||
val spreadAbs = closeP.subtract(openP).abs()
|
val spreadAbs = closeP.subtract(openP).abs()
|
||||||
|
|
||||||
// 获取有效价差
|
// 获取有效价差
|
||||||
val effectiveSpread = when (strategy.spreadMode) {
|
val effectiveSpread = when (strategy.spreadMode) {
|
||||||
SpreadMode.FIXED -> {
|
SpreadMode.FIXED -> {
|
||||||
strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
|
strategy.spreadValue?.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||||
}
|
}
|
||||||
|
|
||||||
SpreadMode.AUTO -> {
|
SpreadMode.AUTO -> {
|
||||||
val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
val result = computeAutoEffectiveSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
||||||
result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
|
result.effectiveSpread.takeIf { it > BigDecimal.ZERO } ?: return true
|
||||||
}
|
}
|
||||||
|
|
||||||
SpreadMode.NONE -> return true
|
SpreadMode.NONE -> return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据价差方向判断
|
// 根据价差方向判断
|
||||||
return if (strategy.spreadDirection == SpreadDirection.MAX) {
|
return if (strategy.spreadDirection == SpreadDirection.MAX) {
|
||||||
// 最大价差模式:价差 <= 配置值时触发
|
// 最大价差模式:价差 <= 配置值时触发
|
||||||
@@ -246,9 +292,22 @@ class CryptoTailStrategyExecutionService(
|
|||||||
val effectiveSpread: BigDecimal
|
val effectiveSpread: BigDecimal
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun computeAutoEffectiveSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? {
|
private fun computeAutoEffectiveSpread(
|
||||||
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.intervalSeconds, periodStartUnix, outcomeIndex)
|
strategy: CryptoTailStrategy,
|
||||||
?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second }
|
periodStartUnix: Long,
|
||||||
|
outcomeIndex: Int
|
||||||
|
): AutoSpreadResult? {
|
||||||
|
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix,
|
||||||
|
outcomeIndex
|
||||||
|
)
|
||||||
|
?: binanceKlineAutoSpreadService.computeAndCache(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)?.let { if (outcomeIndex == 0) it.first else it.second }
|
||||||
?: return null
|
?: return null
|
||||||
if (baseSpread <= BigDecimal.ZERO) return null
|
if (baseSpread <= BigDecimal.ZERO) return null
|
||||||
val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L
|
val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L
|
||||||
@@ -278,21 +337,50 @@ class CryptoTailStrategyExecutionService(
|
|||||||
val ctx = getOrInvalidatePeriodContext(strategy, periodStartUnix)
|
val ctx = getOrInvalidatePeriodContext(strategy, periodStartUnix)
|
||||||
|
|
||||||
if (ctx != null) {
|
if (ctx != null) {
|
||||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
var availableBalanceForRatio = BigDecimal.ZERO
|
||||||
|
var amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
val balanceResult = accountService.getAccountBalance(ctx.account.id)
|
val balanceResult = accountService.getAccountBalance(ctx.account.id)
|
||||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
val availableBalance =
|
||||||
|
balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||||
|
availableBalanceForRatio = availableBalance
|
||||||
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> strategy.amountValue
|
else -> strategy.amountValue
|
||||||
}
|
}
|
||||||
if (amountUsdc < BigDecimal("1")) {
|
if (amountUsdc < MIN_ORDER_USDC) {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足")
|
val amountMode = strategy.amountMode.uppercase()
|
||||||
return
|
if (amountMode == "RATIO" && availableBalanceForRatio >= MIN_ORDER_USDC) {
|
||||||
|
amountUsdc = MIN_ORDER_USDC
|
||||||
|
} else {
|
||||||
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"投入金额不足"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"tokenIds 越界"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +413,16 @@ class CryptoTailStrategyExecutionService(
|
|||||||
orderType = "FAK",
|
orderType = "FAK",
|
||||||
deferExec = false
|
deferExec = false
|
||||||
)
|
)
|
||||||
submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
submitOrderAndSaveRecord(
|
||||||
|
ctx.clobApi,
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
orderRequest
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,8 +445,18 @@ class CryptoTailStrategyExecutionService(
|
|||||||
if (response.isSuccessful && response.body() != null) {
|
if (response.isSuccessful && response.body() != null) {
|
||||||
val body = response.body()!!
|
val body = response.body()!!
|
||||||
if (body.success && body.orderId != null) {
|
if (body.success && body.orderId != null) {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, body.orderId, "success", null)
|
saveTriggerRecord(
|
||||||
logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
body.orderId,
|
||||||
|
"success",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
failReason = body.errorMsg ?: "unknown"
|
failReason = body.errorMsg ?: "unknown"
|
||||||
@@ -359,10 +466,20 @@ class CryptoTailStrategyExecutionService(
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
failReason = e.message ?: e.toString()
|
failReason = e.message ?: e.toString()
|
||||||
logger.error("尾盘策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
|
logger.error("加密价差策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
|
||||||
}
|
}
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", failReason)
|
saveTriggerRecord(
|
||||||
logger.error("尾盘策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
failReason
|
||||||
|
)
|
||||||
|
logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */
|
/** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */
|
||||||
@@ -376,28 +493,73 @@ class CryptoTailStrategyExecutionService(
|
|||||||
) {
|
) {
|
||||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||||
logger.warn("账户不存在: accountId=${strategy.accountId}")
|
logger.warn("账户不存在: accountId=${strategy.accountId}")
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户不存在")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"账户不存在"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||||
logger.warn("账户未配置 API 凭证: accountId=${account.id}")
|
logger.warn("账户未配置 API 凭证: accountId=${account.id}")
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户未配置API凭证")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"账户未配置API凭证"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val balanceResult = accountService.getAccountBalance(account.id)
|
val balanceResult = accountService.getAccountBalance(account.id)
|
||||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
var amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||||
"RATIO" -> availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
"RATIO" -> availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||||
else -> strategy.amountValue
|
else -> strategy.amountValue
|
||||||
}
|
}
|
||||||
if (amountUsdc < BigDecimal("1")) {
|
if (amountUsdc < MIN_ORDER_USDC) {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足")
|
val amountMode = strategy.amountMode.uppercase()
|
||||||
return
|
if (amountMode == "RATIO" && availableBalance >= MIN_ORDER_USDC) {
|
||||||
|
amountUsdc = MIN_ORDER_USDC
|
||||||
|
} else {
|
||||||
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"投入金额不足"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"tokenIds 越界"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,16 +578,30 @@ class CryptoTailStrategyExecutionService(
|
|||||||
cryptoUtils.decrypt(account.privateKey) ?: ""
|
cryptoUtils.decrypt(account.privateKey) ?: ""
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("解密私钥失败: accountId=${account.id}", e)
|
logger.error("解密私钥失败: accountId=${account.id}", e)
|
||||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "解密私钥失败")
|
saveTriggerRecord(
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
null,
|
||||||
|
"fail",
|
||||||
|
"解密私钥失败"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val apiSecret = try {
|
val apiSecret = try {
|
||||||
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: ""
|
account.apiSecret.let { cryptoUtils.decrypt(it) }
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
val apiPassphrase = try {
|
val apiPassphrase = try {
|
||||||
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: ""
|
account.apiPassphrase.let { cryptoUtils.decrypt(it) }
|
||||||
} catch (e: Exception) { "" }
|
} catch (e: Exception) {
|
||||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
""
|
||||||
|
}
|
||||||
|
val clobApi = retrofitFactory.createClobApi(account.apiKey, apiSecret, apiPassphrase, account.walletAddress)
|
||||||
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
||||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||||
|
|
||||||
@@ -447,7 +623,16 @@ class CryptoTailStrategyExecutionService(
|
|||||||
orderType = "FAK",
|
orderType = "FAK",
|
||||||
deferExec = false
|
deferExec = false
|
||||||
)
|
)
|
||||||
submitOrderAndSaveRecord(clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
submitOrderAndSaveRecord(
|
||||||
|
clobApi,
|
||||||
|
strategy,
|
||||||
|
periodStartUnix,
|
||||||
|
marketTitle,
|
||||||
|
outcomeIndex,
|
||||||
|
triggerPrice,
|
||||||
|
amountUsdc,
|
||||||
|
orderRequest
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||||
@@ -495,4 +680,13 @@ class CryptoTailStrategyExecutionService(
|
|||||||
)
|
)
|
||||||
triggerRepository.save(record)
|
triggerRepository.save(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
// 清理所有周期上下文缓存,避免敏感信息(明文私钥、API Secret)在内存中保留
|
||||||
|
periodContextCache.clear()
|
||||||
|
// 清理所有锁,避免内存泄漏
|
||||||
|
triggerMutexMap.clear()
|
||||||
|
logger.debug("加密价差策略执行服务已清理缓存和锁")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -104,7 +104,7 @@ class CryptoTailStrategyService(
|
|||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("创建尾盘策略失败: ${e.message}", e)
|
logger.error("创建加密价差策略失败: ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ class CryptoTailStrategyService(
|
|||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("更新尾盘策略失败: ${e.message}", e)
|
logger.error("更新加密价差策略失败: ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ class CryptoTailStrategyService(
|
|||||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("删除尾盘策略失败: ${e.message}", e)
|
logger.error("删除加密价差策略失败: ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +215,7 @@ class CryptoTailStrategyService(
|
|||||||
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
|
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
|
||||||
Result.success(CryptoTailStrategyListResponse(list = dtos))
|
Result.success(CryptoTailStrategyListResponse(list = dtos))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("查询尾盘策略列表失败: ${e.message}", e)
|
logger.error("查询加密价差策略列表失败: ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +263,7 @@ class CryptoTailStrategyService(
|
|||||||
private fun generateStrategyName(marketSlugPrefix: String): String {
|
private fun generateStrategyName(marketSlugPrefix: String): String {
|
||||||
val suffix = Instant.now().atZone(ZoneId.systemDefault())
|
val suffix = Instant.now().atZone(ZoneId.systemDefault())
|
||||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||||
return "尾盘策略-${marketSlugPrefix}-$suffix"
|
return "加密价差策略-${marketSlugPrefix}-$suffix"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto {
|
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto {
|
||||||
|
|||||||
+8
-1
@@ -243,7 +243,14 @@ class ApiHealthCheckService(
|
|||||||
name = "币安 WebSocket",
|
name = "币安 WebSocket",
|
||||||
url = binanceWsUrl,
|
url = binanceWsUrl,
|
||||||
status = "success",
|
status = "success",
|
||||||
message = "连接正常 (5m、15m)"
|
message = "连接正常 (按策略订阅)"
|
||||||
|
)
|
||||||
|
} else if (total == 0) {
|
||||||
|
ApiHealthCheckDto(
|
||||||
|
name = "币安 WebSocket",
|
||||||
|
url = binanceWsUrl,
|
||||||
|
status = "success",
|
||||||
|
message = "无加密价差策略,未订阅"
|
||||||
)
|
)
|
||||||
} else if (connected > 0) {
|
} else if (connected > 0) {
|
||||||
val which = statuses.filter { it.value }.keys.joinToString("、")
|
val which = statuses.filter { it.value }.keys.joinToString("、")
|
||||||
|
|||||||
+4
-4
@@ -304,7 +304,7 @@ class TelegramNotificationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送尾盘策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配尾盘订单后调用)
|
* 发送加密价差策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配价差策略订单后调用)
|
||||||
*/
|
*/
|
||||||
suspend fun sendCryptoTailOrderSuccessNotification(
|
suspend fun sendCryptoTailOrderSuccessNotification(
|
||||||
orderId: String?,
|
orderId: String?,
|
||||||
@@ -324,7 +324,7 @@ class TelegramNotificationService(
|
|||||||
if (orderId != null) {
|
if (orderId != null) {
|
||||||
val lastSentTime = sentOrderIds[orderId]
|
val lastSentTime = sentOrderIds[orderId]
|
||||||
if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
|
if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
|
||||||
logger.info("尾盘订单通知已发送过(5分钟内),跳过: orderId=$orderId")
|
logger.info("加密价差策略订单通知已发送过(5分钟内),跳过: orderId=$orderId")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sentOrderIds[orderId] = System.currentTimeMillis()
|
sentOrderIds[orderId] = System.currentTimeMillis()
|
||||||
@@ -894,7 +894,7 @@ class TelegramNotificationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建尾盘策略下单成功消息(与订单成功格式一致,增加「尾盘策略」标题与策略名)
|
* 构建加密价差策略下单成功消息(与订单成功格式一致,增加「加密价差策略」标题与策略名)
|
||||||
*/
|
*/
|
||||||
private fun buildCryptoTailOrderSuccessMessage(
|
private fun buildCryptoTailOrderSuccessMessage(
|
||||||
orderId: String?,
|
orderId: String?,
|
||||||
@@ -912,7 +912,7 @@ class TelegramNotificationService(
|
|||||||
locale: java.util.Locale,
|
locale: java.util.Locale,
|
||||||
orderTime: Long?
|
orderTime: Long?
|
||||||
): String {
|
): String {
|
||||||
val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "尾盘策略下单成功", locale)
|
val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "加密价差策略下单成功", locale)
|
||||||
val strategyLabel = messageSource.getMessage("notification.tail.strategy", null, "策略", locale)
|
val strategyLabel = messageSource.getMessage("notification.tail.strategy", null, "策略", locale)
|
||||||
val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale)
|
val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale)
|
||||||
val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale)
|
val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale)
|
||||||
|
|||||||
+1
-2
@@ -193,7 +193,7 @@ class UnifiedWebSocketHandler(
|
|||||||
lastActivityTime.remove(sessionId)
|
lastActivityTime.remove(sessionId)
|
||||||
sessionLocks.remove(sessionId) // 清理同步锁
|
sessionLocks.remove(sessionId) // 清理同步锁
|
||||||
subscriptionService.unregisterSession(sessionId)
|
subscriptionService.unregisterSession(sessionId)
|
||||||
|
|
||||||
if (session != null && session.isOpen) {
|
if (session != null && session.isOpen) {
|
||||||
try {
|
try {
|
||||||
session.close(CloseStatus.NORMAL)
|
session.close(CloseStatus.NORMAL)
|
||||||
@@ -201,7 +201,6 @@ class UnifiedWebSocketHandler(
|
|||||||
// 忽略关闭时的异常
|
// 忽略关闭时的异常
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("清理 WebSocket 资源时发生错误: $sessionId, ${e.message}", e)
|
logger.error("清理 WebSocket 资源时发生错误: $sessionId, ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ notification.order.time=Time
|
|||||||
notification.order.error_info=Error Information
|
notification.order.error_info=Error Information
|
||||||
notification.order.unknown_account=Unknown Account
|
notification.order.unknown_account=Unknown Account
|
||||||
notification.order.calculate_failed=Calculation Failed
|
notification.order.calculate_failed=Calculation Failed
|
||||||
notification.tail.order.success=Tail Session Order Success
|
notification.tail.order.success=Crypto spread strategy order success
|
||||||
notification.tail.strategy=Strategy
|
notification.tail.strategy=Strategy
|
||||||
notification.redeem.success=Position Redeemed Successfully
|
notification.redeem.success=Position Redeemed Successfully
|
||||||
notification.redeem.info=Redeem Information
|
notification.redeem.info=Redeem Information
|
||||||
@@ -277,16 +277,16 @@ error.server.backtest_stop_failed=Failed to stop backtest task
|
|||||||
error.server.backtest_retry_failed=Failed to retry backtest task
|
error.server.backtest_retry_failed=Failed to retry backtest task
|
||||||
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
|
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
|
||||||
|
|
||||||
# Crypto tail strategy
|
# Crypto spread strategy
|
||||||
error.crypto_tail_strategy_not_found=Crypto tail strategy not found
|
error.crypto_tail_strategy_not_found=Crypto spread strategy not found
|
||||||
error.crypto_tail_strategy_window_invalid=Window start must not be greater than window end
|
error.crypto_tail_strategy_window_invalid=Window start must not be greater than window end
|
||||||
error.crypto_tail_strategy_window_exceed=Time window must not exceed period length
|
error.crypto_tail_strategy_window_exceed=Time window must not exceed period length
|
||||||
error.crypto_tail_strategy_interval_invalid=Interval must be 300 or 900 seconds
|
error.crypto_tail_strategy_interval_invalid=Interval must be 300 or 900 seconds
|
||||||
error.crypto_tail_strategy_amount_mode_invalid=Amount mode must be RATIO or FIXED
|
error.crypto_tail_strategy_amount_mode_invalid=Amount mode must be RATIO or FIXED
|
||||||
error.server.crypto_tail_strategy_create_failed=Failed to create crypto tail strategy
|
error.server.crypto_tail_strategy_create_failed=Failed to create crypto spread strategy
|
||||||
error.server.crypto_tail_strategy_update_failed=Failed to update crypto tail strategy
|
error.server.crypto_tail_strategy_update_failed=Failed to update crypto spread strategy
|
||||||
error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto tail strategy
|
error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto spread strategy
|
||||||
error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto tail strategy list
|
error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto spread strategy list
|
||||||
error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
|
error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
|
||||||
# Backtest Management
|
# Backtest Management
|
||||||
backtest.title=Backtest Management
|
backtest.title=Backtest Management
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ notification.order.time=时间
|
|||||||
notification.order.error_info=错误信息
|
notification.order.error_info=错误信息
|
||||||
notification.order.unknown_account=未知账户
|
notification.order.unknown_account=未知账户
|
||||||
notification.order.calculate_failed=计算失败
|
notification.order.calculate_failed=计算失败
|
||||||
notification.tail.order.success=尾盘策略下单成功
|
notification.tail.order.success=加密价差策略下单成功
|
||||||
notification.tail.strategy=策略
|
notification.tail.strategy=策略
|
||||||
notification.redeem.success=仓位赎回成功
|
notification.redeem.success=仓位赎回成功
|
||||||
notification.redeem.info=赎回信息
|
notification.redeem.info=赎回信息
|
||||||
@@ -277,16 +277,16 @@ error.server.backtest_stop_failed=停止回测任务失败
|
|||||||
error.server.backtest_retry_failed=重试回测任务失败
|
error.server.backtest_retry_failed=重试回测任务失败
|
||||||
error.server.backtest_rerun_failed=按配置重新测试失败
|
error.server.backtest_rerun_failed=按配置重新测试失败
|
||||||
|
|
||||||
# 尾盘策略
|
# 加密价差策略
|
||||||
error.crypto_tail_strategy_not_found=尾盘策略不存在
|
error.crypto_tail_strategy_not_found=加密价差策略不存在
|
||||||
error.crypto_tail_strategy_window_invalid=时间区间开始不能大于结束
|
error.crypto_tail_strategy_window_invalid=时间区间开始不能大于结束
|
||||||
error.crypto_tail_strategy_window_exceed=时间区间不能超过周期长度
|
error.crypto_tail_strategy_window_exceed=时间区间不能超过周期长度
|
||||||
error.crypto_tail_strategy_interval_invalid=周期仅支持 300 或 900 秒
|
error.crypto_tail_strategy_interval_invalid=周期仅支持 300 或 900 秒
|
||||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式仅支持 RATIO 或 FIXED
|
error.crypto_tail_strategy_amount_mode_invalid=投入方式仅支持 RATIO 或 FIXED
|
||||||
error.server.crypto_tail_strategy_create_failed=创建尾盘策略失败
|
error.server.crypto_tail_strategy_create_failed=创建加密价差策略失败
|
||||||
error.server.crypto_tail_strategy_update_failed=更新尾盘策略失败
|
error.server.crypto_tail_strategy_update_failed=更新加密价差策略失败
|
||||||
error.server.crypto_tail_strategy_delete_failed=删除尾盘策略失败
|
error.server.crypto_tail_strategy_delete_failed=删除加密价差策略失败
|
||||||
error.server.crypto_tail_strategy_list_fetch_failed=查询尾盘策略列表失败
|
error.server.crypto_tail_strategy_list_fetch_failed=查询加密价差策略列表失败
|
||||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败
|
error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败
|
||||||
# 回测管理
|
# 回测管理
|
||||||
backtest.title=回测管理
|
backtest.title=回测管理
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ notification.order.time=時間
|
|||||||
notification.order.error_info=錯誤信息
|
notification.order.error_info=錯誤信息
|
||||||
notification.order.unknown_account=未知賬戶
|
notification.order.unknown_account=未知賬戶
|
||||||
notification.order.calculate_failed=計算失敗
|
notification.order.calculate_failed=計算失敗
|
||||||
notification.tail.order.success=尾盤策略下單成功
|
notification.tail.order.success=加密價差策略下單成功
|
||||||
notification.tail.strategy=策略
|
notification.tail.strategy=策略
|
||||||
notification.redeem.success=倉位贖回成功
|
notification.redeem.success=倉位贖回成功
|
||||||
notification.redeem.info=贖回信息
|
notification.redeem.info=贖回信息
|
||||||
@@ -277,16 +277,16 @@ error.server.backtest_stop_failed=停止回測任務失敗
|
|||||||
error.server.backtest_retry_failed=重試回測任務失敗
|
error.server.backtest_retry_failed=重試回測任務失敗
|
||||||
error.server.backtest_rerun_failed=依配置重新測試失敗
|
error.server.backtest_rerun_failed=依配置重新測試失敗
|
||||||
|
|
||||||
# 尾盤策略
|
# 加密價差策略
|
||||||
error.crypto_tail_strategy_not_found=尾盤策略不存在
|
error.crypto_tail_strategy_not_found=加密價差策略不存在
|
||||||
error.crypto_tail_strategy_window_invalid=時間區間開始不能大於結束
|
error.crypto_tail_strategy_window_invalid=時間區間開始不能大於結束
|
||||||
error.crypto_tail_strategy_window_exceed=時間區間不能超過週期長度
|
error.crypto_tail_strategy_window_exceed=時間區間不能超過週期長度
|
||||||
error.crypto_tail_strategy_interval_invalid=週期僅支援 300 或 900 秒
|
error.crypto_tail_strategy_interval_invalid=週期僅支援 300 或 900 秒
|
||||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式僅支援 RATIO 或 FIXED
|
error.crypto_tail_strategy_amount_mode_invalid=投入方式僅支援 RATIO 或 FIXED
|
||||||
error.server.crypto_tail_strategy_create_failed=創建尾盤策略失敗
|
error.server.crypto_tail_strategy_create_failed=創建加密價差策略失敗
|
||||||
error.server.crypto_tail_strategy_update_failed=更新尾盤策略失敗
|
error.server.crypto_tail_strategy_update_failed=更新加密價差策略失敗
|
||||||
error.server.crypto_tail_strategy_delete_failed=刪除尾盤策略失敗
|
error.server.crypto_tail_strategy_delete_failed=刪除加密價差策略失敗
|
||||||
error.server.crypto_tail_strategy_list_fetch_failed=查詢尾盤策略列表失敗
|
error.server.crypto_tail_strategy_list_fetch_failed=查詢加密價差策略列表失敗
|
||||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
|
error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
|
||||||
# 回測管理
|
# 回測管理
|
||||||
backtest.title=回測管理
|
backtest.title=回測管理
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 尾盘策略文档 (Crypto Tail Strategy)
|
# 加密价差策略文档 (Crypto Spread Strategy)
|
||||||
|
|
||||||
本目录集中存放与 Polymarket 加密市场尾盘策略相关的文档。
|
本目录集中存放与 Polymarket 加密市场加密价差策略相关的文档。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ effectiveMinSpread = baseSpread × coefficient
|
|||||||
|
|
||||||
- 需要策略的 `windowStartSeconds`、`windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。
|
- 需要策略的 `windowStartSeconds`、`windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。
|
||||||
|
|
||||||
**优点**:与「尾盘只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。
|
**优点**:与「加密价差策略只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。
|
||||||
**缺点**:`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。
|
**缺点**:`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Crypto Tail Strategy Configuration Guide
|
# Crypto Spread Strategy Configuration Guide
|
||||||
|
|
||||||
## Part 1: What is Crypto Tail Strategy?
|
## Part 1: What is Crypto Spread Strategy?
|
||||||
|
|
||||||
Crypto Tail Strategy is an automated trading strategy designed specifically for Polymarket crypto markets' **5-minute** or **15-minute** "Up or Down" markets.
|
Crypto Spread Strategy is an automated trading strategy designed specifically for Polymarket crypto markets' **5-minute** or **15-minute** "Up or Down" markets.
|
||||||
|
|
||||||
**Core Logic**: Within a specified time window, when the market price enters your set price range, the system will automatically buy at a fixed price (0.99) without manual operation.
|
**Core Logic**: Within a specified time window, when the market price enters your set price range, the system will automatically buy at a fixed price (0.99) without manual operation.
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ Cycle Start → Within Time Window → Price Enters Range → Auto Order
|
|||||||
|
|
||||||
- **Maximum one trigger per cycle**: Within the same cycle, even if conditions are met multiple times, only one order is placed
|
- **Maximum one trigger per cycle**: Within the same cycle, even if conditions are met multiple times, only one order is placed
|
||||||
- **Fixed order price**: All orders are submitted at price 0.99
|
- **Fixed order price**: All orders are submitted at price 0.99
|
||||||
- **Requires separate wallet**: It's recommended to use a dedicated wallet for tail strategies to avoid conflicts with other operations (manual trading, copy trading, etc.)
|
- **Requires separate wallet**: It's recommended to use a dedicated wallet for crypto spread strategies to avoid conflicts with other operations (manual trading, copy trading, etc.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ Cycle Start → Within Time Window → Price Enters Range → Auto Order
|
|||||||
| Parameter | Description | Required | Example |
|
| Parameter | Description | Required | Example |
|
||||||
|-----------|-------------|----------|---------|
|
|-----------|-------------|----------|---------|
|
||||||
| **Account** | Select the wallet account for trading | ✅ | Account A |
|
| **Account** | Select the wallet account for trading | ✅ | Account A |
|
||||||
| **Strategy Name** | Name your strategy for easy identification | ❌ | "BTC 15min Tail Strategy" |
|
| **Strategy Name** | Name your strategy for easy identification | ❌ | "BTC 15min Crypto Spread Strategy" |
|
||||||
| **Market** | Select the market to trade (5-minute or 15-minute) | ✅ | btc-updown-15m |
|
| **Market** | Select the market to trade (5-minute or 15-minute) | ✅ | btc-updown-15m |
|
||||||
|
|
||||||
### 3.2 Cycle Settings
|
### 3.2 Cycle Settings
|
||||||
@@ -341,7 +341,7 @@ Enabled: On
|
|||||||
2. **Position conflicts**: Manual trading and strategy trading may conflict
|
2. **Position conflicts**: Manual trading and strategy trading may conflict
|
||||||
3. **Management confusion**: Difficult to distinguish which orders are from strategy vs manual
|
3. **Management confusion**: Difficult to distinguish which orders are from strategy vs manual
|
||||||
|
|
||||||
**Recommendation**: Create a dedicated wallet, only for tail strategies.
|
**Recommendation**: Create a dedicated wallet, only for crypto spread strategies.
|
||||||
|
|
||||||
### Q7: Why is the order price fixed at 0.99?
|
### Q7: Why is the order price fixed at 0.99?
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ Enabled: On
|
|||||||
|
|
||||||
### Q8: Does the strategy depend on auto-redeem functionality?
|
### Q8: Does the strategy depend on auto-redeem functionality?
|
||||||
|
|
||||||
**A**: Yes, tail strategy depends on auto-redeem functionality.
|
**A**: Yes, crypto spread strategy depends on auto-redeem functionality.
|
||||||
|
|
||||||
**Reasons**:
|
**Reasons**:
|
||||||
- Strategy orders create positions after execution
|
- Strategy orders create positions after execution
|
||||||
@@ -411,7 +411,7 @@ Enabled: On
|
|||||||
|
|
||||||
### 7.1 View Strategy List
|
### 7.1 View Strategy List
|
||||||
|
|
||||||
On the "Crypto Tail Strategy" page, you can view all strategies:
|
On the "Crypto Spread Strategy" page, you can view all strategies:
|
||||||
- Strategy name
|
- Strategy name
|
||||||
- Market information
|
- Market information
|
||||||
- Time window
|
- Time window
|
||||||
@@ -453,7 +453,7 @@ After deleting a strategy:
|
|||||||
|
|
||||||
## Part 8: Summary
|
## Part 8: Summary
|
||||||
|
|
||||||
Crypto Tail Strategy is a powerful automated trading tool that can help you:
|
Crypto Spread Strategy is a powerful automated trading tool that can help you:
|
||||||
|
|
||||||
1. **Automated Trading**: No need for manual monitoring, system executes automatically
|
1. **Automated Trading**: No need for manual monitoring, system executes automatically
|
||||||
2. **Precise Control**: Precisely control trigger conditions through time windows and price ranges
|
2. **Precise Control**: Precisely control trigger conditions through time windows and price ranges
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 加密市场尾盘策略 - 流程图
|
# 加密价差策略 - 流程图
|
||||||
|
|
||||||
## 一、整体架构
|
## 一、整体架构
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 加密市场尾盘策略 - 5/15 分钟市场数据获取说明
|
# 加密价差策略 - 5/15 分钟市场数据获取说明
|
||||||
|
|
||||||
> 前端 UI 与交互详见 `crypto-tail-strategy-ui-spec.md`。
|
> 前端 UI 与交互详见 `crypto-tail-strategy-ui-spec.md`。
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# 尾盘策略 - 最小价差参数流程分析
|
# 加密价差策略 - 最小价差参数流程分析
|
||||||
|
|
||||||
## 一、需求摘要
|
## 一、需求摘要
|
||||||
|
|
||||||
在现有尾盘策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。
|
在现有加密价差策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。
|
||||||
|
|
||||||
- **后端**:需订阅币安对应币对(如 BTC/USDC)的 K 线,维护当前周期的**开盘价**与**实时收盘价**,并在触发时做价差校验。
|
- **后端**:需订阅币安对应币对(如 BTC/USDC)的 K 线,维护当前周期的**开盘价**与**实时收盘价**,并在触发时做价差校验。
|
||||||
- **前端**:可配置三种场景——无、固定、自动(见下)。
|
- **前端**:可配置三种场景——无、固定、自动(见下)。
|
||||||
@@ -244,4 +244,4 @@ sequenceDiagram
|
|||||||
3. **下单与去重**
|
3. **下单与去重**
|
||||||
- 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。
|
- 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。
|
||||||
|
|
||||||
按上述流程即可在现有尾盘策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。
|
按上述流程即可在现有加密价差策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 加密市场尾盘策略 - 任务梳理
|
# 加密价差策略 - 任务梳理
|
||||||
|
|
||||||
> 需求与 UI 见 `crypto-tail-strategy-ui-spec.md`,市场数据与执行规则见 `crypto-tail-strategy-market-data.md`。
|
> 需求与 UI 见 `crypto-tail-strategy-ui-spec.md`,市场数据与执行规则见 `crypto-tail-strategy-market-data.md`。
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
| 序号 | 任务 | 说明 |
|
| 序号 | 任务 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| B16 | 自动赎回包含尾盘策略仓位 | 尾盘策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 |
|
| B16 | 自动赎回包含加密价差策略仓位 | 加密价差策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 |
|
||||||
| B17 | 调度/定时或常驻 | 对已启用策略按周期(如每 10–30 秒)检查:当前周期、是否在时间窗口内、是否已触发、价格是否进区间;满足则执行下单并写触发记录。 |
|
| B17 | 调度/定时或常驻 | 对已启用策略按周期(如每 10–30 秒)检查:当前周期、是否在时间窗口内、是否已触发、价格是否进区间;满足则执行下单并写触发记录。 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
| 序号 | 任务 | 说明 |
|
| 序号 | 任务 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| F1 | 路由 | App.tsx 增加 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id`。 |
|
| F1 | 路由 | App.tsx 增加 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id`。 |
|
||||||
| F2 | 菜单 | Layout 中增加「尾盘策略」菜单项,与跟单同级或在其下;key 与路由一致。 |
|
| F2 | 菜单 | Layout 中增加「加密价差策略」菜单项,与跟单同级或在其下;key 与路由一致。 |
|
||||||
|
|
||||||
### 4.2 列表页
|
### 4.2 列表页
|
||||||
|
|
||||||
@@ -146,5 +146,5 @@ F10 触发记录
|
|||||||
- **时间区间**:仅当周期内当前时间落在 [windowStartSeconds, windowEndSeconds] 时才判断价格并下单;前端区间开始 ≤ 结束,且不超出 5min/15min。
|
- **时间区间**:仅当周期内当前时间落在 [windowStartSeconds, windowEndSeconds] 时才判断价格并下单;前端区间开始 ≤ 结束,且不超出 5min/15min。
|
||||||
- **每周期一次**:同一策略同一周期只触发一次(先满足价格的 outcome 买入,反方向不买)。
|
- **每周期一次**:同一策略同一周期只触发一次(先满足价格的 outcome 买入,反方向不买)。
|
||||||
- **重试**:下单失败最多重试 2 次,共 3 次;仍失败记入触发记录为失败。
|
- **重试**:下单失败最多重试 2 次,共 3 次;仍失败记入触发记录为失败。
|
||||||
- **自动赎回**:尾盘策略产生的仓位可被自动赎回,无排除逻辑。
|
- **自动赎回**:加密价差策略产生的仓位可被自动赎回,无排除逻辑。
|
||||||
- **创建前检查**:未配置自动赎回时点击新增策略弹出「去配置」弹窗,不打开表单。
|
- **创建前检查**:未配置自动赎回时点击新增策略弹出「去配置」弹窗,不打开表单。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 加密市场尾盘策略 - 前端 UI 规格
|
# 加密价差策略 - 前端 UI 规格
|
||||||
|
|
||||||
> 周期推导与市场数据获取详见 `crypto-tail-strategy-market-data.md`。
|
> 周期推导与市场数据获取详见 `crypto-tail-strategy-market-data.md`。
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
| 项目 | 说明 |
|
| 项目 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **菜单** | 在「跟单管理」同级或其下增加一项,如「尾盘策略」,key 建议 `/crypto-tail-strategy`。 |
|
| **菜单** | 在「跟单管理」同级或其下增加一项,如「加密价差策略」,key 建议 `/crypto-tail-strategy`。 |
|
||||||
| **路由** | 列表页 `/crypto-tail-strategy`;可选详情/触发记录 `/crypto-tail-strategy/records/:id`。 |
|
| **路由** | 列表页 `/crypto-tail-strategy`;可选详情/触发记录 `/crypto-tail-strategy/records/:id`。 |
|
||||||
|
|
||||||
参考:`Layout.tsx` 中 `/copy-trading`、`/backtest` 的配置;`App.tsx` 中对应 `Route`。
|
参考:`Layout.tsx` 中 `/copy-trading`、`/backtest` 的配置;`App.tsx` 中对应 `Route`。
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
| 元素 | 类型 | 说明 |
|
| 元素 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 页面标题 | 标题文案 | 如「加密尾盘策略」,用 `t('cryptoTailStrategy.list.title')`。 |
|
| 页面标题 | 标题文案 | 如「加密价差策略」,用 `t('cryptoTailStrategy.list.title')`。 |
|
||||||
| **钱包使用提示** | **Alert(Warning)** | **必须**在页面顶部或标题下方展示:提示用户**使用单独/专用钱包**运行本策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,进而造成策略执行异常(如余额不足、下单失败等)。文案走多语言 `t('cryptoTailStrategy.list.walletTip')`,可带 `showIcon`。 |
|
| **钱包使用提示** | **Alert(Warning)** | **必须**在页面顶部或标题下方展示:提示用户**使用单独/专用钱包**运行本策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,进而造成策略执行异常(如余额不足、下单失败等)。文案走多语言 `t('cryptoTailStrategy.list.walletTip')`,可带 `showIcon`。 |
|
||||||
| 新增策略 | Button(Primary) | 点击时**先检查自动赎回相关配置**(见 2.4);若未配置则弹出「去配置」简易弹窗,若已配置则打开「新增策略」表单弹窗。图标可用 `PlusOutlined`。 |
|
| 新增策略 | Button(Primary) | 点击时**先检查自动赎回相关配置**(见 2.4);若未配置则弹出「去配置」简易弹窗,若已配置则打开「新增策略」表单弹窗。图标可用 `PlusOutlined`。 |
|
||||||
| 筛选(可选) | Select / 筛选项 | 按账户、启用状态筛选;移动端可收起到抽屉或折叠。 |
|
| 筛选(可选) | Select / 筛选项 | 按账户、启用状态筛选;移动端可收起到抽屉或折叠。 |
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
1. **检查**:请求系统配置(如 `apiService.systemConfig.getConfig()` 或已有接口),判断是否已配置 Builder API Key(及可选:自动赎回已开启)。若 `builderApiKeyConfigured === false`(或后端约定之「未配置」状态),视为未配置。
|
1. **检查**:请求系统配置(如 `apiService.systemConfig.getConfig()` 或已有接口),判断是否已配置 Builder API Key(及可选:自动赎回已开启)。若 `builderApiKeyConfigured === false`(或后端约定之「未配置」状态),视为未配置。
|
||||||
2. **未配置时**:不打开新增策略表单,改为弹出**简易弹窗**(Modal),内容建议:
|
2. **未配置时**:不打开新增策略表单,改为弹出**简易弹窗**(Modal),内容建议:
|
||||||
- **标题**:如「请先配置自动赎回」,`t('cryptoTailStrategy.redeemRequiredModal.title')`。
|
- **标题**:如「请先配置自动赎回」,`t('cryptoTailStrategy.redeemRequiredModal.title')`。
|
||||||
- **正文**:简短说明尾盘策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`。
|
- **正文**:简短说明加密价差策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`。
|
||||||
- **操作**:
|
- **操作**:
|
||||||
- **去配置**:主按钮,点击后关闭弹窗并跳转到系统设置页(如 `/system-settings`,该页含 Relayer 配置与自动赎回开关)。
|
- **去配置**:主按钮,点击后关闭弹窗并跳转到系统设置页(如 `/system-settings`,该页含 Relayer 配置与自动赎回开关)。
|
||||||
- **取消**:次按钮或关闭图标,仅关闭弹窗。
|
- **取消**:次按钮或关闭图标,仅关闭弹窗。
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| **钱包提示** | 列表页与新增/编辑表单**必须**包含「使用单独钱包」的 Alert 提示,避免用户用混用钱包导致异常;文案走多语言。 |
|
| **钱包提示** | 列表页与新增/编辑表单**必须**包含「使用单独钱包」的 Alert 提示,避免用户用混用钱包导致异常;文案走多语言。 |
|
||||||
| **创建前检查** | 点击「新增策略」时先检查自动赎回/Builder API 是否已配置;未配置则弹出简易「去配置」弹窗,引导用户到系统设置配置 API Key 与自动赎回,不打开策略表单。 |
|
| **创建前检查** | 点击「新增策略」时先检查自动赎回/Builder API 是否已配置;未配置则弹出简易「去配置」弹窗,引导用户到系统设置配置 API Key 与自动赎回,不打开策略表单。 |
|
||||||
| 多语言 | 所有文案 `t('cryptoTailStrategy.xxx')`,在 `locales/zh-CN`、`zh-TW`、`en` 的 `common.json` 中增加键。需包含:`cryptoTailStrategy.list.walletTip`、`cryptoTailStrategy.form.walletTip`,以及 `cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。文案示例:列表页 `walletTip`:「请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。」表单内 `walletTip`:「建议使用专用钱包,避免手动操作等导致余额或下单异常。」未配置赎回弹窗 `title`:「请先配置自动赎回」;`description`:「尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。」;`goToSettings`:「去配置」;`cancel`:「取消」。 |
|
| 多语言 | 所有文案 `t('cryptoTailStrategy.xxx')`,在 `locales/zh-CN`、`zh-TW`、`en` 的 `common.json` 中增加键。需包含:`cryptoTailStrategy.list.walletTip`、`cryptoTailStrategy.form.walletTip`,以及 `cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。文案示例:列表页 `walletTip`:「请使用单独的钱包运行加密价差策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。」表单内 `walletTip`:「建议使用专用钱包,避免手动操作等导致余额或下单异常。」未配置赎回弹窗 `title`:「请先配置自动赎回」;`description`:「加密价差策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。」;`goToSettings`:「去配置」;`cancel`:「取消」。 |
|
||||||
| 金额 | 统一 `formatUSDC`(见 frontend.mdc)。 |
|
| 金额 | 统一 `formatUSDC`(见 frontend.mdc)。 |
|
||||||
| 响应式 | `useMediaQuery`;按钮触摸目标 ≥ 44px;移动端主操作突出。 |
|
| 响应式 | `useMediaQuery`;按钮触摸目标 ≥ 44px;移动端主操作突出。 |
|
||||||
| 类型 | 不用 `any`;为策略、触发记录定义 TypeScript 类型。 |
|
| 类型 | 不用 `any`;为策略、触发记录定义 TypeScript 类型。 |
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
| 新增/编辑弹窗 | `frontend/src/pages/CryptoTailStrategyList/FormModal.tsx` 或内嵌 Modal |
|
| 新增/编辑弹窗 | `frontend/src/pages/CryptoTailStrategyList/FormModal.tsx` 或内嵌 Modal |
|
||||||
| 触发记录 | `frontend/src/pages/CryptoTailStrategyList/TriggerRecordsModal.tsx` 或 `CryptoTailStrategyRecords.tsx` |
|
| 触发记录 | `frontend/src/pages/CryptoTailStrategyList/TriggerRecordsModal.tsx` 或 `CryptoTailStrategyRecords.tsx` |
|
||||||
| 路由 | `App.tsx` 中 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id` |
|
| 路由 | `App.tsx` 中 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id` |
|
||||||
| 菜单 | `Layout.tsx` 中增加「尾盘策略」菜单项 |
|
| 菜单 | `Layout.tsx` 中增加「加密价差策略」菜单项 |
|
||||||
| 类型 | `frontend/src/types/index.ts` 或 `types/cryptoTailStrategy.ts` 中增加策略与触发记录类型 |
|
| 类型 | `frontend/src/types/index.ts` 或 `types/cryptoTailStrategy.ts` 中增加策略与触发记录类型 |
|
||||||
| 多语言 | `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 中增加 `cryptoTailStrategy.*` |
|
| 多语言 | `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 中增加 `cryptoTailStrategy.*` |
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
|
|
||||||
## 7. 小结:UI 包含的主要元素
|
## 7. 小结:UI 包含的主要元素
|
||||||
|
|
||||||
- **导航**:主导航中「尾盘策略」入口。
|
- **导航**:主导航中「加密价差策略」入口。
|
||||||
- **列表页**:标题、钱包提示 Alert、新增按钮(点击前先检查赎回配置,未配置则弹「去配置」简易弹窗)、筛选、表格/卡片(策略名、市场、价格区间、投入方式、状态、最近触发、操作)、加载与空状态。
|
- **列表页**:标题、钱包提示 Alert、新增按钮(点击前先检查赎回配置,未配置则弹「去配置」简易弹窗)、筛选、表格/卡片(策略名、市场、价格区间、投入方式、状态、最近触发、操作)、加载与空状态。
|
||||||
- **未配置赎回弹窗**:简易 Modal,提示依赖自动赎回、需先配置 Builder API Key 与自动赎回;按钮「去配置」(跳转 `/system-settings`)、「取消」。
|
- **未配置赎回弹窗**:简易 Modal,提示依赖自动赎回、需先配置 Builder API Key 与自动赎回;按钮「去配置」(跳转 `/system-settings`)、「取消」。
|
||||||
- **表单弹窗**:策略名、账户、市场选择、minPrice/maxPrice、投入方式(比例/固定)、启用开关、提交/取消。
|
- **表单弹窗**:策略名、账户、市场选择、minPrice/maxPrice、投入方式(比例/固定)、启用开关、提交/取消。
|
||||||
@@ -169,9 +169,9 @@
|
|||||||
|
|
||||||
## 附录 A 后端/产品要求:自动赎回须支持本策略仓位
|
## 附录 A 后端/产品要求:自动赎回须支持本策略仓位
|
||||||
|
|
||||||
自动赎回逻辑**必须支持赎回由尾盘策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「尾盘策略」而被排除。后端实现时需保证:
|
自动赎回逻辑**必须支持赎回由加密价差策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「加密价差策略」而被排除。后端实现时需保证:
|
||||||
|
|
||||||
- 尾盘策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回;
|
- 加密价差策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回;
|
||||||
- 若当前自动赎回按账户或仓位类型过滤,需将「尾盘策略订单产生的仓位」包含在内。
|
- 若当前自动赎回按账户或仓位类型过滤,需将「加密价差策略订单产生的仓位」包含在内。
|
||||||
|
|
||||||
这样前端所依赖的「自动赎回」对该策略才完整有效。
|
这样前端所依赖的「自动赎回」对该策略才完整有效。
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# 尾盘策略配置指南
|
# 加密价差策略配置指南
|
||||||
|
|
||||||
## 一、什么是尾盘策略?
|
## 一、什么是加密价差策略?
|
||||||
|
|
||||||
尾盘策略是一种自动化交易策略,专门用于 Polymarket 加密市场的 **5分钟** 或 **15分钟** "Up or Down" 市场。
|
加密价差策略是一种自动化交易策略,专门用于 Polymarket 加密市场的 **5分钟** 或 **15分钟** "Up or Down" 市场。
|
||||||
|
|
||||||
**核心逻辑**:在指定时间窗口内,当市场价格进入您设定的价格区间时,系统会自动以固定价格(0.99)买入,无需手动操作。
|
**核心逻辑**:在指定时间窗口内,当市场价格进入您设定的价格区间时,系统会自动以固定价格(0.99)买入,无需手动操作。
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
- **每周期最多触发一次**:同一个周期内,即使多次满足条件,也只下单一次
|
- **每周期最多触发一次**:同一个周期内,即使多次满足条件,也只下单一次
|
||||||
- **固定下单价格**:所有订单都以 0.99 的价格提交
|
- **固定下单价格**:所有订单都以 0.99 的价格提交
|
||||||
- **需要单独钱包**:建议使用专门的钱包运行尾盘策略,避免与其他操作(手动交易、跟单等)冲突
|
- **需要单独钱包**:建议使用专门的钱包运行加密价差策略,避免与其他操作(手动交易、跟单等)冲突
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
| 参数 | 说明 | 必填 | 示例 |
|
| 参数 | 说明 | 必填 | 示例 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| **账户** | 选择用于交易的钱包账户 | ✅ | 账户A |
|
| **账户** | 选择用于交易的钱包账户 | ✅ | 账户A |
|
||||||
| **策略名称** | 给策略起个名字,方便识别 | ❌ | "BTC 15分钟尾盘策略" |
|
| **策略名称** | 给策略起个名字,方便识别 | ❌ | "BTC 15分钟加密价差策略" |
|
||||||
| **市场** | 选择要交易的市场(5分钟或15分钟) | ✅ | btc-updown-15m |
|
| **市场** | 选择要交易的市场(5分钟或15分钟) | ✅ | btc-updown-15m |
|
||||||
|
|
||||||
### 3.2 周期设置
|
### 3.2 周期设置
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
2. **仓位冲突**:手动交易和策略交易可能产生冲突
|
2. **仓位冲突**:手动交易和策略交易可能产生冲突
|
||||||
3. **管理混乱**:难以区分哪些订单是策略产生的,哪些是手动产生的
|
3. **管理混乱**:难以区分哪些订单是策略产生的,哪些是手动产生的
|
||||||
|
|
||||||
**建议**:创建一个专门的钱包,只用于尾盘策略。
|
**建议**:创建一个专门的钱包,只用于加密价差策略。
|
||||||
|
|
||||||
### Q7:下单价格为什么是固定的 0.99?
|
### Q7:下单价格为什么是固定的 0.99?
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@
|
|||||||
|
|
||||||
### Q8:策略需要依赖自动赎回功能吗?
|
### Q8:策略需要依赖自动赎回功能吗?
|
||||||
|
|
||||||
**A**:是的,尾盘策略依赖自动赎回功能。
|
**A**:是的,加密价差策略依赖自动赎回功能。
|
||||||
|
|
||||||
**原因**:
|
**原因**:
|
||||||
- 策略下单后会形成仓位
|
- 策略下单后会形成仓位
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
|
|
||||||
### 7.1 查看策略列表
|
### 7.1 查看策略列表
|
||||||
|
|
||||||
在「尾盘策略」页面可以查看所有策略:
|
在「加密价差策略」页面可以查看所有策略:
|
||||||
- 策略名称
|
- 策略名称
|
||||||
- 市场信息
|
- 市场信息
|
||||||
- 时间窗口
|
- 时间窗口
|
||||||
@@ -453,7 +453,7 @@
|
|||||||
|
|
||||||
## 八、总结
|
## 八、总结
|
||||||
|
|
||||||
尾盘策略是一个强大的自动化交易工具,可以帮助您:
|
加密价差策略是一个强大的自动化交易工具,可以帮助您:
|
||||||
|
|
||||||
1. **自动化交易**:无需手动盯盘,系统自动执行
|
1. **自动化交易**:无需手动盯盘,系统自动执行
|
||||||
2. **精准控制**:通过时间窗口和价格区间精确控制触发条件
|
2. **精准控制**:通过时间窗口和价格区间精确控制触发条件
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import Announcements from './pages/Announcements'
|
|||||||
import BacktestList from './pages/BacktestList'
|
import BacktestList from './pages/BacktestList'
|
||||||
import BacktestDetail from './pages/BacktestDetail'
|
import BacktestDetail from './pages/BacktestDetail'
|
||||||
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
|
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
|
||||||
|
import CryptoTailMonitor from './pages/CryptoTailMonitor'
|
||||||
import { wsManager } from './services/websocket'
|
import { wsManager } from './services/websocket'
|
||||||
import type { OrderPushMessage } from './types'
|
import type { OrderPushMessage } from './types'
|
||||||
import { apiService } from './services/api'
|
import { apiService } from './services/api'
|
||||||
@@ -252,6 +253,7 @@ function App() {
|
|||||||
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
|
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
|
||||||
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
|
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
|
||||||
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
|
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
|
||||||
|
<Route path="/crypto-tail-monitor" element={<ProtectedRoute><CryptoTailMonitor /></ProtectedRoute>} />
|
||||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
||||||
{/* 保留旧路由以保持向后兼容 */}
|
{/* 保留旧路由以保持向后兼容 */}
|
||||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import {
|
|||||||
SendOutlined,
|
SendOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
NotificationOutlined,
|
NotificationOutlined,
|
||||||
LineChartOutlined
|
LineChartOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
DashboardOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
@@ -75,6 +77,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||||
keys.push('/copy-trading-management')
|
keys.push('/copy-trading-management')
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||||
|
keys.push('/crypto-tail-management')
|
||||||
|
}
|
||||||
if (path.startsWith('/system-settings')) {
|
if (path.startsWith('/system-settings')) {
|
||||||
keys.push('/system-settings')
|
keys.push('/system-settings')
|
||||||
}
|
}
|
||||||
@@ -90,6 +95,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||||
keys.push('/copy-trading-management')
|
keys.push('/copy-trading-management')
|
||||||
}
|
}
|
||||||
|
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||||
|
keys.push('/crypto-tail-management')
|
||||||
|
}
|
||||||
if (path.startsWith('/system-settings')) {
|
if (path.startsWith('/system-settings')) {
|
||||||
keys.push('/system-settings')
|
keys.push('/system-settings')
|
||||||
}
|
}
|
||||||
@@ -158,9 +166,21 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/crypto-tail-strategy',
|
key: '/crypto-tail-management',
|
||||||
icon: <LineChartOutlined />,
|
icon: <LineChartOutlined />,
|
||||||
label: t('menu.cryptoTailStrategy')
|
label: t('menu.cryptoSpreadStrategy'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/crypto-tail-strategy',
|
||||||
|
icon: <RocketOutlined />,
|
||||||
|
label: t('menu.cryptoTailStrategy')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/crypto-tail-monitor',
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
label: t('menu.cryptoTailMonitor')
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '/positions',
|
key: '/positions',
|
||||||
@@ -230,7 +250,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
|
|
||||||
const handleMenuClick = ({ key }: { key: string }) => {
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
// 如果是父菜单,不导航(但 /system-settings 作为子菜单项时可以导航)
|
// 如果是父菜单,不导航(但 /system-settings 作为子菜单项时可以导航)
|
||||||
if (key === '/copy-trading-management') {
|
if (key === '/copy-trading-management' || key === '/crypto-tail-management') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +308,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
verticalAlign: 'middle'
|
verticalAlign: 'middle'
|
||||||
}}
|
}}
|
||||||
title={hasUpdate ? '有新版本可用,点击前往系统更新' : '当前已是最新版本'}
|
title={hasUpdate ? t('systemUpdate.versionTooltipNew') : t('systemUpdate.versionTooltipLatest')}
|
||||||
>
|
>
|
||||||
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -410,7 +430,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
verticalAlign: 'middle'
|
verticalAlign: 'middle'
|
||||||
}}
|
}}
|
||||||
title={hasUpdate ? '有新版本可用,点击前往系统更新' : '当前已是最新版本'}
|
title={hasUpdate ? t('systemUpdate.versionTooltipNew') : t('systemUpdate.versionTooltipLatest')}
|
||||||
>
|
>
|
||||||
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
{getVersionInfo().gitTag || `v${getVersionText()}`}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@@ -312,7 +312,9 @@
|
|||||||
"leaders": "Leader Management",
|
"leaders": "Leader Management",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"copyTradingConfig": "Copy Trading Config",
|
"copyTradingConfig": "Copy Trading Config",
|
||||||
"cryptoTailStrategy": "Tail Strategy",
|
"cryptoSpreadStrategy": "Crypto Spread Strategy",
|
||||||
|
"cryptoTailStrategy": "Strategy Config",
|
||||||
|
"cryptoTailMonitor": "Real-time Monitor",
|
||||||
"positions": "Position Management",
|
"positions": "Position Management",
|
||||||
"backtest": "Backtest",
|
"backtest": "Backtest",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
@@ -418,6 +420,40 @@
|
|||||||
"saveFailed": "Failed to save auto redeem configuration"
|
"saveFailed": "Failed to save auto redeem configuration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"systemUpdate": {
|
||||||
|
"title": "System Update",
|
||||||
|
"currentVersion": "Current Version",
|
||||||
|
"ready": "Ready",
|
||||||
|
"hasNewVersion": "New version available: {{version}}",
|
||||||
|
"alreadyLatest": "You are on the latest version",
|
||||||
|
"checkFailed": "Failed to check for updates",
|
||||||
|
"confirmTitle": "Confirm Update",
|
||||||
|
"confirmContent1": "Update to version {{version}}?",
|
||||||
|
"confirmContent2": "The system will be temporarily unavailable during the update (about 30-60 seconds).",
|
||||||
|
"confirmContent3": "The page will refresh automatically when the update completes.",
|
||||||
|
"okText": "Update Now",
|
||||||
|
"cancelText": "Cancel",
|
||||||
|
"updateStarted": "Update started, please wait...",
|
||||||
|
"updateFailedWithMessage": "Update failed: {{message}}",
|
||||||
|
"updateSuccessRefresh": "Update successful! Page will refresh in 3 seconds...",
|
||||||
|
"needAdmin": "Admin permission required to perform update",
|
||||||
|
"startFailed": "Failed to start update",
|
||||||
|
"updating": "System is updating",
|
||||||
|
"updateFailedTitle": "Update Failed",
|
||||||
|
"checkUpdate": "Check for Updates",
|
||||||
|
"newVersionFound": "New Version Available",
|
||||||
|
"publishedAt": "Published At",
|
||||||
|
"releaseNotes": "Release Notes",
|
||||||
|
"upgradeNow": "Upgrade to v{{version}} Now",
|
||||||
|
"usageTitle": "Instructions",
|
||||||
|
"usage1": "Click \"Check for Updates\" to see if a new version is available",
|
||||||
|
"usage2": "Update takes about 30-60 seconds; the system will be temporarily unavailable",
|
||||||
|
"usage3": "The page will refresh automatically after a successful update",
|
||||||
|
"usage4": "If the update fails, the system will roll back to the current version",
|
||||||
|
"versionTooltipNew": "New version available, click to go to System Update",
|
||||||
|
"versionTooltipLatest": "You are on the latest version",
|
||||||
|
"prerelease": "Pre-release"
|
||||||
|
},
|
||||||
"builderApiKey": {
|
"builderApiKey": {
|
||||||
"title": "Builder API Key Configuration",
|
"title": "Builder API Key Configuration",
|
||||||
"alertTitle": "What is Builder API Key?",
|
"alertTitle": "What is Builder API Key?",
|
||||||
@@ -1450,12 +1486,12 @@
|
|||||||
"cryptoTailStrategy": {
|
"cryptoTailStrategy": {
|
||||||
"binanceApiAlert": {
|
"binanceApiAlert": {
|
||||||
"title": "Cannot connect to Binance API — strategy cannot run for now",
|
"title": "Cannot connect to Binance API — strategy cannot run for now",
|
||||||
"description": "Tail strategy needs Binance market data to work. The connection failed; this may be a network issue or Binance outage. Try again later by clicking the button below.",
|
"description": "Crypto spread strategy needs Binance market data to work. The connection failed; this may be a network issue or Binance outage. Try again later by clicking the button below.",
|
||||||
"recheck": "Re-check"
|
"recheck": "Re-check"
|
||||||
},
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"title": "Crypto Tail Strategy",
|
"title": "Crypto Spread Strategy",
|
||||||
"walletTip": "Use a dedicated wallet for tail strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.",
|
"walletTip": "Use a dedicated wallet for crypto spread strategy. Do not use it for manual trading or copy trading to avoid balance/position issues.",
|
||||||
"addStrategy": "Add Strategy",
|
"addStrategy": "Add Strategy",
|
||||||
"strategyName": "Strategy Name",
|
"strategyName": "Strategy Name",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
@@ -1512,7 +1548,7 @@
|
|||||||
},
|
},
|
||||||
"redeemRequiredModal": {
|
"redeemRequiredModal": {
|
||||||
"title": "Configure Auto Redeem First",
|
"title": "Configure Auto Redeem First",
|
||||||
"description": "Tail strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.",
|
"description": "Crypto spread strategy requires auto redeem. Please configure Builder API Key and enable auto redeem in System Settings.",
|
||||||
"goToSettings": "Go to Settings",
|
"goToSettings": "Go to Settings",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
@@ -1541,5 +1577,73 @@
|
|||||||
"emptyFail": "No failed records",
|
"emptyFail": "No failed records",
|
||||||
"totalCount": "{count} record(s)"
|
"totalCount": "{count} record(s)"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cryptoTailMonitor": {
|
||||||
|
"title": "Crypto Spread Strategy Monitor",
|
||||||
|
"selectStrategy": "Strategy",
|
||||||
|
"selectStrategyPlaceholder": "Select a strategy to monitor",
|
||||||
|
"direction": "Direction",
|
||||||
|
"directionUp": "Up",
|
||||||
|
"directionDown": "Down",
|
||||||
|
"noData": "Select a strategy to start monitoring",
|
||||||
|
"priceRange": "Price Range",
|
||||||
|
"timeWindow": "Time Window",
|
||||||
|
"stat": {
|
||||||
|
"openPrice": "Open Price",
|
||||||
|
"currentPrice": "Current Price",
|
||||||
|
"spread": "Spread",
|
||||||
|
"remainingTime": "Remaining",
|
||||||
|
"configuredSpread": "Configured Spread",
|
||||||
|
"configuredSpreadMin": "Min Spread",
|
||||||
|
"configuredSpreadMax": "Max Spread",
|
||||||
|
"status": "Status",
|
||||||
|
"minSpreadLine": "Min Spread",
|
||||||
|
"periodSpreadMinMax": "Period Spread",
|
||||||
|
"periodSpreadMin": "Period Min Spread",
|
||||||
|
"periodSpreadMax": "Period Max Spread",
|
||||||
|
"minSpread": "Min",
|
||||||
|
"maxSpread": "Max"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"triggered": "Triggered",
|
||||||
|
"periodEnded": "Period Ended",
|
||||||
|
"inCondition": "In Condition",
|
||||||
|
"waiting": "Waiting"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"title": "Price Chart",
|
||||||
|
"btcTitle": "BTC Price Chart",
|
||||||
|
"marketTitle": "Market Price Chart",
|
||||||
|
"priceChart": "Price Chart",
|
||||||
|
"price": "Price",
|
||||||
|
"openPrice": "Open",
|
||||||
|
"spread": "Spread",
|
||||||
|
"minSpreadLine": "Min Spread Line",
|
||||||
|
"maxSpreadLine": "Max Spread Line",
|
||||||
|
"marketUp": "Up",
|
||||||
|
"marketDown": "Down",
|
||||||
|
"time": "Time",
|
||||||
|
"latestPrice": "Latest",
|
||||||
|
"timeWindowStart": "Window Start",
|
||||||
|
"timeWindowEnd": "Window End"
|
||||||
|
},
|
||||||
|
"strategyInfo": {
|
||||||
|
"title": "Strategy Info",
|
||||||
|
"market": "Market",
|
||||||
|
"interval": "Interval",
|
||||||
|
"account": "Account",
|
||||||
|
"spreadMode": "Spread Mode",
|
||||||
|
"spreadDirection": "Spread Direction"
|
||||||
|
},
|
||||||
|
"periodSwitch": {
|
||||||
|
"mode": "Period Switch Mode",
|
||||||
|
"auto": "Auto",
|
||||||
|
"manual": "Manual",
|
||||||
|
"autoDesc": "Automatically switch to the latest period when it ends",
|
||||||
|
"manualDesc": "Keep complete data when period ends",
|
||||||
|
"switchToLatest": "Switch to Latest Period",
|
||||||
|
"periodEnded": "Current period has ended",
|
||||||
|
"newPeriodAvailable": "New period has started"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
"accountNamePlaceholder": "账户名称(可选)",
|
"accountNamePlaceholder": "账户名称(可选)",
|
||||||
"accountIdRequired": "账户ID不能为空",
|
"accountIdRequired": "账户ID不能为空",
|
||||||
"walletAddress": "钱包地址",
|
"walletAddress": "钱包地址",
|
||||||
|
"walletType": "钱包类型",
|
||||||
"proxyAddress": "代理钱包地址",
|
"proxyAddress": "代理钱包地址",
|
||||||
"apiCredentials": "API 凭证",
|
"apiCredentials": "API 凭证",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
@@ -311,7 +312,9 @@
|
|||||||
"leaders": "Leader 管理",
|
"leaders": "Leader 管理",
|
||||||
"templates": "跟单模板",
|
"templates": "跟单模板",
|
||||||
"copyTradingConfig": "跟单配置",
|
"copyTradingConfig": "跟单配置",
|
||||||
"cryptoTailStrategy": "尾盘策略",
|
"cryptoSpreadStrategy": "加密价差策略",
|
||||||
|
"cryptoTailStrategy": "策略配置",
|
||||||
|
"cryptoTailMonitor": "实时监控",
|
||||||
"positions": "仓位管理",
|
"positions": "仓位管理",
|
||||||
"backtest": "回测",
|
"backtest": "回测",
|
||||||
"statistics": "统计信息",
|
"statistics": "统计信息",
|
||||||
@@ -417,6 +420,40 @@
|
|||||||
"saveFailed": "保存自动赎回配置失败"
|
"saveFailed": "保存自动赎回配置失败"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"systemUpdate": {
|
||||||
|
"title": "系统更新",
|
||||||
|
"currentVersion": "当前版本",
|
||||||
|
"ready": "就绪",
|
||||||
|
"hasNewVersion": "发现新版本: {{version}}",
|
||||||
|
"alreadyLatest": "当前已是最新版本",
|
||||||
|
"checkFailed": "检查更新失败",
|
||||||
|
"confirmTitle": "确认更新",
|
||||||
|
"confirmContent1": "确定要更新到版本 {{version}} 吗?",
|
||||||
|
"confirmContent2": "更新过程中系统将暂时不可用(约30-60秒)。",
|
||||||
|
"confirmContent3": "更新完成后页面将自动刷新。",
|
||||||
|
"okText": "立即更新",
|
||||||
|
"cancelText": "取消",
|
||||||
|
"updateStarted": "更新已启动,请稍候...",
|
||||||
|
"updateFailedWithMessage": "更新失败: {{message}}",
|
||||||
|
"updateSuccessRefresh": "更新成功!页面将在3秒后刷新...",
|
||||||
|
"needAdmin": "需要管理员权限才能执行更新",
|
||||||
|
"startFailed": "启动更新失败",
|
||||||
|
"updating": "系统正在更新",
|
||||||
|
"updateFailedTitle": "更新失败",
|
||||||
|
"checkUpdate": "检查更新",
|
||||||
|
"newVersionFound": "发现新版本",
|
||||||
|
"publishedAt": "发布时间",
|
||||||
|
"releaseNotes": "更新内容",
|
||||||
|
"upgradeNow": "立即升级到 v{{version}}",
|
||||||
|
"usageTitle": "使用说明",
|
||||||
|
"usage1": "点击「检查更新」按钮检查是否有新版本",
|
||||||
|
"usage2": "更新过程约需30-60秒,期间系统将暂时不可用",
|
||||||
|
"usage3": "更新成功后页面将自动刷新",
|
||||||
|
"usage4": "如果更新失败,系统会自动回滚到当前版本",
|
||||||
|
"versionTooltipNew": "有新版本可用,点击前往系统更新",
|
||||||
|
"versionTooltipLatest": "当前已是最新版本",
|
||||||
|
"prerelease": "预发布"
|
||||||
|
},
|
||||||
"builderApiKey": {
|
"builderApiKey": {
|
||||||
"title": "Builder API Key 配置",
|
"title": "Builder API Key 配置",
|
||||||
"alertTitle": "什么是 Builder API Key?",
|
"alertTitle": "什么是 Builder API Key?",
|
||||||
@@ -1449,12 +1486,12 @@
|
|||||||
"cryptoTailStrategy": {
|
"cryptoTailStrategy": {
|
||||||
"binanceApiAlert": {
|
"binanceApiAlert": {
|
||||||
"title": "无法连接币安 API,策略暂时不能运行",
|
"title": "无法连接币安 API,策略暂时不能运行",
|
||||||
"description": "尾盘策略需要从币安获取行情数据才能工作。当前连接失败,可能是网络问题或币安服务异常,请稍后点击下方按钮重新检测。",
|
"description": "加密价差策略需要从币安获取行情数据才能工作。当前连接失败,可能是网络问题或币安服务异常,请稍后点击下方按钮重新检测。",
|
||||||
"recheck": "重新检测"
|
"recheck": "重新检测"
|
||||||
},
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"title": "加密尾盘策略",
|
"title": "加密价差策略",
|
||||||
"walletTip": "请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。",
|
"walletTip": "请使用单独的钱包运行加密价差策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。",
|
||||||
"addStrategy": "新增策略",
|
"addStrategy": "新增策略",
|
||||||
"strategyName": "策略名称",
|
"strategyName": "策略名称",
|
||||||
"account": "账户",
|
"account": "账户",
|
||||||
@@ -1511,7 +1548,7 @@
|
|||||||
},
|
},
|
||||||
"redeemRequiredModal": {
|
"redeemRequiredModal": {
|
||||||
"title": "请先配置自动赎回",
|
"title": "请先配置自动赎回",
|
||||||
"description": "尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。",
|
"description": "加密价差策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。",
|
||||||
"goToSettings": "去配置",
|
"goToSettings": "去配置",
|
||||||
"cancel": "取消"
|
"cancel": "取消"
|
||||||
},
|
},
|
||||||
@@ -1540,5 +1577,73 @@
|
|||||||
"emptyFail": "暂无失败记录",
|
"emptyFail": "暂无失败记录",
|
||||||
"totalCount": "共 {count} 条"
|
"totalCount": "共 {count} 条"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cryptoTailMonitor": {
|
||||||
|
"title": "加密价差策略监控",
|
||||||
|
"selectStrategy": "选择策略",
|
||||||
|
"selectStrategyPlaceholder": "请选择要监控的策略",
|
||||||
|
"direction": "监控方向",
|
||||||
|
"directionUp": "Up",
|
||||||
|
"directionDown": "Down",
|
||||||
|
"noData": "请选择一个策略开始监控",
|
||||||
|
"priceRange": "价格区间",
|
||||||
|
"timeWindow": "时间窗口",
|
||||||
|
"stat": {
|
||||||
|
"openPrice": "开盘价",
|
||||||
|
"currentPrice": "最新价",
|
||||||
|
"spread": "价差",
|
||||||
|
"remainingTime": "剩余时间",
|
||||||
|
"configuredSpread": "配置价差",
|
||||||
|
"configuredSpreadMin": "最小价差",
|
||||||
|
"configuredSpreadMax": "最大价差",
|
||||||
|
"status": "状态",
|
||||||
|
"minSpreadLine": "最小价差线",
|
||||||
|
"periodSpreadMinMax": "周期内价差",
|
||||||
|
"periodSpreadMin": "周期内最小价差",
|
||||||
|
"periodSpreadMax": "周期内最大价差",
|
||||||
|
"minSpread": "最小",
|
||||||
|
"maxSpread": "最大"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"triggered": "已触发",
|
||||||
|
"periodEnded": "周期结束",
|
||||||
|
"inCondition": "满足条件",
|
||||||
|
"waiting": "等待中"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"title": "分时图",
|
||||||
|
"btcTitle": "BTC 分时图",
|
||||||
|
"marketTitle": "市场分时图",
|
||||||
|
"priceChart": "价格分时图",
|
||||||
|
"price": "价格",
|
||||||
|
"openPrice": "开盘价",
|
||||||
|
"spread": "价差",
|
||||||
|
"minSpreadLine": "最小价差线",
|
||||||
|
"maxSpreadLine": "最大价差线",
|
||||||
|
"marketUp": "Up",
|
||||||
|
"marketDown": "Down",
|
||||||
|
"time": "时间",
|
||||||
|
"latestPrice": "最新价",
|
||||||
|
"timeWindowStart": "区间开始",
|
||||||
|
"timeWindowEnd": "区间结束"
|
||||||
|
},
|
||||||
|
"strategyInfo": {
|
||||||
|
"title": "策略信息",
|
||||||
|
"market": "市场",
|
||||||
|
"interval": "周期",
|
||||||
|
"account": "账户",
|
||||||
|
"spreadMode": "价差模式",
|
||||||
|
"spreadDirection": "价差方向"
|
||||||
|
},
|
||||||
|
"periodSwitch": {
|
||||||
|
"mode": "周期切换模式",
|
||||||
|
"auto": "自动",
|
||||||
|
"manual": "手动",
|
||||||
|
"autoDesc": "周期结束时自动切换到最新周期",
|
||||||
|
"manualDesc": "周期结束时保留完整数据",
|
||||||
|
"switchToLatest": "切换到最新周期",
|
||||||
|
"periodEnded": "当前周期已结束",
|
||||||
|
"newPeriodAvailable": "新周期已开始"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,9 @@
|
|||||||
"leaders": "Leader 管理",
|
"leaders": "Leader 管理",
|
||||||
"templates": "跟單模板",
|
"templates": "跟單模板",
|
||||||
"copyTradingConfig": "跟單配置",
|
"copyTradingConfig": "跟單配置",
|
||||||
"cryptoTailStrategy": "尾盤策略",
|
"cryptoSpreadStrategy": "加密價差策略",
|
||||||
|
"cryptoTailStrategy": "策略配置",
|
||||||
|
"cryptoTailMonitor": "即時監控",
|
||||||
"positions": "倉位管理",
|
"positions": "倉位管理",
|
||||||
"backtest": "回測",
|
"backtest": "回測",
|
||||||
"statistics": "統計信息",
|
"statistics": "統計信息",
|
||||||
@@ -418,6 +420,40 @@
|
|||||||
"saveFailed": "保存自動贖回配置失敗"
|
"saveFailed": "保存自動贖回配置失敗"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"systemUpdate": {
|
||||||
|
"title": "系統更新",
|
||||||
|
"currentVersion": "當前版本",
|
||||||
|
"ready": "就緒",
|
||||||
|
"hasNewVersion": "發現新版本: {{version}}",
|
||||||
|
"alreadyLatest": "當前已是最新版本",
|
||||||
|
"checkFailed": "檢查更新失敗",
|
||||||
|
"confirmTitle": "確認更新",
|
||||||
|
"confirmContent1": "確定要更新到版本 {{version}} 嗎?",
|
||||||
|
"confirmContent2": "更新過程中系統將暫時不可用(約30-60秒)。",
|
||||||
|
"confirmContent3": "更新完成後頁面將自動刷新。",
|
||||||
|
"okText": "立即更新",
|
||||||
|
"cancelText": "取消",
|
||||||
|
"updateStarted": "更新已啟動,請稍候...",
|
||||||
|
"updateFailedWithMessage": "更新失敗: {{message}}",
|
||||||
|
"updateSuccessRefresh": "更新成功!頁面將在3秒後刷新...",
|
||||||
|
"needAdmin": "需要管理員權限才能執行更新",
|
||||||
|
"startFailed": "啟動更新失敗",
|
||||||
|
"updating": "系統正在更新",
|
||||||
|
"updateFailedTitle": "更新失敗",
|
||||||
|
"checkUpdate": "檢查更新",
|
||||||
|
"newVersionFound": "發現新版本",
|
||||||
|
"publishedAt": "發佈時間",
|
||||||
|
"releaseNotes": "更新內容",
|
||||||
|
"upgradeNow": "立即升級到 v{{version}}",
|
||||||
|
"usageTitle": "使用說明",
|
||||||
|
"usage1": "點擊「檢查更新」按鈕檢查是否有新版本",
|
||||||
|
"usage2": "更新過程約需30-60秒,期間系統將暫時不可用",
|
||||||
|
"usage3": "更新成功後頁面將自動刷新",
|
||||||
|
"usage4": "如果更新失敗,系統會自動回滾到當前版本",
|
||||||
|
"versionTooltipNew": "有新版本可用,點擊前往系統更新",
|
||||||
|
"versionTooltipLatest": "當前已是最新版本",
|
||||||
|
"prerelease": "預發佈"
|
||||||
|
},
|
||||||
"builderApiKey": {
|
"builderApiKey": {
|
||||||
"title": "Builder API Key 配置",
|
"title": "Builder API Key 配置",
|
||||||
"alertTitle": "什麼是 Builder API Key?",
|
"alertTitle": "什麼是 Builder API Key?",
|
||||||
@@ -1450,12 +1486,12 @@
|
|||||||
"cryptoTailStrategy": {
|
"cryptoTailStrategy": {
|
||||||
"binanceApiAlert": {
|
"binanceApiAlert": {
|
||||||
"title": "無法連接幣安 API,策略暫時不能運行",
|
"title": "無法連接幣安 API,策略暫時不能運行",
|
||||||
"description": "尾盤策略需要從幣安取得行情資料才能運作。目前連線失敗,可能是網路問題或幣安服務異常,請稍後點擊下方按鈕重新檢測。",
|
"description": "加密價差策略需要從幣安取得行情資料才能運作。目前連線失敗,可能是網路問題或幣安服務異常,請稍後點擊下方按鈕重新檢測。",
|
||||||
"recheck": "重新檢測"
|
"recheck": "重新檢測"
|
||||||
},
|
},
|
||||||
"list": {
|
"list": {
|
||||||
"title": "加密尾盤策略",
|
"title": "加密價差策略",
|
||||||
"walletTip": "請使用單獨的錢包運行尾盤策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。",
|
"walletTip": "請使用單獨的錢包運行加密價差策略,避免該錢包用於手動交易、跟單等其他操作,否則可能導致餘額或倉位變化,造成策略執行異常。",
|
||||||
"addStrategy": "新增策略",
|
"addStrategy": "新增策略",
|
||||||
"strategyName": "策略名稱",
|
"strategyName": "策略名稱",
|
||||||
"account": "賬戶",
|
"account": "賬戶",
|
||||||
@@ -1512,7 +1548,7 @@
|
|||||||
},
|
},
|
||||||
"redeemRequiredModal": {
|
"redeemRequiredModal": {
|
||||||
"title": "請先配置自動贖回",
|
"title": "請先配置自動贖回",
|
||||||
"description": "尾盤策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。",
|
"description": "加密價差策略依賴自動贖回功能,請先在系統設置中配置 Builder API Key 並開啟自動贖回。",
|
||||||
"goToSettings": "去配置",
|
"goToSettings": "去配置",
|
||||||
"cancel": "取消"
|
"cancel": "取消"
|
||||||
},
|
},
|
||||||
@@ -1541,5 +1577,73 @@
|
|||||||
"emptyFail": "暫無失敗記錄",
|
"emptyFail": "暫無失敗記錄",
|
||||||
"totalCount": "共 {count} 條"
|
"totalCount": "共 {count} 條"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cryptoTailMonitor": {
|
||||||
|
"title": "加密價差策略監控",
|
||||||
|
"selectStrategy": "選擇策略",
|
||||||
|
"selectStrategyPlaceholder": "請選擇要監控的策略",
|
||||||
|
"direction": "監控方向",
|
||||||
|
"directionUp": "Up",
|
||||||
|
"directionDown": "Down",
|
||||||
|
"noData": "請選擇一個策略開始監控",
|
||||||
|
"priceRange": "價格區間",
|
||||||
|
"timeWindow": "時間窗口",
|
||||||
|
"stat": {
|
||||||
|
"openPrice": "開盤價",
|
||||||
|
"currentPrice": "最新價",
|
||||||
|
"spread": "價差",
|
||||||
|
"remainingTime": "剩餘時間",
|
||||||
|
"configuredSpread": "配置價差",
|
||||||
|
"configuredSpreadMin": "最小價差",
|
||||||
|
"configuredSpreadMax": "最大價差",
|
||||||
|
"status": "狀態",
|
||||||
|
"minSpreadLine": "最小價差線",
|
||||||
|
"periodSpreadMinMax": "週期內價差",
|
||||||
|
"periodSpreadMin": "週期內最小價差",
|
||||||
|
"periodSpreadMax": "週期內最大價差",
|
||||||
|
"minSpread": "最小",
|
||||||
|
"maxSpread": "最大"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"triggered": "已觸發",
|
||||||
|
"periodEnded": "週期結束",
|
||||||
|
"inCondition": "滿足條件",
|
||||||
|
"waiting": "等待中"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"title": "分時圖",
|
||||||
|
"btcTitle": "BTC 分時圖",
|
||||||
|
"marketTitle": "市場分時圖",
|
||||||
|
"priceChart": "價格分時圖",
|
||||||
|
"price": "價格",
|
||||||
|
"openPrice": "開盤價",
|
||||||
|
"spread": "價差",
|
||||||
|
"minSpreadLine": "最小價差線",
|
||||||
|
"maxSpreadLine": "最大價差線",
|
||||||
|
"marketUp": "Up",
|
||||||
|
"marketDown": "Down",
|
||||||
|
"time": "時間",
|
||||||
|
"latestPrice": "最新價",
|
||||||
|
"timeWindowStart": "區間開始",
|
||||||
|
"timeWindowEnd": "區間結束"
|
||||||
|
},
|
||||||
|
"strategyInfo": {
|
||||||
|
"title": "策略信息",
|
||||||
|
"market": "市場",
|
||||||
|
"interval": "週期",
|
||||||
|
"account": "賬戶",
|
||||||
|
"spreadMode": "價差模式",
|
||||||
|
"spreadDirection": "價差方向"
|
||||||
|
},
|
||||||
|
"periodSwitch": {
|
||||||
|
"mode": "週期切換模式",
|
||||||
|
"auto": "自動",
|
||||||
|
"manual": "手動",
|
||||||
|
"autoDesc": "週期結束時自動切換到最新週期",
|
||||||
|
"manualDesc": "週期結束時保留完整數據",
|
||||||
|
"switchToLatest": "切換到最新週期",
|
||||||
|
"periodEnded": "當前週期已結束",
|
||||||
|
"newPeriodAvailable": "新週期已開始"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,8 @@ 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 BacktestChart: React.FC<BacktestChartProps> = ({ trades }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const chartRef = useRef<HTMLDivElement>(null)
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
|||||||
@@ -0,0 +1,951 @@
|
|||||||
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Typography,
|
||||||
|
Spin,
|
||||||
|
Empty,
|
||||||
|
Alert,
|
||||||
|
Radio,
|
||||||
|
Button,
|
||||||
|
Tooltip
|
||||||
|
} from 'antd'
|
||||||
|
import { ClockCircleOutlined, SyncOutlined, InfoCircleOutlined } from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMediaQuery } from 'react-responsive'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import type { EChartsOption } from 'echarts'
|
||||||
|
import { apiService } from '../services/api'
|
||||||
|
import { useWebSocketSubscription } from '../hooks/useWebSocket'
|
||||||
|
import { formatNumber } from '../utils'
|
||||||
|
import type {
|
||||||
|
CryptoTailStrategyDto,
|
||||||
|
CryptoTailMonitorInitResponse,
|
||||||
|
CryptoTailMonitorPushData
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
/** 分时图数据点:时间戳、BTC 价格 USDC、市场 Up/Down 价格 0-1 */
|
||||||
|
interface PriceDataPoint {
|
||||||
|
time: number
|
||||||
|
btcPrice: number | null
|
||||||
|
marketPriceUp: number | null
|
||||||
|
marketPriceDown: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CryptoTailMonitor: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||||
|
|
||||||
|
// 策略列表
|
||||||
|
const [strategies, setStrategies] = useState<CryptoTailStrategyDto[]>([])
|
||||||
|
const [strategiesLoading, setStrategiesLoading] = useState(false)
|
||||||
|
|
||||||
|
// 选中的策略
|
||||||
|
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// 监控数据
|
||||||
|
const [initData, setInitData] = useState<CryptoTailMonitorInitResponse | null>(null)
|
||||||
|
const [pushData, setPushData] = useState<CryptoTailMonitorPushData | null>(null)
|
||||||
|
const [initLoading, setInitLoading] = useState(false)
|
||||||
|
|
||||||
|
// 价格历史数据(用于分时图)
|
||||||
|
const [priceHistory, setPriceHistory] = useState<PriceDataPoint[]>([])
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const chartInstance = useRef<echarts.ECharts | null>(null)
|
||||||
|
const marketChartRef = useRef<HTMLDivElement>(null)
|
||||||
|
const marketChartInstance = useRef<echarts.ECharts | null>(null)
|
||||||
|
const lastPeriodStartRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
// localStorage key for period switch mode
|
||||||
|
const PERIOD_SWITCH_MODE_KEY = 'cryptoTailMonitor_periodSwitchMode'
|
||||||
|
|
||||||
|
// 记录首次数据进入时间(用于中途进入时的横轴起点)
|
||||||
|
const [firstDataTime, setFirstDataTime] = useState<number | null>(null)
|
||||||
|
// 标记是否已切换过周期(切换后使用完整周期)
|
||||||
|
const [hasSwitchedPeriod, setHasSwitchedPeriod] = useState<boolean>(false)
|
||||||
|
// 周期切换模式:auto(自动切换)| manual(手动切换),从 localStorage 读取缓存
|
||||||
|
const [periodSwitchMode, setPeriodSwitchMode] = useState<'auto' | 'manual'>(() => {
|
||||||
|
const cached = localStorage.getItem(PERIOD_SWITCH_MODE_KEY)
|
||||||
|
return (cached === 'auto' || cached === 'manual') ? cached : 'auto'
|
||||||
|
})
|
||||||
|
// 手动模式下,存储最新周期的数据(用户未切换时)
|
||||||
|
const [pendingPeriodData, setPendingPeriodData] = useState<{
|
||||||
|
periodStartUnix: number
|
||||||
|
priceHistory: PriceDataPoint[]
|
||||||
|
initData: CryptoTailMonitorInitResponse | null
|
||||||
|
pushData: CryptoTailMonitorPushData | null
|
||||||
|
} | null>(null)
|
||||||
|
// 标记当前是否在查看旧周期(手动模式下)
|
||||||
|
const [isViewingOldPeriod, setIsViewingOldPeriod] = useState<boolean>(false)
|
||||||
|
|
||||||
|
// 获取策略列表
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStrategies = async () => {
|
||||||
|
setStrategiesLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiService.cryptoTailStrategy.list({ enabled: true })
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
setStrategies(res.data.data.list ?? [])
|
||||||
|
// 自动选择第一个策略
|
||||||
|
if (res.data.data.list?.length > 0 && !selectedStrategyId) {
|
||||||
|
setSelectedStrategyId(res.data.data.list[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch strategies:', e)
|
||||||
|
} finally {
|
||||||
|
setStrategiesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchStrategies()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 初始化监控数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedStrategyId) {
|
||||||
|
setInitData(null)
|
||||||
|
setPushData(null)
|
||||||
|
setPriceHistory([])
|
||||||
|
setFirstDataTime(null)
|
||||||
|
setHasSwitchedPeriod(false)
|
||||||
|
setPendingPeriodData(null)
|
||||||
|
setIsViewingOldPeriod(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const initMonitor = async () => {
|
||||||
|
setInitLoading(true)
|
||||||
|
setPriceHistory([])
|
||||||
|
setFirstDataTime(null)
|
||||||
|
setHasSwitchedPeriod(false)
|
||||||
|
setPendingPeriodData(null)
|
||||||
|
setIsViewingOldPeriod(false)
|
||||||
|
try {
|
||||||
|
const res = await apiService.cryptoTailStrategy.monitorInit(selectedStrategyId)
|
||||||
|
if (res.data.code === 0 && res.data.data) {
|
||||||
|
setInitData(res.data.data)
|
||||||
|
} else {
|
||||||
|
setInitData(null)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to init monitor:', e)
|
||||||
|
setInitData(null)
|
||||||
|
} finally {
|
||||||
|
setInitLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initMonitor()
|
||||||
|
}, [selectedStrategyId])
|
||||||
|
|
||||||
|
// WebSocket 订阅
|
||||||
|
const handlePushData = useCallback((data: CryptoTailMonitorPushData) => {
|
||||||
|
if (data.strategyId !== selectedStrategyId) return
|
||||||
|
|
||||||
|
const btcPrice = data.currentPriceBtc != null && data.currentPriceBtc !== ''
|
||||||
|
? parseFloat(data.currentPriceBtc)
|
||||||
|
: null
|
||||||
|
const marketUp = data.currentPriceUp != null && data.currentPriceUp !== ''
|
||||||
|
? parseFloat(data.currentPriceUp)
|
||||||
|
: null
|
||||||
|
const marketDown = data.currentPriceDown != null && data.currentPriceDown !== ''
|
||||||
|
? parseFloat(data.currentPriceDown)
|
||||||
|
: null
|
||||||
|
const hasBtc = btcPrice != null && !Number.isNaN(btcPrice)
|
||||||
|
const hasMarket = (marketUp != null && !Number.isNaN(marketUp)) || (marketDown != null && !Number.isNaN(marketDown))
|
||||||
|
if (!hasBtc && !hasMarket) return
|
||||||
|
|
||||||
|
const newPoint: PriceDataPoint = {
|
||||||
|
time: data.timestamp,
|
||||||
|
btcPrice: hasBtc ? btcPrice : null,
|
||||||
|
marketPriceUp: hasMarket && marketUp != null && !Number.isNaN(marketUp) ? marketUp : null,
|
||||||
|
marketPriceDown: hasMarket && marketDown != null && !Number.isNaN(marketDown) ? marketDown : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 ref 检测周期切换,避免因依赖 initData 导致回调频繁重建
|
||||||
|
const pushPeriod = data.periodStartUnix
|
||||||
|
const lastPeriod = lastPeriodStartRef.current
|
||||||
|
|
||||||
|
if (pushPeriod != null && pushPeriod !== lastPeriod) {
|
||||||
|
// 新周期到来
|
||||||
|
lastPeriodStartRef.current = pushPeriod
|
||||||
|
|
||||||
|
if (periodSwitchMode === 'manual' && lastPeriod != null) {
|
||||||
|
// 手动模式:保存新周期数据到 pending,保留当前显示
|
||||||
|
setPendingPeriodData({
|
||||||
|
periodStartUnix: pushPeriod,
|
||||||
|
priceHistory: [newPoint],
|
||||||
|
initData: null,
|
||||||
|
pushData: data
|
||||||
|
})
|
||||||
|
setIsViewingOldPeriod(true)
|
||||||
|
// 更新 pending 数据的 initData
|
||||||
|
setInitData(prev => {
|
||||||
|
if (prev) {
|
||||||
|
setPendingPeriodData(p => p ? { ...p, initData: { ...prev, periodStartUnix: pushPeriod, marketTitle: (data as { marketTitle?: string }).marketTitle ?? prev.marketTitle } } : null)
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 自动模式或首次推送:直接切换
|
||||||
|
if (lastPeriod != null) {
|
||||||
|
setHasSwitchedPeriod(true)
|
||||||
|
}
|
||||||
|
setFirstDataTime(newPoint.time)
|
||||||
|
setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod, marketTitle: (data as { marketTitle?: string }).marketTitle ?? prev.marketTitle } : null)
|
||||||
|
setPriceHistory([newPoint])
|
||||||
|
setPushData(data)
|
||||||
|
setIsViewingOldPeriod(false)
|
||||||
|
setPendingPeriodData(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 同周期:追加数据
|
||||||
|
if (periodSwitchMode === 'manual' && isViewingOldPeriod && pendingPeriodData) {
|
||||||
|
// 手动模式下,更新 pending 数据
|
||||||
|
const minIntervalMs = 1_000
|
||||||
|
setPendingPeriodData(prev => {
|
||||||
|
if (!prev) return null
|
||||||
|
const lastTime = prev.priceHistory.length > 0 ? prev.priceHistory[prev.priceHistory.length - 1].time : 0
|
||||||
|
if (prev.priceHistory.length > 0 && newPoint.time - lastTime < minIntervalMs) {
|
||||||
|
return { ...prev, pushData: data }
|
||||||
|
}
|
||||||
|
const maxPoints = 300
|
||||||
|
const newHistory = [...prev.priceHistory, newPoint].slice(-maxPoints)
|
||||||
|
return { ...prev, priceHistory: newHistory, pushData: data }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setFirstDataTime(prev => {
|
||||||
|
if (prev == null) {
|
||||||
|
return newPoint.time
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
const minIntervalMs = 1_000
|
||||||
|
setPriceHistory(prev => {
|
||||||
|
const lastTime = prev.length > 0 ? prev[prev.length - 1].time : 0
|
||||||
|
if (prev.length > 0 && newPoint.time - lastTime < minIntervalMs) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const maxPoints = 300
|
||||||
|
const newHistory = [...prev, newPoint]
|
||||||
|
return newHistory.slice(-maxPoints)
|
||||||
|
})
|
||||||
|
setPushData(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedStrategyId, periodSwitchMode, isViewingOldPeriod, pendingPeriodData])
|
||||||
|
|
||||||
|
// 手动切换到最新周期
|
||||||
|
const handleSwitchToLatestPeriod = useCallback(() => {
|
||||||
|
if (pendingPeriodData) {
|
||||||
|
setPriceHistory(pendingPeriodData.priceHistory)
|
||||||
|
setFirstDataTime(pendingPeriodData.priceHistory[0]?.time ?? null)
|
||||||
|
if (pendingPeriodData.initData) {
|
||||||
|
setInitData(pendingPeriodData.initData)
|
||||||
|
}
|
||||||
|
if (pendingPeriodData.pushData) {
|
||||||
|
setPushData(pendingPeriodData.pushData)
|
||||||
|
}
|
||||||
|
setHasSwitchedPeriod(true)
|
||||||
|
setIsViewingOldPeriod(false)
|
||||||
|
setPendingPeriodData(null)
|
||||||
|
}
|
||||||
|
}, [pendingPeriodData])
|
||||||
|
|
||||||
|
const channel = selectedStrategyId ? `crypto_tail_monitor_${selectedStrategyId}` : ''
|
||||||
|
useWebSocketSubscription(channel, handlePushData)
|
||||||
|
|
||||||
|
// 图表容器仅在 initData 存在时渲染,故在更新图表时懒初始化
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
chartInstance.current?.resize()
|
||||||
|
marketChartInstance.current?.resize()
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
chartInstance.current?.dispose()
|
||||||
|
chartInstance.current = null
|
||||||
|
marketChartInstance.current?.dispose()
|
||||||
|
marketChartInstance.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 切换策略时销毁并重新初始化图表实例
|
||||||
|
useEffect(() => {
|
||||||
|
if (chartInstance.current) {
|
||||||
|
chartInstance.current.dispose()
|
||||||
|
chartInstance.current = null
|
||||||
|
}
|
||||||
|
if (marketChartInstance.current) {
|
||||||
|
marketChartInstance.current.dispose()
|
||||||
|
marketChartInstance.current = null
|
||||||
|
}
|
||||||
|
}, [selectedStrategyId])
|
||||||
|
|
||||||
|
// 更新图表:分时图为 BTC 价格 USDC
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initData) return
|
||||||
|
if (chartRef.current && !chartInstance.current) {
|
||||||
|
chartInstance.current = echarts.init(chartRef.current)
|
||||||
|
}
|
||||||
|
if (!chartInstance.current) return
|
||||||
|
|
||||||
|
const periodStartMs = (initData.periodStartUnix ?? 0) * 1000
|
||||||
|
const periodEndMs = periodStartMs + (initData.intervalSeconds ?? 300) * 1000
|
||||||
|
|
||||||
|
// data.timestamp 为毫秒,firstDataTime 已是 ms,无需再乘 1000
|
||||||
|
const firstDataMs = firstDataTime != null ? firstDataTime : null
|
||||||
|
const isMidEntry = firstDataMs != null && !hasSwitchedPeriod && firstDataMs > periodStartMs
|
||||||
|
// 中途进入时横轴起点为进入时刻,否则为周期起点
|
||||||
|
const xAxisMin = isMidEntry ? firstDataMs : periodStartMs
|
||||||
|
|
||||||
|
const btcData: [number, number | null][] = priceHistory.length > 0
|
||||||
|
? priceHistory.map(p => [p.time, p.btcPrice])
|
||||||
|
: []
|
||||||
|
const openBtc = pushData?.openPriceBtc ?? initData.openPriceBtc
|
||||||
|
const openBtcNum = openBtc != null ? parseFloat(openBtc) : null
|
||||||
|
|
||||||
|
const hasAnyBtcData = btcData.some(([, v]) => v != null && !Number.isNaN(v))
|
||||||
|
const btcPlaceholderTime = xAxisMin
|
||||||
|
const displayBtcData: [number, number | null][] = hasAnyBtcData
|
||||||
|
? btcData
|
||||||
|
: (openBtcNum != null ? [[btcPlaceholderTime, openBtcNum]] : [])
|
||||||
|
const minSpreadUpRaw = pushData?.minSpreadLineUp ?? initData.autoMinSpreadUp
|
||||||
|
const minSpreadDownRaw = pushData?.minSpreadLineDown ?? initData.autoMinSpreadDown
|
||||||
|
const minSpreadUp = minSpreadUpRaw != null && minSpreadUpRaw !== '' ? parseFloat(minSpreadUpRaw) : null
|
||||||
|
const minSpreadDown = minSpreadDownRaw != null && minSpreadDownRaw !== '' ? parseFloat(minSpreadDownRaw) : null
|
||||||
|
|
||||||
|
const validPrices = displayBtcData.flatMap(([, v]) => (v != null && !Number.isNaN(v) ? [v] : []))
|
||||||
|
const defaultRange = 500
|
||||||
|
let yMin: number | undefined
|
||||||
|
let yMax: number | undefined
|
||||||
|
if (validPrices.length > 0) {
|
||||||
|
const dataMin = Math.min(...validPrices)
|
||||||
|
const dataMax = Math.max(...validPrices)
|
||||||
|
const dataRange = dataMax - dataMin
|
||||||
|
const minRange = Math.max(Math.abs(dataMax) * 0.01, 10)
|
||||||
|
const range = Math.max(dataRange, minRange)
|
||||||
|
const padding = range * 0.25
|
||||||
|
yMin = dataMin - padding
|
||||||
|
yMax = dataMax + padding
|
||||||
|
} else if (openBtcNum != null) {
|
||||||
|
const spread = minSpreadUp ?? minSpreadDown ?? defaultRange
|
||||||
|
const halfRange = spread * 1.5
|
||||||
|
yMin = openBtcNum - halfRange
|
||||||
|
yMax = openBtcNum + halfRange
|
||||||
|
}
|
||||||
|
|
||||||
|
const markLineData: Array<{ name?: string; yAxis?: number; xAxis?: number; lineStyle: { type: 'dashed' | 'solid'; color: string }; label?: { show: boolean; formatter?: string }; emphasis?: { label?: { show?: boolean; formatter?: string } } }> = []
|
||||||
|
if (openBtcNum != null && !Number.isNaN(openBtcNum)) {
|
||||||
|
markLineData.push({
|
||||||
|
name: t('cryptoTailMonitor.chart.openPrice'),
|
||||||
|
yAxis: openBtcNum,
|
||||||
|
lineStyle: { type: 'dashed', color: '#999' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const isMaxSpread = (initData.spreadDirection ?? 'MIN') === 'MAX'
|
||||||
|
const spreadLineLabelKey = isMaxSpread ? 'cryptoTailMonitor.chart.maxSpreadLine' : 'cryptoTailMonitor.chart.minSpreadLine'
|
||||||
|
if (openBtcNum != null && minSpreadUp != null && !Number.isNaN(minSpreadUp)) {
|
||||||
|
markLineData.push({
|
||||||
|
name: t(spreadLineLabelKey) + ' Up',
|
||||||
|
yAxis: openBtcNum + minSpreadUp,
|
||||||
|
lineStyle: { type: 'dashed', color: '#ff4d4f' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (openBtcNum != null && minSpreadDown != null && !Number.isNaN(minSpreadDown)) {
|
||||||
|
markLineData.push({
|
||||||
|
name: t(spreadLineLabelKey) + ' Down',
|
||||||
|
yAxis: openBtcNum - minSpreadDown,
|
||||||
|
lineStyle: { type: 'dashed', color: '#ff4d4f' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 时间窗口两条竖线:灰色虚线,悬停时显示标签
|
||||||
|
const windowStartMs = periodStartMs + (initData.windowStartSeconds ?? 0) * 1000
|
||||||
|
const windowEndMs = periodStartMs + (initData.windowEndSeconds ?? 0) * 1000
|
||||||
|
if (windowStartMs > xAxisMin && windowStartMs < periodEndMs) {
|
||||||
|
markLineData.push({
|
||||||
|
xAxis: windowStartMs,
|
||||||
|
lineStyle: { type: 'dashed', color: 'rgba(128, 128, 128, 0.9)' },
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: { label: { show: true, formatter: t('cryptoTailMonitor.chart.timeWindowStart') } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (windowEndMs > xAxisMin && windowEndMs < periodEndMs && windowEndMs !== windowStartMs) {
|
||||||
|
markLineData.push({
|
||||||
|
xAxis: windowEndMs,
|
||||||
|
lineStyle: { type: 'dashed', color: 'rgba(128, 128, 128, 0.9)' },
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: { label: { show: true, formatter: t('cryptoTailMonitor.chart.timeWindowEnd') } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodStartUnixSec = initData.periodStartUnix ?? 0
|
||||||
|
const option: EChartsOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
confine: true,
|
||||||
|
padding: [6, 8],
|
||||||
|
formatter: (params: unknown) => {
|
||||||
|
const arr = params as Array<{ seriesName: string; name: string | number; value: number | [number, number]; axisValue?: number }>
|
||||||
|
const priceParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.price'))
|
||||||
|
if (!priceParam) return ''
|
||||||
|
const val = Array.isArray(priceParam.value) ? priceParam.value[1] : priceParam.value
|
||||||
|
if (val == null || Number.isNaN(val)) return ''
|
||||||
|
// 优先从 value[0] 取时间戳(毫秒),否则用 axisValue 或 name
|
||||||
|
const rawTime = Array.isArray(priceParam.value)
|
||||||
|
? priceParam.value[0]
|
||||||
|
: (priceParam.axisValue ?? priceParam.name)
|
||||||
|
let timeStr = ''
|
||||||
|
if (typeof rawTime === 'number' && !Number.isNaN(rawTime)) {
|
||||||
|
const offsetSec = Math.floor(rawTime / 1000) - periodStartUnixSec
|
||||||
|
const mins = Math.floor(offsetSec / 60)
|
||||||
|
const secs = Math.abs(offsetSec) % 60
|
||||||
|
timeStr = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
} else if (rawTime != null && rawTime !== '') {
|
||||||
|
timeStr = String(rawTime)
|
||||||
|
} else {
|
||||||
|
timeStr = '--'
|
||||||
|
}
|
||||||
|
return `<span style="font-size:12px">${timeStr} ${Number(val).toFixed(2)} USDC</span>`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
top: 0
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
top: '12%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'time',
|
||||||
|
min: xAxisMin,
|
||||||
|
max: periodEndMs,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (val: number) => {
|
||||||
|
const offsetSec = Math.floor(val / 1000) - periodStartUnixSec
|
||||||
|
const mins = Math.floor(offsetSec / 60)
|
||||||
|
const secs = Math.abs(offsetSec) % 60
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
scale: true,
|
||||||
|
min: yMin,
|
||||||
|
max: yMax,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (value: number) => value.toFixed(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: t('cryptoTailMonitor.chart.price'),
|
||||||
|
type: 'line',
|
||||||
|
data: displayBtcData,
|
||||||
|
smooth: true,
|
||||||
|
symbol: displayBtcData.length === 1 ? 'circle' : 'none',
|
||||||
|
symbolSize: 4,
|
||||||
|
lineStyle: { width: 2, color: '#1890ff' },
|
||||||
|
areaStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
x2: 0,
|
||||||
|
y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
|
||||||
|
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markLine: markLineData.length > 0 ? { symbol: ['none', 'none'], data: markLineData } : undefined,
|
||||||
|
// 添加满足条件的价差区域(浅绿色背景)
|
||||||
|
markArea: (() => {
|
||||||
|
if (openBtcNum == null) return undefined
|
||||||
|
const areas: Array<[{ yAxis: number }, { yAxis: number }]> = []
|
||||||
|
if (isMaxSpread) {
|
||||||
|
// 最大价差:价差 <= 配置值触发,满足条件为靠近开盘价的带状区域
|
||||||
|
if (minSpreadUp != null && !Number.isNaN(minSpreadUp)) {
|
||||||
|
areas.push([
|
||||||
|
{ yAxis: openBtcNum },
|
||||||
|
{ yAxis: openBtcNum + minSpreadUp }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (minSpreadDown != null && !Number.isNaN(minSpreadDown)) {
|
||||||
|
areas.push([
|
||||||
|
{ yAxis: openBtcNum - minSpreadDown },
|
||||||
|
{ yAxis: openBtcNum }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 最小价差:价差 >= 配置值触发,满足条件为远离开盘价的两侧
|
||||||
|
if (minSpreadUp != null && !Number.isNaN(minSpreadUp)) {
|
||||||
|
areas.push([
|
||||||
|
{ yAxis: openBtcNum + minSpreadUp },
|
||||||
|
{ yAxis: yMax ?? openBtcNum + minSpreadUp * 2 }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (minSpreadDown != null && !Number.isNaN(minSpreadDown)) {
|
||||||
|
areas.push([
|
||||||
|
{ yAxis: yMin ?? openBtcNum - minSpreadDown * 2 },
|
||||||
|
{ yAxis: openBtcNum - minSpreadDown }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return areas.length > 0 ? {
|
||||||
|
silent: true,
|
||||||
|
data: areas,
|
||||||
|
itemStyle: { color: 'rgba(82, 196, 26, 0.12)' }
|
||||||
|
} : undefined
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance.current.setOption(option, true)
|
||||||
|
chartInstance.current.resize()
|
||||||
|
}, [priceHistory, initData, pushData, firstDataTime, hasSwitchedPeriod, t])
|
||||||
|
|
||||||
|
// 更新市场分时图:Polymarket 价格 0-1
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initData) return
|
||||||
|
if (marketChartRef.current && !marketChartInstance.current) {
|
||||||
|
marketChartInstance.current = echarts.init(marketChartRef.current)
|
||||||
|
}
|
||||||
|
if (!marketChartInstance.current) return
|
||||||
|
|
||||||
|
const periodStartMs = (initData.periodStartUnix ?? 0) * 1000
|
||||||
|
const periodEndMs = periodStartMs + (initData.intervalSeconds ?? 300) * 1000
|
||||||
|
|
||||||
|
// data.timestamp 为毫秒,firstDataTime 已是 ms,无需再乘 1000
|
||||||
|
const firstDataMs = firstDataTime != null ? firstDataTime : null
|
||||||
|
const isMidEntry = firstDataMs != null && !hasSwitchedPeriod && firstDataMs > periodStartMs
|
||||||
|
const xAxisMin = isMidEntry ? firstDataMs : periodStartMs
|
||||||
|
|
||||||
|
const toMs = (t: number) => (t > 0 && t < 1e12 ? t * 1000 : t)
|
||||||
|
let marketUpData: [number, number | null][] = priceHistory.length > 0
|
||||||
|
? priceHistory.map(p => [toMs(p.time), p.marketPriceUp])
|
||||||
|
: []
|
||||||
|
let marketDownData: [number, number | null][] = priceHistory.length > 0
|
||||||
|
? priceHistory.map(p => [toMs(p.time), p.marketPriceDown])
|
||||||
|
: []
|
||||||
|
|
||||||
|
// 若推送有最新价且与当前周期一致,追加到末端使曲线显示到最新价格
|
||||||
|
if (pushData && pushData.periodStartUnix === (initData.periodStartUnix ?? 0)) {
|
||||||
|
const ts = pushData.timestamp
|
||||||
|
const lastTime = marketUpData.length > 0 ? marketUpData[marketUpData.length - 1][0] : 0
|
||||||
|
const tsMs = ts > 0 && ts < 1e12 ? ts * 1000 : ts
|
||||||
|
if (tsMs >= lastTime) {
|
||||||
|
const up = pushData.currentPriceUp != null && pushData.currentPriceUp !== '' ? parseFloat(pushData.currentPriceUp) : null
|
||||||
|
const down = pushData.currentPriceDown != null && pushData.currentPriceDown !== '' ? parseFloat(pushData.currentPriceDown) : null
|
||||||
|
const upVal = up != null && !Number.isNaN(up) ? up : (down != null && !Number.isNaN(down) ? 1 - down : null)
|
||||||
|
const downVal = down != null && !Number.isNaN(down) ? down : (up != null && !Number.isNaN(up) ? 1 - up : null)
|
||||||
|
if (upVal != null) marketUpData = [...marketUpData, [tsMs, upVal]]
|
||||||
|
if (downVal != null) marketDownData = [...marketDownData, [tsMs, downVal]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minPrice = parseFloat(initData.minPrice)
|
||||||
|
const maxPrice = parseFloat(initData.maxPrice)
|
||||||
|
const midPrice = (minPrice + maxPrice) / 2
|
||||||
|
const isValid = (v: number | null): v is number => v != null && !Number.isNaN(v)
|
||||||
|
const validUp: [number, number][] = marketUpData.filter((point): point is [number, number] => isValid(point[1]))
|
||||||
|
const validDown: [number, number][] = marketDownData.filter((point): point is [number, number] => isValid(point[1]))
|
||||||
|
const hasAnyMarketData = validUp.length > 0 || validDown.length > 0
|
||||||
|
const placeholderTime = xAxisMin
|
||||||
|
const finalMarketUp: [number, number][] = hasAnyMarketData ? validUp : [[placeholderTime, midPrice]]
|
||||||
|
const finalMarketDown: [number, number][] = hasAnyMarketData ? validDown : [[placeholderTime, midPrice]]
|
||||||
|
|
||||||
|
const periodStartUnixSec = initData.periodStartUnix ?? 0
|
||||||
|
const windowStartMs = periodStartMs + (initData.windowStartSeconds ?? 0) * 1000
|
||||||
|
const windowEndMs = periodStartMs + (initData.windowEndSeconds ?? 0) * 1000
|
||||||
|
const timeWindowMarkLine: Array<{ xAxis: number; lineStyle: { type: 'dashed'; color: string }; label: { show: boolean }; emphasis: { label: { show: boolean; formatter: string } } }> = []
|
||||||
|
if (windowStartMs > xAxisMin && windowStartMs < periodEndMs) {
|
||||||
|
timeWindowMarkLine.push({
|
||||||
|
xAxis: windowStartMs,
|
||||||
|
lineStyle: { type: 'dashed', color: 'rgba(128, 128, 128, 0.9)' },
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: { label: { show: true, formatter: t('cryptoTailMonitor.chart.timeWindowStart') } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (windowEndMs > xAxisMin && windowEndMs < periodEndMs && windowEndMs !== windowStartMs) {
|
||||||
|
timeWindowMarkLine.push({
|
||||||
|
xAxis: windowEndMs,
|
||||||
|
lineStyle: { type: 'dashed', color: 'rgba(128, 128, 128, 0.9)' },
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: { label: { show: true, formatter: t('cryptoTailMonitor.chart.timeWindowEnd') } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const option: EChartsOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: unknown) => {
|
||||||
|
const arr = params as Array<{ seriesName: string; name: string | number; value: number | [number, number]; axisValue?: number }>
|
||||||
|
const upParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.marketUp'))
|
||||||
|
const downParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.marketDown'))
|
||||||
|
const firstParam = arr[0]
|
||||||
|
const rawTime = firstParam && Array.isArray(firstParam.value)
|
||||||
|
? firstParam.value[0]
|
||||||
|
: (firstParam?.axisValue ?? firstParam?.name)
|
||||||
|
let timeStr = ''
|
||||||
|
if (typeof rawTime === 'number' && !Number.isNaN(rawTime)) {
|
||||||
|
const offsetSec = Math.floor(rawTime / 1000) - periodStartUnixSec
|
||||||
|
const mins = Math.floor(offsetSec / 60)
|
||||||
|
const secs = Math.abs(offsetSec) % 60
|
||||||
|
timeStr = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
} else if (rawTime != null && rawTime !== '') {
|
||||||
|
timeStr = String(rawTime)
|
||||||
|
} else {
|
||||||
|
timeStr = '--'
|
||||||
|
}
|
||||||
|
let html = `<div><div>${t('cryptoTailMonitor.chart.time')}: ${timeStr}</div>`
|
||||||
|
const upVal = Array.isArray(upParam?.value) ? upParam?.value[1] : upParam?.value
|
||||||
|
const downVal = Array.isArray(downParam?.value) ? downParam?.value[1] : downParam?.value
|
||||||
|
if (upVal != null && !Number.isNaN(upVal)) html += `<div>Up: ${Number(upVal).toFixed(4)}</div>`
|
||||||
|
if (downVal != null && !Number.isNaN(downVal)) html += `<div>Down: ${Number(downVal).toFixed(4)}</div>`
|
||||||
|
html += '</div>'
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: true,
|
||||||
|
top: 0,
|
||||||
|
data: [t('cryptoTailMonitor.chart.marketUp'), t('cryptoTailMonitor.chart.marketDown')]
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
top: '15%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'time',
|
||||||
|
min: xAxisMin,
|
||||||
|
max: periodEndMs,
|
||||||
|
axisLabel: {
|
||||||
|
formatter: (val: number) => {
|
||||||
|
const offsetSec = Math.floor(val / 1000) - periodStartUnixSec
|
||||||
|
const mins = Math.floor(offsetSec / 60)
|
||||||
|
const secs = Math.abs(offsetSec) % 60
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
interval: 0.2,
|
||||||
|
axisLabel: { formatter: (v: number) => v.toFixed(1) }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: t('cryptoTailMonitor.chart.marketUp'),
|
||||||
|
type: 'line',
|
||||||
|
data: finalMarketUp,
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
showSymbol: true,
|
||||||
|
connectNulls: true,
|
||||||
|
lineStyle: { width: 2, color: '#1890ff' },
|
||||||
|
itemStyle: { color: '#1890ff' },
|
||||||
|
markArea: {
|
||||||
|
silent: true,
|
||||||
|
itemStyle: { color: 'rgba(82, 196, 26, 0.12)' },
|
||||||
|
data: [[{ yAxis: minPrice }, { yAxis: maxPrice }]]
|
||||||
|
},
|
||||||
|
markLine: timeWindowMarkLine.length > 0 ? { symbol: ['none', 'none'], data: timeWindowMarkLine } : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('cryptoTailMonitor.chart.marketDown'),
|
||||||
|
type: 'line',
|
||||||
|
data: finalMarketDown,
|
||||||
|
smooth: true,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
showSymbol: true,
|
||||||
|
connectNulls: true,
|
||||||
|
lineStyle: { width: 2, color: '#fa8c16' },
|
||||||
|
itemStyle: { color: '#fa8c16' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
marketChartInstance.current.setOption(option, true)
|
||||||
|
marketChartInstance.current.resize()
|
||||||
|
}, [priceHistory, initData, pushData, firstDataTime, hasSwitchedPeriod, t])
|
||||||
|
|
||||||
|
// 格式化剩余时间
|
||||||
|
const formatRemainingTime = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示 BTC 价格(最新价、价差、开盘价均为 USDC)
|
||||||
|
const openPrice = pushData?.openPriceBtc ?? initData?.openPriceBtc
|
||||||
|
const currentPrice = pushData?.currentPriceBtc
|
||||||
|
const currentSpread = pushData?.spreadBtc
|
||||||
|
const minSpreadUpStr = pushData?.minSpreadLineUp ?? initData?.autoMinSpreadUp
|
||||||
|
const minSpreadDownStr = pushData?.minSpreadLineDown ?? initData?.autoMinSpreadDown
|
||||||
|
const minSpreadUpVal = minSpreadUpStr != null && minSpreadUpStr !== '' ? parseFloat(minSpreadUpStr) : null
|
||||||
|
const minSpreadDownVal = minSpreadDownStr != null && minSpreadDownStr !== '' ? parseFloat(minSpreadDownStr) : null
|
||||||
|
const minSpreadLineNum = [minSpreadUpVal, minSpreadDownVal].filter((v): v is number => v != null && !Number.isNaN(v))
|
||||||
|
const spreadBelowThreshold = currentSpread != null && currentSpread !== '' && minSpreadLineNum.length > 0 &&
|
||||||
|
parseFloat(currentSpread) < Math.min(...minSpreadLineNum)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: isMobile ? 12 : 24 }}>
|
||||||
|
<Title level={2} style={{ marginBottom: 16, fontSize: isMobile ? 20 : 24 }}>
|
||||||
|
{t('cryptoTailMonitor.title')}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{/* 顶部控制区 */}
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<Space wrap size="middle">
|
||||||
|
<Space>
|
||||||
|
<Text strong>{t('cryptoTailMonitor.selectStrategy')}</Text>
|
||||||
|
<Select
|
||||||
|
style={{ minWidth: isMobile ? 200 : 300 }}
|
||||||
|
loading={strategiesLoading}
|
||||||
|
value={selectedStrategyId}
|
||||||
|
onChange={(id) => setSelectedStrategyId(id)}
|
||||||
|
placeholder={t('cryptoTailMonitor.selectStrategyPlaceholder')}
|
||||||
|
options={strategies.map(s => ({
|
||||||
|
label: `${s.name || s.marketSlugPrefix} (${s.intervalSeconds === 300 ? '5m' : '15m'})`,
|
||||||
|
value: s.id
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
{selectedStrategyId && (
|
||||||
|
<Space>
|
||||||
|
<Text strong>{t('cryptoTailMonitor.periodSwitch.mode')}</Text>
|
||||||
|
<Radio.Group
|
||||||
|
value={periodSwitchMode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newMode = e.target.value
|
||||||
|
setPeriodSwitchMode(newMode)
|
||||||
|
localStorage.setItem(PERIOD_SWITCH_MODE_KEY, newMode)
|
||||||
|
if (newMode === 'auto' && isViewingOldPeriod && pendingPeriodData) {
|
||||||
|
handleSwitchToLatestPeriod()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Tooltip title={t('cryptoTailMonitor.periodSwitch.autoDesc')}>
|
||||||
|
<Radio.Button value="auto">{t('cryptoTailMonitor.periodSwitch.auto')}</Radio.Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t('cryptoTailMonitor.periodSwitch.manualDesc')}>
|
||||||
|
<Radio.Button value="manual">{t('cryptoTailMonitor.periodSwitch.manual')}</Radio.Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Radio.Group>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{initLoading ? (
|
||||||
|
<Spin spinning style={{ display: 'flex', justifyContent: 'center', padding: 100 }} />
|
||||||
|
) : !initData ? (
|
||||||
|
<Empty description={t('cryptoTailMonitor.noData')} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 状态卡片:最小宽度填满整行,间距 16 */}
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col flex="1" style={{ minWidth: 140 }}>
|
||||||
|
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailMonitor.stat.openPrice')}
|
||||||
|
value={openPrice ? formatNumber(openPrice, 2) : '-'}
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col flex="1" style={{ minWidth: 140 }}>
|
||||||
|
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailMonitor.stat.currentPrice')}
|
||||||
|
value={currentPrice ? formatNumber(currentPrice, 2) : '-'}
|
||||||
|
precision={2}
|
||||||
|
valueStyle={{ color: isMobile ? undefined : '#1890ff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col flex="1" style={{ minWidth: 140 }}>
|
||||||
|
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailMonitor.stat.spread')}
|
||||||
|
value={(() => {
|
||||||
|
if (currentSpread == null || currentSpread === '') return '-'
|
||||||
|
const num = parseFloat(currentSpread)
|
||||||
|
if (Number.isNaN(num)) return '-'
|
||||||
|
const formatted = formatNumber(currentSpread, 2)
|
||||||
|
return num >= 0 ? `+${formatted}` : formatted
|
||||||
|
})()}
|
||||||
|
precision={2}
|
||||||
|
valueStyle={{
|
||||||
|
color: spreadBelowThreshold ? '#ff4d4f' : undefined
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col flex="1" style={{ minWidth: 140 }}>
|
||||||
|
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||||
|
<Statistic
|
||||||
|
title={t('cryptoTailMonitor.stat.remainingTime')}
|
||||||
|
value={pushData ? formatRemainingTime(pushData.remainingSeconds) : '-'}
|
||||||
|
prefix={<ClockCircleOutlined />}
|
||||||
|
valueStyle={{
|
||||||
|
color: pushData && pushData.remainingSeconds < 60 ? '#ff4d4f' : undefined
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col flex="1" style={{ minWidth: 140 }}>
|
||||||
|
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||||
|
<Statistic
|
||||||
|
title={(initData.spreadDirection ?? 'MIN') === 'MAX' ? t('cryptoTailMonitor.stat.configuredSpreadMax') : t('cryptoTailMonitor.stat.configuredSpreadMin')}
|
||||||
|
valueRender={() => {
|
||||||
|
const mode = initData.minSpreadMode ?? 'NONE'
|
||||||
|
if (mode === 'NONE') return <Text type="secondary">-</Text>
|
||||||
|
if (mode === 'FIXED') {
|
||||||
|
const v = initData.minSpreadValue
|
||||||
|
return v != null && v !== '' ? formatNumber(v, 2) : '-'
|
||||||
|
}
|
||||||
|
const up = minSpreadUpStr != null && minSpreadUpStr !== '' ? formatNumber(minSpreadUpStr, 2) : null
|
||||||
|
const down = minSpreadDownStr != null && minSpreadDownStr !== '' ? formatNumber(minSpreadDownStr, 2) : null
|
||||||
|
if (up == null && down == null) return <Text type="secondary">-</Text>
|
||||||
|
return (
|
||||||
|
<Text style={{ fontSize: 13, lineHeight: 1.4 }}>
|
||||||
|
{up != null && <span style={{ display: 'block' }}>Up: {up}</span>}
|
||||||
|
{down != null && <span style={{ display: 'block' }}>Down: {down}</span>}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 手动模式下:周期结束提示 */}
|
||||||
|
{periodSwitchMode === 'manual' && isViewingOldPeriod && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message={t('cryptoTailMonitor.periodSwitch.periodEnded')}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size="small">
|
||||||
|
<Text>{t('cryptoTailMonitor.periodSwitch.newPeriodAvailable')}</Text>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
onClick={handleSwitchToLatestPeriod}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{t('cryptoTailMonitor.periodSwitch.switchToLatest')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 价格区间提示 */}
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message={`${t('cryptoTailMonitor.priceRange')}: ${formatNumber(initData.minPrice, 2)} ~ ${formatNumber(initData.maxPrice, 2)} | ${t('cryptoTailMonitor.timeWindow')}: ${Math.floor(initData.windowStartSeconds / 60)}:${(initData.windowStartSeconds % 60).toString().padStart(2, '0')} ~ ${Math.floor(initData.windowEndSeconds / 60)}:${(initData.windowEndSeconds % 60).toString().padStart(2, '0')}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 价格分时图 */}
|
||||||
|
<Card title={`${initData.marketSlugPrefix || 'BTC'} ${t('cryptoTailMonitor.chart.priceChart')}`}>
|
||||||
|
<div
|
||||||
|
ref={chartRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: isMobile ? 200 : 240
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 市场分时图 */}
|
||||||
|
<Card
|
||||||
|
title={t('cryptoTailMonitor.chart.marketTitle')}
|
||||||
|
extra={
|
||||||
|
pushData?.currentPriceUp != null || pushData?.currentPriceDown != null ? (
|
||||||
|
<Space size="middle">
|
||||||
|
<Text type="secondary">{t('cryptoTailMonitor.chart.latestPrice')}:</Text>
|
||||||
|
{pushData.currentPriceUp != null && pushData.currentPriceUp !== '' && (
|
||||||
|
<Text>Up {formatNumber(pushData.currentPriceUp, 4)}</Text>
|
||||||
|
)}
|
||||||
|
{pushData.currentPriceDown != null && pushData.currentPriceDown !== '' && (
|
||||||
|
<Text>Down {formatNumber(pushData.currentPriceDown, 4)}</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={marketChartRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: isMobile ? 200 : 240
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 策略信息 */}
|
||||||
|
<Card title={t('cryptoTailMonitor.strategyInfo.title')} style={{ marginTop: 16 }}>
|
||||||
|
<Row gutter={[16, 8]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.market')}: </Text>
|
||||||
|
<Text>{pushData?.marketTitle ?? initData.marketTitle}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.interval')}: </Text>
|
||||||
|
<Text>{initData.intervalSeconds === 300 ? '5m' : '15m'}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.account')}: </Text>
|
||||||
|
<Text>{initData.accountName || `#${initData.accountId}`}</Text>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadMode')}: </Text>
|
||||||
|
<Text>{initData.minSpreadMode}</Text>
|
||||||
|
{initData.minSpreadMode === 'FIXED' && initData.minSpreadValue && (
|
||||||
|
<Text> ({formatNumber(initData.minSpreadValue, 4)})</Text>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadDirection')}: </Text>
|
||||||
|
<Text>{(initData.spreadDirection ?? 'MIN') === 'MAX' ? t('cryptoTailMonitor.stat.configuredSpreadMax') : t('cryptoTailMonitor.stat.configuredSpreadMin')}</Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CryptoTailMonitor
|
||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, ArrowUpOutlined, ArrowDownOutlined, FileTextOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, UnorderedListOutlined, InfoCircleOutlined, WarningOutlined, CalendarOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMediaQuery } from 'react-responsive'
|
import { useMediaQuery } from 'react-responsive'
|
||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
@@ -555,7 +555,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
{t('cryptoTailStrategy.list.configGuide')}
|
{t('cryptoTailStrategy.list.configGuide')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{binanceUnhealthy.length > 0 && (
|
{binanceUnhealthy.length > 0 && list.some((s) => s.enabled) && (
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
@@ -960,9 +960,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
render: (i: number) =>
|
render: (i: number) =>
|
||||||
i === 0 ? (
|
i === 0 ? (
|
||||||
<Tag icon={<ArrowUpOutlined />} color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
<Tag color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag icon={<ArrowDownOutlined />} color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
<Tag color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1035,9 +1035,9 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
render: (i: number) =>
|
render: (i: number) =>
|
||||||
i === 0 ? (
|
i === 0 ? (
|
||||||
<Tag icon={<ArrowUpOutlined />} color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
<Tag color="green">{t('cryptoTailStrategy.triggerRecords.up')}</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag icon={<ArrowDownOutlined />} color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
<Tag color="volcano">{t('cryptoTailStrategy.triggerRecords.down')}</Tag>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ExclamationCircleOutlined
|
ExclamationCircleOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { apiClient } from '../services/api'
|
import { apiClient } from '../services/api'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
@@ -29,13 +30,14 @@ interface UpdateStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SystemUpdate: React.FC = () => {
|
const SystemUpdate: React.FC = () => {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
const [currentVersion, setCurrentVersion] = useState('')
|
const [currentVersion, setCurrentVersion] = useState('')
|
||||||
const [updateChecking, setUpdateChecking] = useState(false)
|
const [updateChecking, setUpdateChecking] = useState(false)
|
||||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({
|
||||||
updating: false,
|
updating: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
message: '就绪',
|
message: '',
|
||||||
error: null
|
error: null
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
setUpdateStatus({
|
setUpdateStatus({
|
||||||
updating: response.data.data.updating,
|
updating: response.data.data.updating,
|
||||||
progress: response.data.data.progress || 0,
|
progress: response.data.data.progress || 0,
|
||||||
message: response.data.data.message || '就绪',
|
message: response.data.data.message || '',
|
||||||
error: response.data.data.error || null
|
error: response.data.data.error || null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -83,15 +85,15 @@ const SystemUpdate: React.FC = () => {
|
|||||||
setUpdateInfo(data.data)
|
setUpdateInfo(data.data)
|
||||||
|
|
||||||
if (data.data.hasUpdate) {
|
if (data.data.hasUpdate) {
|
||||||
message.success(`发现新版本: ${data.data.latestVersion}`)
|
message.success(t('systemUpdate.hasNewVersion', { version: data.data.latestVersion }))
|
||||||
} else {
|
} else {
|
||||||
message.info('当前已是最新版本')
|
message.info(t('systemUpdate.alreadyLatest'))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
message.error(data.message || '检查更新失败')
|
message.error(data.message || t('systemUpdate.checkFailed'))
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || '检查更新失败')
|
message.error(error.message || t('systemUpdate.checkFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setUpdateChecking(false)
|
setUpdateChecking(false)
|
||||||
}
|
}
|
||||||
@@ -99,25 +101,25 @@ const SystemUpdate: React.FC = () => {
|
|||||||
|
|
||||||
const handleExecuteUpdate = () => {
|
const handleExecuteUpdate = () => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认更新',
|
title: t('systemUpdate.confirmTitle'),
|
||||||
icon: <ExclamationCircleOutlined />,
|
icon: <ExclamationCircleOutlined />,
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
<p>确定要更新到版本 <strong>{updateInfo?.latestVersion}</strong> 吗?</p>
|
<p>{t('systemUpdate.confirmContent1', { version: updateInfo?.latestVersion })}</p>
|
||||||
<p>更新过程中系统将暂时不可用(约30-60秒)。</p>
|
<p>{t('systemUpdate.confirmContent2')}</p>
|
||||||
<p>更新完成后页面将自动刷新。</p>
|
<p>{t('systemUpdate.confirmContent3')}</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
okText: '立即更新',
|
okText: t('systemUpdate.okText'),
|
||||||
okType: 'primary',
|
okType: 'primary',
|
||||||
cancelText: '取消',
|
cancelText: t('systemUpdate.cancelText'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/update/update', {})
|
const response = await apiClient.post('/update/update', {})
|
||||||
const data = response.data
|
const data = response.data
|
||||||
|
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
message.success('更新已启动,请稍候...')
|
message.success(t('systemUpdate.updateStarted'))
|
||||||
|
|
||||||
// 开始轮询更新状态
|
// 开始轮询更新状态
|
||||||
const pollInterval = setInterval(async () => {
|
const pollInterval = setInterval(async () => {
|
||||||
@@ -138,9 +140,9 @@ const SystemUpdate: React.FC = () => {
|
|||||||
clearInterval(pollInterval)
|
clearInterval(pollInterval)
|
||||||
|
|
||||||
if (statusData.data.error) {
|
if (statusData.data.error) {
|
||||||
message.error(`更新失败: ${statusData.data.error}`)
|
message.error(t('systemUpdate.updateFailedWithMessage', { message: statusData.data.error }))
|
||||||
} else if (statusData.data.progress === 100) {
|
} else if (statusData.data.progress === 100) {
|
||||||
message.success('更新成功!页面将在3秒后刷新...')
|
message.success(t('systemUpdate.updateSuccessRefresh'))
|
||||||
setTimeout(() => window.location.reload(), 3000)
|
setTimeout(() => window.location.reload(), 3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,19 +155,19 @@ const SystemUpdate: React.FC = () => {
|
|||||||
// 5分钟后停止轮询
|
// 5分钟后停止轮询
|
||||||
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000)
|
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000)
|
||||||
} else if (data.code === 403) {
|
} else if (data.code === 403) {
|
||||||
message.error('需要管理员权限才能执行更新')
|
message.error(t('systemUpdate.needAdmin'))
|
||||||
} else {
|
} else {
|
||||||
message.error(data.message || '启动更新失败')
|
message.error(data.message || t('systemUpdate.startFailed'))
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || '启动更新失败')
|
message.error(error.message || t('systemUpdate.startFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString('zh-CN')
|
return new Date(dateString).toLocaleString(i18n.language === 'zh-CN' ? 'zh-CN' : i18n.language === 'zh-TW' ? 'zh-TW' : 'en')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -173,7 +175,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<CloudUploadOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
<CloudUploadOutlined style={{ fontSize: '18px', color: '#1890ff' }} />
|
||||||
<span style={{ fontSize: '16px', fontWeight: 600 }}>系统更新</span>
|
<span style={{ fontSize: '16px', fontWeight: 600 }}>{t('systemUpdate.title')}</span>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
@@ -195,7 +197,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
}}>
|
}}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '13px', opacity: 0.9, marginBottom: '4px' }}>
|
<div style={{ fontSize: '13px', opacity: 0.9, marginBottom: '4px' }}>
|
||||||
当前版本
|
{t('systemUpdate.currentVersion')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '20px', fontWeight: 600 }}>
|
<div style={{ fontSize: '20px', fontWeight: 600 }}>
|
||||||
v{currentVersion || 'unknown'}
|
v{currentVersion || 'unknown'}
|
||||||
@@ -208,16 +210,16 @@ const SystemUpdate: React.FC = () => {
|
|||||||
{updateStatus.updating && (
|
{updateStatus.updating && (
|
||||||
<Alert
|
<Alert
|
||||||
message={
|
message={
|
||||||
<span style={{ fontSize: '15px', fontWeight: 500 }}>系统正在更新</span>
|
<span style={{ fontSize: '15px', fontWeight: 500 }}>{t('systemUpdate.updating')}</span>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<div style={{ marginTop: '12px' }}>
|
<div style={{ marginTop: '12px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#595959'
|
color: '#595959'
|
||||||
}}>
|
}}>
|
||||||
{updateStatus.message}
|
{updateStatus.message || t('systemUpdate.ready')}
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
percent={updateStatus.progress}
|
percent={updateStatus.progress}
|
||||||
@@ -245,7 +247,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
|
|
||||||
{updateStatus.error && (
|
{updateStatus.error && (
|
||||||
<Alert
|
<Alert
|
||||||
message={<span style={{ fontSize: '15px', fontWeight: 500 }}>更新失败</span>}
|
message={<span style={{ fontSize: '15px', fontWeight: 500 }}>{t('systemUpdate.updateFailedTitle')}</span>}
|
||||||
description={
|
description={
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: '8px',
|
marginTop: '8px',
|
||||||
@@ -281,14 +283,14 @@ const SystemUpdate: React.FC = () => {
|
|||||||
boxShadow: '0 2px 4px rgba(24, 144, 255, 0.2)'
|
boxShadow: '0 2px 4px rgba(24, 144, 255, 0.2)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
检查更新
|
{t('systemUpdate.checkUpdate')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{updateInfo && !updateInfo.hasUpdate && (
|
{updateInfo && !updateInfo.hasUpdate && (
|
||||||
<Alert
|
<Alert
|
||||||
message={
|
message={
|
||||||
<span style={{ fontSize: '15px', fontWeight: 500 }}>
|
<span style={{ fontSize: '15px', fontWeight: 500 }}>
|
||||||
当前已是最新版本
|
{t('systemUpdate.alreadyLatest')}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
type="success"
|
type="success"
|
||||||
@@ -326,7 +328,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
color: '#8c8c8c',
|
color: '#8c8c8c',
|
||||||
marginBottom: '6px'
|
marginBottom: '6px'
|
||||||
}}>
|
}}>
|
||||||
发现新版本
|
{t('systemUpdate.newVersionFound')}
|
||||||
</div>
|
</div>
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<Tag
|
<Tag
|
||||||
@@ -341,15 +343,15 @@ const SystemUpdate: React.FC = () => {
|
|||||||
v{updateInfo.latestVersion}
|
v{updateInfo.latestVersion}
|
||||||
</Tag>
|
</Tag>
|
||||||
{updateInfo.prerelease && (
|
{updateInfo.prerelease && (
|
||||||
<Tag
|
<Tag
|
||||||
color="orange"
|
color="orange"
|
||||||
style={{
|
style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
padding: '4px 12px',
|
padding: '4px 12px',
|
||||||
borderRadius: '4px'
|
borderRadius: '4px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pre-release
|
{t('systemUpdate.prerelease')}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -367,7 +369,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
color: '#8c8c8c',
|
color: '#8c8c8c',
|
||||||
marginBottom: '4px'
|
marginBottom: '4px'
|
||||||
}}>
|
}}>
|
||||||
发布时间
|
{t('systemUpdate.publishedAt')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
@@ -385,7 +387,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
fontWeight: 500
|
fontWeight: 500
|
||||||
}}>
|
}}>
|
||||||
更新内容
|
{t('systemUpdate.releaseNotes')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
@@ -449,7 +451,7 @@ const SystemUpdate: React.FC = () => {
|
|||||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)'
|
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.4)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
立即升级到 v{updateInfo.latestVersion}
|
{t('systemUpdate.upgradeNow', { version: updateInfo.latestVersion })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -458,20 +460,20 @@ const SystemUpdate: React.FC = () => {
|
|||||||
{!updateStatus.updating && !(updateInfo && updateInfo.hasUpdate) && (
|
{!updateStatus.updating && !(updateInfo && updateInfo.hasUpdate) && (
|
||||||
<Alert
|
<Alert
|
||||||
message={
|
message={
|
||||||
<span style={{ fontSize: '15px', fontWeight: 500 }}>使用说明</span>
|
<span style={{ fontSize: '15px', fontWeight: 500 }}>{t('systemUpdate.usageTitle')}</span>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<ul style={{
|
<ul style={{
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
paddingLeft: '20px',
|
paddingLeft: '20px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#595959',
|
color: '#595959',
|
||||||
lineHeight: '1.8'
|
lineHeight: '1.8'
|
||||||
}}>
|
}}>
|
||||||
<li>点击"检查更新"按钮检查是否有新版本</li>
|
<li>{t('systemUpdate.usage1')}</li>
|
||||||
<li>更新过程约需30-60秒,期间系统将暂时不可用</li>
|
<li>{t('systemUpdate.usage2')}</li>
|
||||||
<li>更新成功后页面将自动刷新</li>
|
<li>{t('systemUpdate.usage3')}</li>
|
||||||
<li>如果更新失败,系统会自动回滚到当前版本</li>
|
<li>{t('systemUpdate.usage4')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
type="info"
|
type="info"
|
||||||
|
|||||||
@@ -446,7 +446,7 @@ export const apiService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略 API
|
* 加密价差策略 API
|
||||||
*/
|
*/
|
||||||
cryptoTailStrategy: {
|
cryptoTailStrategy: {
|
||||||
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
|
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
|
||||||
@@ -497,7 +497,9 @@ export const apiService = {
|
|||||||
marketOptions: () =>
|
marketOptions: () =>
|
||||||
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
|
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
|
||||||
autoMinSpread: (data: { intervalSeconds: number }) =>
|
autoMinSpread: (data: { intervalSeconds: number }) =>
|
||||||
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data)
|
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data),
|
||||||
|
monitorInit: (strategyId: number) =>
|
||||||
|
apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@ export interface BacktestTaskDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略
|
* 加密价差策略
|
||||||
*/
|
*/
|
||||||
export interface CryptoTailStrategyDto {
|
export interface CryptoTailStrategyDto {
|
||||||
id: number
|
id: number
|
||||||
@@ -1076,7 +1076,7 @@ export interface CryptoTailAutoMinSpreadResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略触发记录
|
* 加密价差策略触发记录
|
||||||
*/
|
*/
|
||||||
export interface CryptoTailStrategyTriggerDto {
|
export interface CryptoTailStrategyTriggerDto {
|
||||||
id: number
|
id: number
|
||||||
@@ -1098,7 +1098,7 @@ export interface CryptoTailStrategyTriggerDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尾盘策略市场选项
|
* 加密价差策略市场选项
|
||||||
*/
|
*/
|
||||||
export interface CryptoTailMarketOptionDto {
|
export interface CryptoTailMarketOptionDto {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -1107,3 +1107,99 @@ export interface CryptoTailMarketOptionDto {
|
|||||||
periodStartUnix: number
|
periodStartUnix: number
|
||||||
endDate?: string
|
endDate?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控初始化响应
|
||||||
|
*/
|
||||||
|
export interface CryptoTailMonitorInitResponse {
|
||||||
|
/** 策略ID */
|
||||||
|
strategyId: number
|
||||||
|
/** 策略名称 */
|
||||||
|
name: string
|
||||||
|
/** 账户ID */
|
||||||
|
accountId: number
|
||||||
|
/** 账户名称 */
|
||||||
|
accountName: string
|
||||||
|
/** 市场 slug 前缀 */
|
||||||
|
marketSlugPrefix: string
|
||||||
|
/** 市场标题 */
|
||||||
|
marketTitle: string
|
||||||
|
/** 周期秒数 (300=5m, 900=15m) */
|
||||||
|
intervalSeconds: number
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
periodStartUnix: number
|
||||||
|
/** 时间窗口开始秒数 */
|
||||||
|
windowStartSeconds: number
|
||||||
|
/** 时间窗口结束秒数 */
|
||||||
|
windowEndSeconds: number
|
||||||
|
/** 最低价格 */
|
||||||
|
minPrice: string
|
||||||
|
/** 最高价格 */
|
||||||
|
maxPrice: string
|
||||||
|
/** 最小价差模式: NONE, FIXED, AUTO */
|
||||||
|
minSpreadMode: string
|
||||||
|
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
|
||||||
|
spreadDirection?: string
|
||||||
|
/** 最小价差数值 (FIXED 时有值) */
|
||||||
|
minSpreadValue?: string
|
||||||
|
/** 自动计算的最小价差 (Up方向) */
|
||||||
|
autoMinSpreadUp?: string
|
||||||
|
/** 自动计算的最小价差 (Down方向) */
|
||||||
|
autoMinSpreadDown?: string
|
||||||
|
/** BTC 开盘价 USDC(来自币安 K 线) */
|
||||||
|
openPriceBtc?: string
|
||||||
|
/** Up tokenId */
|
||||||
|
tokenIdUp?: string
|
||||||
|
/** Down tokenId */
|
||||||
|
tokenIdDown?: string
|
||||||
|
/** 当前时间 (毫秒时间戳) */
|
||||||
|
currentTimestamp: number
|
||||||
|
/** 是否启用 */
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控实时推送数据
|
||||||
|
*/
|
||||||
|
export interface CryptoTailMonitorPushData {
|
||||||
|
/** 策略ID */
|
||||||
|
strategyId: number
|
||||||
|
/** 推送时间 (毫秒时间戳) */
|
||||||
|
timestamp: number
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
periodStartUnix: number
|
||||||
|
/** 当前周期市场标题(周期切换时更新) */
|
||||||
|
marketTitle?: string
|
||||||
|
/** 当前价格 (Up方向,来自订单簿) */
|
||||||
|
currentPriceUp?: string
|
||||||
|
/** 当前价格 (Down方向,来自订单簿) */
|
||||||
|
currentPriceDown?: string
|
||||||
|
/** 当前价差 (Up方向: 1 - currentPriceUp) */
|
||||||
|
spreadUp?: string
|
||||||
|
/** 当前价差 (Down方向: currentPriceUp) */
|
||||||
|
spreadDown?: string
|
||||||
|
/** 最小价差线 (Up方向,USDC) */
|
||||||
|
minSpreadLineUp?: string
|
||||||
|
/** 最小价差线 (Down方向,USDC) */
|
||||||
|
minSpreadLineDown?: string
|
||||||
|
/** BTC 开盘价 USDC */
|
||||||
|
openPriceBtc?: string
|
||||||
|
/** BTC 最新价 USDC */
|
||||||
|
currentPriceBtc?: string
|
||||||
|
/** BTC 价差 USDC(currentPriceBtc - openPriceBtc) */
|
||||||
|
spreadBtc?: string
|
||||||
|
/** 周期剩余秒数 */
|
||||||
|
remainingSeconds: number
|
||||||
|
/** 是否在时间窗口内 */
|
||||||
|
inTimeWindow: boolean
|
||||||
|
/** 是否在价格区间内 (Up方向) */
|
||||||
|
inPriceRangeUp: boolean
|
||||||
|
/** 是否在价格区间内 (Down方向) */
|
||||||
|
inPriceRangeDown: boolean
|
||||||
|
/** 是否已触发 */
|
||||||
|
triggered: boolean
|
||||||
|
/** 触发方向: UP, DOWN, null */
|
||||||
|
triggerDirection?: string
|
||||||
|
/** 周期是否已结束 */
|
||||||
|
periodEnded: boolean
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user