Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f50939625 | |||
| a64d1edbf4 | |||
| a62f1bd6e8 | |||
| 63ce4107c6 | |||
| a136d68e8c | |||
| 61a5077d4d | |||
| bd529d9041 | |||
| 5cdcc487d4 | |||
| ca2b1acbb9 | |||
| 77b681a40e | |||
| c6f9e5db61 | |||
| 2ec4ae3f98 | |||
| 5074bb4f28 | |||
| 9fef4bea59 | |||
| b50e43c239 | |||
| 7ec9311df2 | |||
| 1f6cf1ecaf | |||
| 9413507997 | |||
| bf0e52fc01 | |||
| c520eb7777 | |||
| d6d4b1200e | |||
| a1f3cfd3ff | |||
| 261dbdc23c | |||
| 4c0d0afceb | |||
| b79caceae6 | |||
| ed749fb606 | |||
| f200ba7f7c | |||
| 0efd4a5fc3 | |||
| 2238370088 | |||
| f1ec0a330b | |||
| 72de65d670 | |||
| 3405a1cda3 | |||
| fc6fa8b419 | |||
| cf2c8a611c | |||
| bd323fca35 | |||
| a067a20a02 | |||
| 99c4328830 | |||
| 1df438d18d | |||
| 366d3d1b25 | |||
| 17a6a4f314 | |||
| 8600449f69 | |||
| 3d47bd50d9 | |||
| 20df471683 | |||
| 5487c7c862 | |||
| c1829fe21b | |||
| b1ad6f02d2 | |||
| 01a1836659 | |||
| b616e10ceb | |||
| d07c4c2c5e | |||
| 7485e2ce24 | |||
| 8f52b5016a | |||
| 5c0808c2cb | |||
| 9d01c120e5 | |||
| ec8cfeac77 | |||
| 7c833f1e9b | |||
| 08cc30a90f | |||
| 8288cd7579 | |||
| 55da551971 | |||
| ff3b24b50e | |||
| 88cae4018a | |||
| 96fbc3f720 | |||
| fd25821e39 | |||
| cdd02e9f3d | |||
| fabbd81f22 | |||
| ace32b37cf | |||
| f1f809f54b | |||
| cccc829cef |
@@ -0,0 +1,39 @@
|
||||
# Cursor Skills 目录结构
|
||||
|
||||
本目录遵循 [Cursor Agent Skills 规范](https://cursor.com/cn/docs/context/skills):每个技能为一个**文件夹**,内含 `SKILL.md` 及可选的 `scripts/`、`references/`、`assets/`。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
.cursor/skills/
|
||||
├── frontend/ # 前端相关 skill 分组
|
||||
│ └── check-i18n-keys/ # 单个 skill(文件夹名 = name)
|
||||
│ ├── SKILL.md # 必填,技能定义与指令
|
||||
│ └── scripts/
|
||||
│ ├── check-i18n-keys.ts
|
||||
│ └── package.json
|
||||
├── backend/ # 后端相关 skill
|
||||
└── common/ # 通用 skill
|
||||
```
|
||||
|
||||
- **SKILL.md**:YAML frontmatter(`name`、`description` 必填,`name` 须与父文件夹名一致、小写连字符)+ 给 Agent 的详细指令。
|
||||
- **scripts/**:Agent 可执行的脚本,在 SKILL.md 中用相对路径引用。
|
||||
|
||||
## 添加新 Skill
|
||||
|
||||
1. **确定分组**:在 `frontend/`、`backend/` 或 `common/` 下新建**以技能名命名的文件夹**(仅小写、数字、连字符,如 `check-i18n-keys`)。
|
||||
2. **创建 SKILL.md**:在该文件夹内创建 `SKILL.md`(大写),frontmatter 中 `name` 必须与文件夹名一致。
|
||||
3. **可选 scripts/**:在技能文件夹内建 `scripts/`,放入可执行脚本;在 SKILL.md 正文中写明运行命令(如 `cd .cursor/skills/.../scripts && npm run xxx`)。
|
||||
|
||||
## 示例
|
||||
|
||||
- `frontend/check-i18n-keys/SKILL.md` + `frontend/check-i18n-keys/scripts/` — 检查前端多语言 key。
|
||||
|
||||
## 运行 check-i18n-keys
|
||||
|
||||
```bash
|
||||
cd .cursor/skills/frontend/check-i18n-keys/scripts
|
||||
npm install
|
||||
npm run check-i18n
|
||||
```
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: check-i18n-keys
|
||||
description: 检查前端多语言 key 完整性。当用户要求检查 i18n、多语言缺失、翻译 key 或运行 check-i18n 时使用。
|
||||
---
|
||||
|
||||
# Check I18n Keys
|
||||
|
||||
检查前端代码中使用的 i18n key 是否在所有语言文件(zh-CN、zh-TW、en)中存在,并报告缺失或语言间不一致的 key。
|
||||
|
||||
## 使用时机
|
||||
|
||||
- 用户要求「检查多语言」「检查 i18n」「扫一下 key」「多语言缺失」时
|
||||
- 用户要求运行多语言检查或执行 check-i18n 时
|
||||
- 在修改或新增前端文案后,需要确认三语言 key 一致时
|
||||
|
||||
## 指令
|
||||
|
||||
1. **运行检查脚本**(在项目仓库根目录下执行):
|
||||
```bash
|
||||
cd .cursor/skills/frontend/check-i18n-keys/scripts && npm install && npm run check-i18n
|
||||
```
|
||||
首次运行需先 `npm install`,之后可直接 `npm run check-i18n`。
|
||||
|
||||
2. **脚本行为**:
|
||||
- 扫描 `frontend/` 下所有 `.ts`、`.tsx`、`.js`、`.jsx`(排除 node_modules、dist、build、*.d.ts)
|
||||
- 提取代码中 `t('key')` / `t("key")` 的 key
|
||||
- 与 `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 对比
|
||||
- 报告:代码中使用但某语言 JSON 缺失的 key;某语言有而另一语言没有的 key(不一致)
|
||||
|
||||
3. **结果处理**:
|
||||
- 若有缺失或不一致,退出码为 1;可根据报告在对应 `common.json` 中补全 key
|
||||
- 脚本路径:`scripts/check-i18n-keys.ts`(相对本 skill 根目录)
|
||||
|
||||
## 可选目录说明
|
||||
|
||||
- `scripts/`:可执行检查脚本,Agent 按上述命令调用。
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 检查前端多语言 key 完整性
|
||||
*
|
||||
* 扫描范围:frontend 下所有 .ts / .tsx / .js / .jsx(排除 node_modules、dist、build、*.d.ts)
|
||||
*
|
||||
* 支持的检查:
|
||||
* 1. 代码引用但 JSON 没有:某 ts/js 引用了 t("common.test"),但任意语言 common.json 里没有该 key → 报缺失
|
||||
* 2. 语言间不一致:zh-CN/zh-TW 的 JSON 有 common.xxx,但 en 的没有 → 报不一致(某些语言有、某些没有)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { glob } from 'glob';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
interface CheckResult {
|
||||
usedKeys: Set<string>;
|
||||
localeKeys: {
|
||||
'zh-CN': Set<string>;
|
||||
'zh-TW': Set<string>;
|
||||
'en': Set<string>;
|
||||
};
|
||||
missingKeys: {
|
||||
'zh-CN': string[];
|
||||
'zh-TW': string[];
|
||||
'en': string[];
|
||||
};
|
||||
inconsistentKeys: Array<{
|
||||
key: string;
|
||||
existsIn: string[];
|
||||
missingIn: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
const LOCALES = ['zh-CN', 'zh-TW', 'en'] as const;
|
||||
type Locale = typeof LOCALES[number];
|
||||
|
||||
// 颜色输出(如果支持)
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function log(message: string, color: keyof typeof colors = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从代码文件中提取所有 t() 调用中的 key
|
||||
*/
|
||||
function extractKeysFromCode(frontendDir: string): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
|
||||
// 匹配 t('key') 或 t("key") 或 t(`key`)
|
||||
const tPattern = /t\(['"`]([^'"`]+)['"`]\)/g;
|
||||
|
||||
// 扫描所有 TS/JS 文件(含 .ts .tsx .js .jsx),排除 node_modules、dist、build、*.d.ts
|
||||
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
|
||||
cwd: frontendDir,
|
||||
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts'],
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
let match;
|
||||
|
||||
while ((match = tPattern.exec(content)) !== null) {
|
||||
const key = match[1];
|
||||
// 过滤掉模板字符串中的变量(如 t(`key.${variable}`))
|
||||
if (!key.includes('${') && !key.includes('${')) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言文件
|
||||
*/
|
||||
function loadLocaleFile(localeDir: string, locale: Locale): Record<string, any> {
|
||||
const filePath = path.join(localeDir, locale, 'common.json');
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
log(`⚠️ 语言文件不存在: ${filePath}`, 'yellow');
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
log(`❌ 解析语言文件失败 ${filePath}: ${error}`, 'red');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展平嵌套对象为点分隔的 key 路径
|
||||
*/
|
||||
function flattenKeys(obj: Record<string, any>, prefix = ''): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// 递归处理嵌套对象
|
||||
const nestedKeys = flattenKeys(value, fullKey);
|
||||
nestedKeys.forEach(k => keys.add(k));
|
||||
} else {
|
||||
// 叶子节点
|
||||
keys.add(fullKey);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 key 完整性
|
||||
*/
|
||||
function checkKeys(usedKeys: Set<string>, localeKeys: Record<Locale, Set<string>>): CheckResult {
|
||||
const result: CheckResult = {
|
||||
usedKeys,
|
||||
localeKeys,
|
||||
missingKeys: {
|
||||
'zh-CN': [],
|
||||
'zh-TW': [],
|
||||
'en': [],
|
||||
},
|
||||
inconsistentKeys: [],
|
||||
};
|
||||
|
||||
// 检查每个语言文件中缺失的 key
|
||||
for (const locale of LOCALES) {
|
||||
for (const key of usedKeys) {
|
||||
if (!localeKeys[locale].has(key)) {
|
||||
result.missingKeys[locale].push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查不一致的 key(某些语言有,某些没有)
|
||||
const allLocaleKeys = new Set<string>();
|
||||
LOCALES.forEach(locale => {
|
||||
localeKeys[locale].forEach(key => allLocaleKeys.add(key));
|
||||
});
|
||||
|
||||
for (const key of allLocaleKeys) {
|
||||
const existsIn: Locale[] = [];
|
||||
const missingIn: Locale[] = [];
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
if (localeKeys[locale].has(key)) {
|
||||
existsIn.push(locale);
|
||||
} else {
|
||||
missingIn.push(locale);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果某些语言有,某些没有,则不一致
|
||||
if (existsIn.length > 0 && existsIn.length < LOCALES.length) {
|
||||
result.inconsistentKeys.push({
|
||||
key,
|
||||
existsIn,
|
||||
missingIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印检查报告
|
||||
*/
|
||||
function printReport(result: CheckResult): void {
|
||||
log('\n=== 多语言 Key 检查报告 ===\n', 'cyan');
|
||||
|
||||
// 统计信息
|
||||
log('📊 统计信息:', 'blue');
|
||||
log(` - 代码中使用的 key 数量: ${result.usedKeys.size}`);
|
||||
log(` - zh-CN 语言文件 key 数量: ${result.localeKeys['zh-CN'].size}`);
|
||||
log(` - zh-TW 语言文件 key 数量: ${result.localeKeys['zh-TW'].size}`);
|
||||
log(` - en 语言文件 key 数量: ${result.localeKeys['en'].size}`);
|
||||
log('');
|
||||
|
||||
// 缺失的 key
|
||||
let hasMissing = false;
|
||||
for (const locale of LOCALES) {
|
||||
if (result.missingKeys[locale].length > 0) {
|
||||
hasMissing = true;
|
||||
log(`❌ 缺失的 Key (代码中使用但 ${locale} 语言文件中不存在):`, 'red');
|
||||
result.missingKeys[locale].forEach(key => {
|
||||
log(` - ${key}`, 'red');
|
||||
});
|
||||
log('');
|
||||
}
|
||||
}
|
||||
|
||||
// 不一致的 key
|
||||
if (result.inconsistentKeys.length > 0) {
|
||||
log('⚠️ 不一致的 Key (某些语言文件有,某些没有):', 'yellow');
|
||||
result.inconsistentKeys.forEach(({ key, existsIn, missingIn }) => {
|
||||
log(` - ${key}`, 'yellow');
|
||||
log(` 存在于: ${existsIn.join(', ')}`, 'yellow');
|
||||
log(` 缺失于: ${missingIn.join(', ')}`, 'yellow');
|
||||
});
|
||||
log('');
|
||||
}
|
||||
|
||||
// 总结
|
||||
const hasErrors = hasMissing || result.inconsistentKeys.length > 0;
|
||||
|
||||
if (hasErrors) {
|
||||
log('❌ 检查失败:发现缺失或不一致的 key', 'red');
|
||||
} else {
|
||||
log('✅ 检查通过:所有 key 都完整且一致', 'green');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主函数
|
||||
*/
|
||||
function main(): void {
|
||||
// 脚本位于 .cursor/skills/frontend/check-i18n-keys/scripts/,向上到项目根
|
||||
const scriptDir = __dirname;
|
||||
const skillDir = path.resolve(scriptDir, '..');
|
||||
const frontendSkillDir = path.resolve(skillDir, '..');
|
||||
const repoRoot = path.resolve(frontendSkillDir, '../../..');
|
||||
const frontendDir = path.join(repoRoot, 'frontend');
|
||||
const localesDir = path.join(frontendDir, 'src', 'locales');
|
||||
|
||||
// 检查目录是否存在
|
||||
if (!fs.existsSync(frontendDir)) {
|
||||
log(`❌ 前端目录不存在: ${frontendDir}`, 'red');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(localesDir)) {
|
||||
log(`❌ 语言文件目录不存在: ${localesDir}`, 'red');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log('🔍 开始检查多语言 key...\n', 'cyan');
|
||||
|
||||
// 1. 提取代码中使用的 key
|
||||
log('📝 扫描代码文件...', 'blue');
|
||||
const usedKeys = extractKeysFromCode(frontendDir);
|
||||
log(` 找到 ${usedKeys.size} 个使用的 key\n`, 'green');
|
||||
|
||||
// 2. 加载语言文件
|
||||
log('📚 加载语言文件...', 'blue');
|
||||
const localeKeys: Record<Locale, Set<string>> = {
|
||||
'zh-CN': new Set(),
|
||||
'zh-TW': new Set(),
|
||||
'en': new Set(),
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const localeObj = loadLocaleFile(localesDir, locale);
|
||||
localeKeys[locale] = flattenKeys(localeObj);
|
||||
log(` ${locale}: ${localeKeys[locale].size} 个 key`, 'green');
|
||||
}
|
||||
log('');
|
||||
|
||||
// 3. 检查
|
||||
log('🔎 检查 key 完整性...', 'blue');
|
||||
const result = checkKeys(usedKeys, localeKeys);
|
||||
|
||||
// 4. 输出报告
|
||||
printReport(result);
|
||||
|
||||
// 5. 退出码
|
||||
const hasErrors =
|
||||
result.missingKeys['zh-CN'].length > 0 ||
|
||||
result.missingKeys['zh-TW'].length > 0 ||
|
||||
result.missingKeys['en'].length > 0 ||
|
||||
result.inconsistentKeys.length > 0;
|
||||
|
||||
process.exit(hasErrors ? 1 : 0);
|
||||
}
|
||||
|
||||
// 运行
|
||||
main();
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
{
|
||||
"name": "cursor-skill-frontend-scripts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-skill-frontend-scripts",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"glob": "^13.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
|
||||
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz",
|
||||
"integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.1.2",
|
||||
"minipass": "^7.1.2",
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz",
|
||||
"integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz",
|
||||
"integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "cursor-skill-frontend-scripts",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-i18n": "tsx check-i18n-keys.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"glob": "^13.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"downlevelIteration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ backend/gradle-app.setting
|
||||
backend/.gradle
|
||||
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
|
||||
# backend/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
polyhub/
|
||||
# Kotlin
|
||||
*.kt.bak
|
||||
*.class
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# PolyHermes 动态更新功能 - 遗漏检查与修复报告
|
||||
|
||||
## 检查时间
|
||||
2026-01-21 03:00
|
||||
|
||||
## ✅ 已发现并修复的遗漏
|
||||
|
||||
### 1. docker-compose.prod.yml 环境变量
|
||||
- **问题**: 生产环境部署文件缺少 `ALLOW_PRERELEASE` 和 `GITHUB_REPO`。
|
||||
- **修复**: 已添加到 `docker-compose.prod.yml`。
|
||||
|
||||
### 2. 后端权限验证端点
|
||||
- **问题**: `/api/auth/verify` 端点缺失,导致 Python 更新服务无法验证管理员权限。
|
||||
- **修复**: 已在 `AuthController` 中添加 `/verify` 接口,仅允许 ADMIN 角色访问。
|
||||
|
||||
### 3. README.md 文档
|
||||
- **问题**: 未提及新功能。
|
||||
- **修复**: 已在 README 中添加"动态更新"功能说明及文档链接。
|
||||
|
||||
### 4. Docker Python 依赖优化
|
||||
- **问题**: 使用 `pip install` 可能导致依赖冲突或安装缓慢。
|
||||
- **修复**: 替换为 `apt-get install python3-flask python3-requests`,使用系统包更稳定、快速,且减小镜像体积。
|
||||
|
||||
---
|
||||
|
||||
## 🏁 最终状态
|
||||
|
||||
所有已知的遗漏都已检查并修复。系统现已准备好进行集成测试。
|
||||
|
||||
### 建议测试步骤
|
||||
|
||||
1. **本地构建测试**: `./deploy.sh` 验证 Dockerfile 更改(系统包安装)。
|
||||
2. **后端测试**: 验证 `/api/auth/verify` 接口(需登录并在 Header 带上 Token)。
|
||||
3. **流程测试**: 按计划进行 Pre-release 测试。
|
||||
|
||||
---
|
||||
**状态**: ✅ **全功能就绪,已加固**
|
||||
@@ -1,301 +0,0 @@
|
||||
# PolyHermes 动态更新功能实施完成总结
|
||||
|
||||
## ✅ 全部完成!
|
||||
|
||||
**实施时间**: 2026-01-21
|
||||
**总文件修改**: 12个
|
||||
**前端新增**: 1个组件
|
||||
**后端新增**: 1个服务
|
||||
**总代码行数**: 约2000行
|
||||
|
||||
---
|
||||
|
||||
## 📂 文件清单
|
||||
|
||||
### 后端实施(已完成)
|
||||
1. ✅ `Dockerfile` - 混合编译方案
|
||||
2. ✅ `docker/update-service.py` - P Python Flask 更新服务(573行)
|
||||
3. ✅ `docker/start.sh` - 启动3个进程
|
||||
4. ✅ `docker/nginx.conf` - Nginx 代理配置
|
||||
5. ✅ `docker-compose.yml` - 环境变量
|
||||
6. ✅ `docker-compose.test.yml` - 测试环境
|
||||
7. ✅ `.github/workflows/docker-build.yml` - CI/CD
|
||||
|
||||
### 前端实施(已完成)
|
||||
8. ✅ `frontend/src/pages/SystemUpdate.tsx` - 系统更新组件(334行)
|
||||
9. ✅ `frontend/src/pages/SystemSettings.tsx` - 集成到系统设置
|
||||
|
||||
### 文档
|
||||
10. ✅ `docs/zh/DYNAMIC_UPDATE.md` - 完整技术文档
|
||||
11. ✅ `docs/zh/IMPLEMENTATION_SUMMARY.md` - 实施总结
|
||||
12. ✅ `verify-implementation.sh` - 验证脚本
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心功能
|
||||
|
||||
### 1. 混合编译策略
|
||||
- **GitHub Actions**: 编译1次,8分钟完成
|
||||
- **本地 deploy.sh**: 完全兼容,Docker内编译
|
||||
- **构建参数**: `BUILD_IN_DOCKER` 控制编译位置
|
||||
|
||||
### 2. Pre-release 测试
|
||||
- 测试版本不推送 `latest` 标签
|
||||
- 测试版本不触发 Telegram 通知
|
||||
- 环境变量 `ALLOW_PRERELEASE=true` 启用检测
|
||||
|
||||
### 3. 更新流程
|
||||
```
|
||||
检查版本 → 下载更新包 → 备份 → 替换文件 → 重启服务 → 健康检查 → 回滚(失败时)
|
||||
```
|
||||
|
||||
### 4. 架构特点
|
||||
- **Nginx 直接代理**: `/api/update/*` → Python:9090
|
||||
- **权限验证**: Python 调用后端 `/api/auth/verify`
|
||||
- **独立服务**: 更新服务与主应用分离
|
||||
- **版本追踪**: `/app/version.json`
|
||||
|
||||
### 5. 前端UI
|
||||
- 实时进度显示
|
||||
- 版本对比
|
||||
- Release Notes 展示
|
||||
- 一键升级
|
||||
- 自动刷新
|
||||
|
||||
---
|
||||
|
||||
## 🚀 使用流程
|
||||
|
||||
### 开发测试(Pre-release)
|
||||
|
||||
```bash
|
||||
# 1. 提交代码
|
||||
git add .
|
||||
git commit -m "feat: 动态更新功能"
|
||||
git push origin dynamic_load
|
||||
|
||||
# 2. 创建测试 tag
|
||||
git tag v1.3.0-beta
|
||||
git push origin v1.3.0-beta
|
||||
|
||||
# 3. GitHub 创建 Pre-release
|
||||
# - Tag: v1.3.0-beta
|
||||
# - ✅ 勾选 "This is a pre-release"
|
||||
# - 发布
|
||||
|
||||
# 4. GitHub Actions 自动执行
|
||||
# - 编译前后端
|
||||
# - 打包更新包
|
||||
# - 上传到 Release Assets
|
||||
# - 构建 Docker 镜像(仅 v1.3.0-beta 标签)
|
||||
# -❌不推送 latest
|
||||
# - ❌ 不发送 Telegram
|
||||
|
||||
# 5. 测试环境部署
|
||||
docker pull wrbug/polyhermes:v1.3.0-beta
|
||||
docker-compose -f docker-compose.test.yml up -d
|
||||
|
||||
# 6. 测试更新功能
|
||||
# - 访问系统设置 → 系统更新
|
||||
# - 点击"检查更新"(应该检测到 v1.3.0-beta)
|
||||
# - 点击"立即升级"
|
||||
# - 验证更新流程
|
||||
```
|
||||
|
||||
### 生产发布
|
||||
|
||||
```bash
|
||||
# 测试通过后,创建正式版本
|
||||
git tag v1.3.0
|
||||
git push origin v1.3.0
|
||||
|
||||
# GitHub 创建 Release
|
||||
# - Tag: v1.3.0
|
||||
# - ❌ 不勾选 "pre-release"
|
||||
# - 发布
|
||||
|
||||
# GitHub Actions 自动执行
|
||||
# - 编译前后端
|
||||
# - 打包更新包
|
||||
# - 上传到 Release Assets
|
||||
# - 构建 Docker 镜像(v1.3.0 + latest)
|
||||
# - ✅ 推送 latest
|
||||
# - ✅ 发送 Telegram 通知
|
||||
|
||||
# 生产环境更新
|
||||
# 1. 用户访问系统设置 → 系统更新
|
||||
# 2. 点击"检查更新"
|
||||
# 3. 点击"立即升级"
|
||||
# 4. 等待30-60秒
|
||||
# 5. 页面自动刷新
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 验证清单
|
||||
|
||||
运行验证脚本:
|
||||
```bash
|
||||
./verify-implementation.sh
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
========================================
|
||||
PolyHermes 动态更新功能验证
|
||||
========================================
|
||||
|
||||
📋 检查文件...
|
||||
✅ Dockerfile
|
||||
✅ docker/update-service.py
|
||||
✅ docker/start.sh
|
||||
✅ docker/nginx.conf
|
||||
✅ docker-compose.yml
|
||||
✅ docker-compose.test.yml
|
||||
✅ .github/workflows/docker-build.yml
|
||||
✅ docs/zh/DYNAMIC_UPDATE.md
|
||||
|
||||
📋 检查关键配置...
|
||||
✅ Dockerfile 包含 BUILD_IN_DOCKER 参数
|
||||
✅ Dockerfile 安装 Python
|
||||
✅ Nginx 配置包含更新服务代理
|
||||
✅ docker-compose.yml 包含 ALLOW_PRERELEASE
|
||||
✅ GitHub Actions 包含 Pre-release 检测
|
||||
✅ GitHub Actions 包含后端编译步骤
|
||||
|
||||
📋 检查 Python 语法...
|
||||
✅ update-service.py 语法正确
|
||||
|
||||
========================================
|
||||
✅ 验证通过!所有检查项正常
|
||||
========================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 必须检查的端点
|
||||
|
||||
1. **健康检查端点**: `/api/system/health`
|
||||
- 用于检查后端服务是否正常
|
||||
- 如果不存在,需要修改 `Dockerfile` 和 `start.sh` 中的健康检查URL
|
||||
|
||||
2. **权限验证端点**: `/api/auth/verify`
|
||||
- 用于验证管理员权限
|
||||
- 如果不存在,有两个选择:
|
||||
- 在后端创建此端点
|
||||
- 或修改 `update-service.py` 中的权限验证逻辑
|
||||
|
||||
---
|
||||
|
||||
## 🎨 前端UI特性
|
||||
|
||||
- ✅ 当前版本显示
|
||||
- ✅ 检查更新按钮
|
||||
- ✅ 更新信息展示(版本、发布时间、Release Notes)
|
||||
- ✅ 实时进度条(0-100%)
|
||||
- ✅ 状态消息显示
|
||||
- ✅ 一键升级按钮
|
||||
- ✅ 错误处理和显示
|
||||
- ✅ 更新成功后自动刷新
|
||||
- ✅ 使用说明提示
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 文档
|
||||
|
||||
### 前端调用的API
|
||||
|
||||
| 端点 | 方法 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| `/api/update/version` | GET | 获取当前版本 | 无 |
|
||||
| `/api/update/check` | GET | 检查更新 | 无 |
|
||||
| `/api/update/execute` | POST | 执行更新 | Admin |
|
||||
| `/api/update/status` | GET | 获取更新状态 | 无 |
|
||||
| `/api/update/logs` | GET | 获取更新日志 | Admin |
|
||||
|
||||
### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
...
|
||||
},
|
||||
"message": "success"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题1:健康检查失败
|
||||
|
||||
**错误信息**: `后端服务启动超时`
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查健康检查端点
|
||||
curl http://localhost:8000/api/system/health
|
||||
|
||||
# 如果404,修改 Dockerfile 和 start.sh
|
||||
# 将 /api/system/health 改为实际存在的端点
|
||||
```
|
||||
|
||||
### 问题2:权限验证失败
|
||||
|
||||
**错误信息**: `需要管理员权限`
|
||||
|
||||
**解决方案**:
|
||||
1. 确保前端已登录且有 Admin Token
|
||||
2. 检查 `/api/auth/verify` 端点是否存在
|
||||
3. 或修改 `update-service.py` 的权限验证逻辑
|
||||
|
||||
### 问题3:更新包下载失败
|
||||
|
||||
**错误信息**: `下载更新包失败`
|
||||
|
||||
**可能原因**:
|
||||
- GitHub Release 未发布
|
||||
- 更新包文件名不符合规范
|
||||
- 网络连接问题
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查 Release Assets
|
||||
curl https://api.github.com/repos/WrBug/PolyHermes/releases/latest
|
||||
|
||||
# 确保文件名格式:polyhermes-{tag}-update.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| **GitHub Actions 构建时间** | ~8分钟 |
|
||||
| **更新包大小** | ~50MB |
|
||||
| **更新总时长** | 30-60秒 |
|
||||
| **下载时间** | 5-15秒(依网络)|
|
||||
| **备份时间** | 2-5秒 |
|
||||
| **解压时间** | 2-3秒 |
|
||||
| **重启时间** | 10-15秒 |
|
||||
| **健康检查** | 最多30秒 |
|
||||
|
||||
---
|
||||
|
||||
##✅ 实施完成状态
|
||||
|
||||
**后端**: ✅ 100% 完成
|
||||
**前端**: ✅ 100% 完成
|
||||
**文档**: ✅ 100% 完成
|
||||
**测试**: ⏳ 待验证
|
||||
|
||||
---
|
||||
|
||||
**状态**: 🎉 **实施完成,准备测试!**
|
||||
|
||||
**下一步**: 创建 Pre-release 进行测试验证
|
||||
@@ -1,31 +0,0 @@
|
||||
# PolyHermes 动态更新功能 - 遗漏检查报告
|
||||
|
||||
## 检查时间
|
||||
2026-01-21 01:46
|
||||
|
||||
## 已发现并修复的遗漏
|
||||
|
||||
### 1. ✅ docker-compose.prod.yml 缺少环境变量
|
||||
**问题**: 生产环境配置文件缺少动态更新相关的环境变量
|
||||
**修复**: 已添加 `ALLOW_PRERELEASE` 和 `GITHUB_REPO` 环境变量
|
||||
|
||||
### 2. ✅ 后端缺少权限验证端点
|
||||
**问题**: Python 更新服务需要调用 `/api/auth/verify` 验证管理员权限,但该端点不存在
|
||||
**修复**: 已在 `AuthController.kt` 中添加 `verify` 端点
|
||||
|
||||
## 继续检查项目
|
||||
|
||||
### 3. 备份文件检查
|
||||
检查是否有遗留的备份文件需要清理...
|
||||
|
||||
### 4. .gitignore 文件
|
||||
检查是否需要添加临时文件到 .gitignore...
|
||||
|
||||
### 5. 前端国际化
|
||||
检查是否需要为系统更新添加多语言支持...
|
||||
|
||||
### 6. README 文档
|
||||
检查是否需要更新 README 说明新功能...
|
||||
|
||||
### 7. 依赖检查
|
||||
检查 Python 依赖是否完整(Flask, requests)...
|
||||
@@ -165,6 +165,29 @@
|
||||
|
||||
**部署步骤**:
|
||||
|
||||
#### ⚡ 一键安装(最快,推荐新用户)
|
||||
|
||||
**使用 curl(推荐):**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
**使用 wget:**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
这个命令会自动:
|
||||
- 📁 创建专用工作目录 `~/polyhermes`
|
||||
- ✅ 自动检查 Docker 环境
|
||||
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
|
||||
- 🔐 自动生成安全的随机密钥
|
||||
- 🚀 自动下载最新镜像并部署
|
||||
|
||||
---
|
||||
|
||||
#### 📋 其他部署方式
|
||||
|
||||
1. **使用 Docker Hub 镜像(推荐,生产环境首选)**
|
||||
|
||||
**方式 1:独立部署(无需 clone 代码,推荐)**
|
||||
|
||||
@@ -164,6 +164,29 @@ Deploy both frontend and backend together in a single Docker container, using Ng
|
||||
|
||||
**Deployment Steps**:
|
||||
|
||||
#### ⚡ One-Liner Installation (Fastest, Recommended for New Users)
|
||||
|
||||
**Using curl (Recommended):**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
**Using wget:**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
This command will automatically:
|
||||
- 📁 Create dedicated working directory `~/polyhermes`
|
||||
- ✅ Automatically check Docker environment
|
||||
- ⚙️ Interactive configuration for all parameters (supports pressing Enter for defaults)
|
||||
- 🔐 Automatically generate secure random keys
|
||||
- 🚀 Automatically download latest images and deploy
|
||||
|
||||
---
|
||||
|
||||
#### 📋 Alternative Deployment Methods
|
||||
|
||||
1. **Using Docker Hub Images (Recommended, Production First Choice)**
|
||||
|
||||
**Method 1: Standalone Deployment (No code clone required, Recommended)**
|
||||
|
||||
-971
@@ -1,971 +0,0 @@
|
||||
# v1.1.10
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 📢 推送已过滤订单功能
|
||||
|
||||
- **新增推送已过滤订单功能(pushFilteredOrders)**,默认关闭
|
||||
- 支持在模板和跟单配置中配置是否推送被过滤的订单通知
|
||||
- 开启后,当订单因过滤条件(价格区间、订单深度、价差、仓位限制等)被过滤时,会发送 Telegram 通知
|
||||
- 帮助用户了解哪些订单被过滤以及过滤原因
|
||||
|
||||
- **数据库迁移**:
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading_templates` 表
|
||||
- 添加 `push_filtered_orders` 字段到 `copy_trading` 表
|
||||
- 迁移脚本:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
|
||||
- **后端实现**:
|
||||
- 在实体类(`CopyTradingTemplate`、`CopyTrading`)中添加 `pushFilteredOrders` 字段
|
||||
- 在 DTO 中添加字段支持(创建、更新、查询)
|
||||
- 在 Service 中处理字段的创建、更新和传递
|
||||
- 在发送过滤订单通知时检查 `pushFilteredOrders` 字段,只有为 `true` 时才发送
|
||||
|
||||
- **前端实现**:
|
||||
- 在模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 在跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 从模板创建跟单配置时,自动填充 `pushFilteredOrders` 字段
|
||||
- 支持多语言(中文、繁体中文、英文)
|
||||
|
||||
### 🔍 优化订单列表筛选功能
|
||||
|
||||
- **支持按市场标题搜索**:
|
||||
- 买入订单列表:添加市场标题筛选,移除方向筛选
|
||||
- 卖出订单列表:添加市场标题筛选,将方向筛选改为状态筛选
|
||||
- 已成交订单列表:添加市场标题筛选和市场列显示
|
||||
|
||||
- **改进分组体验**:
|
||||
- 记录用户的分组偏好到 localStorage,跨会话持久化
|
||||
- 所有搜索输入框添加 0.5 秒防抖优化,提升性能
|
||||
|
||||
- **后端优化**:
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
- 买入/卖出订单分组接口支持市场 ID 模糊匹配和市场标题关键字筛选
|
||||
|
||||
### ⚡ 优化跟单关系统计性能
|
||||
|
||||
- **移除未实现盈亏计算**:
|
||||
- 移除未实现盈亏和持仓价值的计算逻辑
|
||||
- 总盈亏现在仅包含已实现盈亏,计算更准确
|
||||
- 简化盈亏百分比计算,仅基于已实现盈亏
|
||||
|
||||
- **性能提升**:
|
||||
- 从 1+N 次网络请求减少到 0 次
|
||||
- 删除不再使用的方法和依赖注入
|
||||
- 统计查询速度显著提升
|
||||
|
||||
### 🐳 Docker 容器时区配置
|
||||
|
||||
- **支持通过 .env 自定义时区**:
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加时区环境变量配置
|
||||
- 支持通过 `TZ` 环境变量自定义容器时区
|
||||
- 默认使用系统时区
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
|
||||
- **问题**:修改跟单配置时,清空价格区间、最大仓位数量、截止时间等可选字段后,无法保存到数据库
|
||||
- **修复**:
|
||||
- 修复价格区间(`minPrice`/`maxPrice`)清空后无法保存的问题
|
||||
- 修复最大仓位数量(`maxPositionCount`)清空后无法保存的问题
|
||||
- 修复市场截止时间(`maxMarketEndDate`)清空后无法保存的问题
|
||||
- 修复其他可选字段(`minOrderDepth`/`maxSpread`/`maxPositionValue`)清空后无法保存的问题
|
||||
|
||||
- **实现方案**:
|
||||
- 前端:清空字段时传空字符串或 `-1` 标记,让后端识别为清空操作
|
||||
- 后端:处理空字符串和 `-1` 标记,正确设置为 `null` 以清空字段
|
||||
|
||||
### 修复新建和编辑页面的截止时间输入框交互问题
|
||||
|
||||
- **问题**:删除截止时间输入框内容后,失去焦点会自动填充 1
|
||||
- **修复**:
|
||||
- 将 `min` 从 `1` 改为 `0`,允许空值
|
||||
- 优化 `onChange` 处理,当值为 `0`、`null` 或 `undefined` 时设置为 `undefined`(清空)
|
||||
- 添加 `onBlur` 处理,确保失去焦点时如果值为空或 `0`,设置为 `undefined`
|
||||
- 修改 `parser`,空值时返回空字符串而不是 `0`
|
||||
|
||||
### 修复订单通知重复发送和时间显示问题
|
||||
|
||||
- **修复并发导致的重复通知问题**:
|
||||
- 在 `OrderStatusUpdateService` 中实现双重检查机制
|
||||
- 先保存订单标记为已发送,再重新查询数据库检查
|
||||
- 防止定时任务并发时重复发送同一订单的通知
|
||||
- 同时修复买入订单和卖出订单的通知逻辑
|
||||
|
||||
- **修复 Telegram 通知时间显示**:
|
||||
- `TelegramNotificationService.sendOrderSuccessNotification` 添加 `orderTime` 参数
|
||||
- 使用订单的 `createdAt` 时间戳作为通知显示时间
|
||||
- 而不是使用当前通知发送时间
|
||||
- 更准确反映订单的实际创建时间
|
||||
|
||||
### 修复新建跟单配置时 pushFilteredOrders 字段未生效的问题
|
||||
|
||||
- **问题**:新建跟单配置时,即使设置了 `pushFilteredOrders: true`,也没有生效
|
||||
- **修复**:
|
||||
- 修复手动输入模式下 `pushFilteredOrders` 被硬编码为 `false` 的问题
|
||||
- 修复从模板填充表单时未加载 `pushFilteredOrders` 的问题
|
||||
- 添加 `CopyTradingTemplate` 接口的 `pushFilteredOrders` 字段定义
|
||||
|
||||
## 📝 技术细节
|
||||
|
||||
### 数据库变更
|
||||
|
||||
- **迁移脚本**:`V24__add_push_filtered_orders_to_templates.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading_templates.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- `copy_trading.push_filtered_orders`: BOOLEAN NOT NULL DEFAULT FALSE
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
|
||||
- **无新增接口**
|
||||
- **无移除接口**
|
||||
- **请求/响应变更**:
|
||||
- `CopyTradingCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `CopyTradingUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateCreateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `TemplateUpdateRequest` 添加 `pushFilteredOrders` 字段
|
||||
- `MarketGroupedOrdersRequest` 添加 `marketId` 和 `marketTitle` 字段
|
||||
|
||||
### 前端变更
|
||||
|
||||
- **新增字段**:
|
||||
- `CopyTradingTemplate` 接口添加 `pushFilteredOrders` 字段
|
||||
- **组件更新**:
|
||||
- 模板新增、编辑、推送页面添加"推送已过滤订单"开关
|
||||
- 跟单配置新增、编辑页面添加"推送已过滤订单"开关
|
||||
- 优化截止时间输入框交互逻辑
|
||||
- **多语言支持**:
|
||||
- 添加中文、繁体中文、英文翻译
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:6 个提交
|
||||
- **文件变更**:30 个文件
|
||||
- **代码变更**:+651 行 / -555 行(净增加 96 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `CopyTrading.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingTemplate.kt` - 添加 `pushFilteredOrders` 字段(+3 行)
|
||||
- `CopyTradingDto.kt` - 添加 `pushFilteredOrders` 字段支持(+3 行)
|
||||
- `CopyTradingTemplateDto.kt` - 添加 `pushFilteredOrders` 字段支持(+10 行)
|
||||
- `CopyTradingService.kt` - 处理 `pushFilteredOrders` 字段和清空字段逻辑(+93 行)
|
||||
- `CopyTradingTemplateService.kt` - 处理 `pushFilteredOrders` 字段(+8 行)
|
||||
- `CopyOrderTrackingService.kt` - 检查 `pushFilteredOrders` 字段发送通知(+42 行)
|
||||
- `CopyTradingStatisticsService.kt` - 优化统计性能(-328 行)
|
||||
- `OrderStatusUpdateService.kt` - 修复重复通知问题(+142 行)
|
||||
- `TelegramNotificationService.kt` - 添加订单时间参数(+17 行)
|
||||
- `V24__add_push_filtered_orders_to_templates.sql` - 数据库迁移脚本(+13 行)
|
||||
|
||||
**前端变更**:
|
||||
- `AddModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+40 行)
|
||||
- `EditModal.tsx` - 添加 `pushFilteredOrders` 字段和优化截止时间输入框(+59 行)
|
||||
- `TemplateAdd.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateEdit.tsx` - 添加 `pushFilteredOrders` 字段(+15 行)
|
||||
- `TemplateList.tsx` - 添加 `pushFilteredOrders` 字段(+13 行)
|
||||
- `BuyOrdersTab.tsx` - 优化筛选功能(+94 行)
|
||||
- `SellOrdersTab.tsx` - 优化筛选功能(+77 行)
|
||||
- `MatchedOrdersTab.tsx` - 优化筛选功能(+26 行)
|
||||
- `types/index.ts` - 添加 `pushFilteredOrders` 字段定义(+6 行)
|
||||
- `locales/*/common.json` - 添加多语言翻译(+31 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加时区配置(+5 行)
|
||||
- `docker-compose.prod.yml` - 添加时区配置(+5 行)
|
||||
- `Dockerfile` - 优化构建配置(+4 行)
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
9c303e0 feat: 添加推送已过滤订单功能并修复相关问题
|
||||
cb8e469 fix: 修复跟单配置更新时清空可选字段无法保存的问题
|
||||
279806d feat: 优化订单列表筛选功能,支持按市场标题搜索并改进分组体验
|
||||
90fa487 refactor: 移除未实现盈亏计算以优化跟单关系统计性能
|
||||
b58bb26 feat: 添加 Docker 容器时区配置,支持通过 .env 自定义
|
||||
6af76c4 fix: 修复订单通知重复发送和时间显示问题
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:本次版本包含数据库迁移脚本,升级时会自动执行
|
||||
- 自动添加 `push_filtered_orders` 字段到模板表和跟单配置表
|
||||
- 现有数据不受影响,新字段默认值为 `false`
|
||||
|
||||
2. **配置更新**:
|
||||
- 可选:在 `.env` 文件中添加 `TZ` 环境变量自定义容器时区
|
||||
- 无需其他配置变更
|
||||
|
||||
3. **兼容性**:
|
||||
- 完全向后兼容,不影响现有功能
|
||||
- API 变更都是新增字段,不影响现有调用
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.10`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.10)
|
||||
- [完整更新日志](https://github.com/WrBug/PolyHermes/compare/v1.1.9...v1.1.10)
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-12
|
||||
|
||||
---
|
||||
|
||||
# v1.1.9
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复盈亏统计计算错误问题
|
||||
|
||||
- **问题**:在仓位检查时,刚创建的订单可能因仓位更新延迟被误判为已卖出,导致盈亏统计计算错误
|
||||
- **修复**:
|
||||
- 优化自动卖出订单筛选逻辑,在 SQL 层直接过滤创建时间超过阈值的订单
|
||||
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` Repository 方法,提高查询效率
|
||||
- 统一使用 2 分钟阈值保护刚创建的订单,避免误判
|
||||
- 两个场景都使用 SQL 过滤:
|
||||
* 场景1:仓位不存在时,延迟检测使用 SQL 过滤
|
||||
* 场景2:仓位部分存在时,FIFO 匹配使用 SQL 过滤
|
||||
|
||||
- **优化效果**:
|
||||
- SQL 层面直接过滤,减少数据传输,提高查询效率
|
||||
- 代码更简洁,逻辑更清晰
|
||||
- 刚创建的订单(< 2 分钟)不会被误判为已卖出
|
||||
- 确保盈亏统计计算的准确性
|
||||
|
||||
## 📝 技术细节
|
||||
|
||||
- **Repository 变更**:
|
||||
- 新增 `findUnmatchedBuyOrdersByOutcomeIndexOlderThan` 方法
|
||||
- 在 SQL 查询中添加 `createdAt < :thresholdTime` 条件过滤
|
||||
|
||||
- **Service 变更**:
|
||||
- `PositionCheckService` 中两个场景都使用 SQL 过滤替代内存过滤
|
||||
- 统一使用 2 分钟(120000 毫秒)作为时间阈值
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:1 个提交
|
||||
- **文件变更**:2 个文件
|
||||
- **代码变更**:+57 行 / -19 行(净增加 38 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `CopyOrderTrackingRepository.kt` - 新增 SQL 查询方法(+18 行)
|
||||
- `PositionCheckService.kt` - 优化订单筛选逻辑(+39 行 / -19 行)
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
6ad4024 fix: 优化自动卖出订单筛选逻辑,避免刚创建的订单被误判
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:无需数据库迁移,可直接升级
|
||||
2. **配置更新**:无需配置变更
|
||||
3. **兼容性**:完全向后兼容,不影响现有功能
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.9`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.9)
|
||||
|
||||
---
|
||||
|
||||
# v1.1.8
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### ⚡ Polymarket Activity WebSocket 双重监听方案
|
||||
|
||||
- **新增 PolymarketActivityWsService**:通过 Activity WebSocket 实时监听 Leader 交易
|
||||
- 延迟 < 100ms,适合快速跟单场景
|
||||
- 订阅全局 activity 交易流,客户端过滤 Leader 地址
|
||||
- 支持动态添加/移除 Leader 监听
|
||||
- 地址筛选忽略大小写,提高匹配准确性
|
||||
|
||||
- **双重监听机制**:
|
||||
- Activity WebSocket(低延迟,< 100ms):作为主要监听方式
|
||||
- On-Chain WebSocket(高可靠性,~2-3s):作为兜底监听方式
|
||||
- 自动去重,避免重复处理同一笔交易
|
||||
|
||||
- **协议支持**:
|
||||
- 支持 `connection_id` 字段
|
||||
- 优先使用消息中的 `outcomeIndex` 字段,fallback 到从 `outcome` 解析
|
||||
|
||||
- **参考文档**:
|
||||
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
|
||||
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
|
||||
|
||||
### 🔧 支持同一用户创建多个相同 Leader 的跟单配置
|
||||
|
||||
- **移除唯一约束**:允许同一用户创建多个跟单同一个 Leader 的配置
|
||||
- **应用场景**:支持不同参数(比例、过滤条件等)的多配置跟单
|
||||
- **数据库迁移**:`V23__remove_unique_constraint_from_copy_trading.sql`
|
||||
|
||||
### 🎯 市场截止时间筛选功能
|
||||
|
||||
- **新增市场截止时间过滤**:支持设置跟单配置的最大市场截止时间
|
||||
- **自动过滤过期市场**:系统会自动跳过超过设定截止时间的市场
|
||||
- **数据库字段**:`max_market_end_date`(时间戳,单位:秒)
|
||||
- **数据库迁移**:`V22__add_max_market_end_date_to_copy_trading.sql`
|
||||
|
||||
### 🔍 关键字过滤功能
|
||||
|
||||
- **新增关键字过滤**:支持在黑名单或白名单模式下过滤市场标题关键字
|
||||
- **过滤模式**:
|
||||
- `DISABLED`:禁用关键字过滤
|
||||
- `BLACKLIST`:黑名单模式(包含关键字的市场会被过滤)
|
||||
- `WHITELIST`:白名单模式(只允许包含关键字的市场)
|
||||
- **多关键字支持**:支持多个关键字,以 JSON 数组格式存储
|
||||
- **数据库字段**:`keyword_filter_mode` 和 `keywords`(JSON 数组)
|
||||
- **数据库迁移**:`V20__add_keyword_filter.sql`
|
||||
|
||||
### 📊 订单列表功能重构
|
||||
|
||||
- **按市场分组显示**:订单列表按市场分组,便于查看和管理
|
||||
- **市场信息展示**:
|
||||
- 显示市场标题和 slug
|
||||
- 支持跳转到 Polymarket 市场页面
|
||||
- 显示市场基本信息(标题、slug、截止时间等)
|
||||
- **订单 ID 复制功能**:一键复制订单 ID,方便查询和调试
|
||||
- **UI/UX 优化**:
|
||||
- 优化订单列表展示样式
|
||||
- 改进移动端适配
|
||||
|
||||
### 📈 市场信息管理和缓存优化
|
||||
|
||||
- **新增 MarketService**:统一管理市场信息查询和缓存
|
||||
- 使用 LRU 缓存提高查询性能
|
||||
- 支持从 Gamma API 和 CLOB API 查询市场信息
|
||||
- 自动缓存市场信息,减少 API 调用
|
||||
- **新增 Market 实体和表**:持久化存储市场信息
|
||||
- 存储市场标题、slug、eventSlug、endDate 等基本信息
|
||||
- 支持通过 marketId 快速查询市场信息
|
||||
- **数据库迁移**:
|
||||
- `V19__create_markets_table.sql`:创建市场信息表
|
||||
- `V21__add_event_slug_to_markets.sql`:添加 eventSlug 字段
|
||||
- **市场信息轮询服务**:定期更新市场信息,保持数据新鲜度
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 前端 TypeScript 类型错误修复
|
||||
|
||||
- **修复 InputNumber parser 类型错误**:`parser` 函数应返回 `number` 而不是 `string`
|
||||
- **修复文件**:
|
||||
- `src/pages/CopyTradingOrders/AddModal.tsx`
|
||||
- `src/pages/CopyTradingOrders/EditModal.tsx`
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
- **新增双重监听方案文档**:详细说明 Activity WebSocket 和 On-Chain WebSocket 的双重监听机制
|
||||
- **新增 Activity WebSocket API 文档**:详细的 API 格式和消息结构说明
|
||||
- **更新开发规范**:JSON 解析规范和 Data Class 规范
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **61 个文件被修改**
|
||||
- **+5125 行新增代码**
|
||||
- **-1942 行删除代码**
|
||||
- **净增加 3183 行代码**
|
||||
|
||||
## 🔄 主要提交
|
||||
|
||||
```
|
||||
d376a82 feat: 添加市场信息管理和订单ID复制功能
|
||||
2af2c0e feat: 订单列表按市场分组并支持跳转到Polymarket
|
||||
9ed5190 feat: 添加关键字过滤功能并优化市场 slug 处理
|
||||
0327eaf feat: 添加市场截止时间筛选功能
|
||||
a16b6fc feat: 支持同一用户创建多个相同 leader 的跟单配置
|
||||
19508dc feat: 实现 Polymarket Activity WebSocket 双重监听方案
|
||||
```
|
||||
|
||||
## 🎯 升级建议
|
||||
|
||||
1. **数据库迁移**:确保执行所有数据库迁移脚本(V19-V23)
|
||||
2. **配置更新**:新版本的 `application.properties` 中添加了 Activity WebSocket URL 配置
|
||||
3. **环境变量**:如果使用 Docker 部署,建议更新 `docker-compose.yml` 中的配置
|
||||
|
||||
## 📦 Docker 镜像
|
||||
|
||||
Docker 镜像会自动构建并推送到 Docker Hub:
|
||||
- `wrbug/polyhermes:v1.1.8`
|
||||
- `wrbug/polyhermes:latest`(如果这是最新版本)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Release](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.8)
|
||||
- [双重监听方案文档](docs/zh/copy-trading-dual-monitoring-plan.md)
|
||||
- [Activity WebSocket API 文档](docs/zh/polymarket-activity-websocket-api.md)
|
||||
|
||||
---
|
||||
|
||||
# v1.1.7
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 💰 Polymarket Maker Rebates Program 费率支持
|
||||
|
||||
- **新增费率查询 API 接口** (`getFeeRate`)
|
||||
- 支持动态查询 Maker Rebates Program 费率
|
||||
- 修正 API 返回字段名:使用 `base_fee` 而非 `fee_rate_bps`(与 TypeScript clob-client 一致)
|
||||
|
||||
- **动态费率获取**
|
||||
- 在所有订单创建处动态获取费率:
|
||||
* 跟单买入订单 (`processBuyTrade`)
|
||||
* 跟单卖出订单 (`matchSellOrder`)
|
||||
* 账户卖出订单 (`sellPosition`)
|
||||
- 费率获取失败时降级到默认值 "0",确保系统可用性
|
||||
- 添加详细的日志记录,便于监控和调试
|
||||
|
||||
- **参考文档**: https://docs.polymarket.com/developers/market-makers/maker-rebates-program
|
||||
|
||||
### 🔧 Docker 部署优化
|
||||
|
||||
- **日志级别环境变量支持**
|
||||
- 在 `application.properties` 中支持通过 `LOG_LEVEL_ROOT` 和 `LOG_LEVEL_APP` 环境变量配置日志级别
|
||||
- 在 `docker-compose.yml` 和 `docker-compose.prod.yml` 中添加日志级别环境变量配置
|
||||
- 在 `deploy.sh` 的 `.env` 模板中添加日志级别配置说明
|
||||
- 支持通过环境变量动态配置日志级别,无需修改配置文件
|
||||
- 默认值:`root=INFO`, `app=DEBUG`
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复市场条件查询的 RPC 调用错误
|
||||
|
||||
- **问题**:使用错误的函数签名 `conditions(bytes32)` 导致 RPC 调用失败(execution reverted)
|
||||
- **修复**:
|
||||
- 将错误的 `conditions(bytes32)` 函数调用改为正确的 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)` 函数调用
|
||||
- 修复 `BlockchainService.getCondition` 方法,使用正确的 ConditionalTokens 合约函数签名
|
||||
- 改进 `MarketPriceService` 的错误处理:当链上查询出现 RPC 错误时,降级到 CLOB API 或 Gamma API 查询,而不是直接抛出异常,提高容错性
|
||||
|
||||
### 修复 RPC 错误时误创建自动卖出记录的问题
|
||||
|
||||
- **问题**:当链上查询市场条件出现 RPC 错误(execution reverted)时,系统会误判为市场已卖出,创建错误的自动卖出记录
|
||||
- **修复**:
|
||||
- 修改 `getPriceFromChainCondition` 返回 `Pair<BigDecimal?, Boolean>`,第二个值表示是否发生 RPC 错误
|
||||
- 在 `getCurrentMarketPrice` 中检测到 RPC 错误时抛出异常,`PositionCheckService` 会捕获并跳过该市场的处理
|
||||
- 避免在市场不存在或尚未创建时误判为已卖出
|
||||
|
||||
## 📝 文档更新
|
||||
|
||||
### 更新 Telegram 群链接
|
||||
|
||||
- 将所有 Telegram 群链接统一更新为 `t.me/polyhermes`
|
||||
- 更新了以下文件:
|
||||
- `frontend/src/components/Layout.tsx` - 桌面端和移动端导航链接
|
||||
- `RELEASE.md` - 相关链接
|
||||
- `README.md` 和 `README_EN.md` - 相关链接部分
|
||||
|
||||
### 添加 Docker 版本徽章
|
||||
|
||||
- 在 README 和 README_EN.md 中添加动态 Docker 版本徽章
|
||||
- 使用 shields.io 自动显示 Docker Hub 上 `wrbug/polyhermes` 镜像的最新版本
|
||||
- 版本信息自动更新,无需手动维护
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:5 个提交
|
||||
- **文件变更**:16 个文件
|
||||
- **代码变更**:+205 行 / -886 行(净减少 681 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `PolymarketClobApi.kt` - 添加费率查询接口(+25 行)
|
||||
- `AccountService.kt` - 在订单创建处添加动态费率获取(+11 行)
|
||||
- `BlockchainService.kt` - 修复市场条件查询的 RPC 调用错误(+84 行)
|
||||
- `MarketPriceService.kt` - 改进错误处理,支持降级到其他数据源(+36 行)
|
||||
- `PolymarketClobService.kt` - 添加费率查询服务(+32 行)
|
||||
- `CopyOrderTrackingService.kt` - 在跟单订单创建处添加费率获取(+34 行)
|
||||
- `PositionCheckService.kt` - 修复 RPC 错误处理逻辑(+2 行)
|
||||
- `application.properties` - 添加日志级别环境变量支持(+6 行)
|
||||
|
||||
**前端变更**:
|
||||
- `Layout.tsx` - 更新 Telegram 群链接(+4 行)
|
||||
|
||||
**配置文件变更**:
|
||||
- `docker-compose.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `docker-compose.prod.yml` - 添加日志级别环境变量(+4 行)
|
||||
- `deploy.sh` - 添加日志级别配置说明(+5 行)
|
||||
|
||||
**文档变更**:
|
||||
- `README.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `README_EN.md` - 更新 Telegram 链接,添加 Docker 版本徽章(+2 行)
|
||||
- `RELEASE.md` - 更新 Telegram 链接(+4 行)
|
||||
- `docs/zh/smart-money-analysis.md` - 删除文档(-836 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### API 变更
|
||||
|
||||
- **新增接口**:
|
||||
- `POST /api/clob/fee-rate` - 获取 Maker Rebates Program 费率(内部使用)
|
||||
- **无移除接口**
|
||||
|
||||
### 环境变量变更
|
||||
|
||||
- **新增环境变量**:
|
||||
- `LOG_LEVEL_ROOT` - Root 日志级别(默认:INFO)
|
||||
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
|
||||
|
||||
### 合约调用修复
|
||||
|
||||
- **修复的函数调用**:
|
||||
- 从 `conditions(bytes32)` 改为 `getOutcomeSlotCount(bytes32)` 和 `payoutDenominator(bytes32)`
|
||||
- 使用正确的 ConditionalTokens 合约函数签名
|
||||
- 参考:https://polygonscan.com/address/0x4d97dcd97ec945f40cf65f87097ace5ea0476045#code
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
|
||||
- **无需数据库迁移**:本次更新不涉及数据库结构变更
|
||||
|
||||
### 配置更新
|
||||
|
||||
- **可选配置**:新增日志级别环境变量,如不配置将使用默认值
|
||||
- `LOG_LEVEL_ROOT=INFO`(默认)
|
||||
- `LOG_LEVEL_APP=DEBUG`(默认)
|
||||
|
||||
### Docker 部署
|
||||
|
||||
- **推荐更新**:使用 Docker Hub 镜像部署的用户,建议更新到最新版本
|
||||
```bash
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
- **Twitter**:@polyhermes
|
||||
- **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**发布日期**:2026-01-07
|
||||
|
||||
---
|
||||
|
||||
# v1.1.5
|
||||
|
||||
## 🔧 功能优化与改进
|
||||
|
||||
### 前端优化
|
||||
|
||||
#### 优化 InputNumber 输入框格式化
|
||||
- 优化数值输入框的格式化逻辑,修正正则表达式以正确处理整数显示
|
||||
- 更新所有相关 InputNumber 组件的 formatter 函数,确保显示准确性
|
||||
- 影响的组件:CopyTradingAdd、CopyTradingEdit、EditModal、TemplateAdd、TemplateEdit、TemplateList
|
||||
- 影响范围:跟单配置、模板配置中的所有数值输入框
|
||||
|
||||
#### 优化数字显示格式
|
||||
- 添加 `formatNumber` 工具函数,自动去除小数尾随零(如 100.00 → 100)
|
||||
- 统一所有数值输入框的显示格式,提升用户体验
|
||||
|
||||
### 后端优化
|
||||
|
||||
#### 优化按比例跟单金额计算逻辑
|
||||
- 优化按比例计算的订单金额处理,使用向上取整确保满足最小限制要求
|
||||
- 对订单金额进行向上取整处理(保留 2 位小数精度)
|
||||
- 自动调整订单数量以满足最小限制要求
|
||||
- 使用 `RoundingMode.CEILING` 确保金额满足最小限制
|
||||
- 影响范围:按比例跟单的订单创建逻辑
|
||||
- 技术细节:
|
||||
- 扩展 `BigDecimal.div()` 扩展函数,支持指定精度和舍入模式
|
||||
- 在 `CopyOrderTrackingService` 中优化金额计算和验证逻辑
|
||||
|
||||
#### 增强 copyRatio 精度支持
|
||||
- 将 copyRatio 字段精度从 DECIMAL(10,2) 增加到 DECIMAL(20,8)
|
||||
- 支持更精确的跟单比例设置(最小 0.01%,最大 10000%)
|
||||
- 影响的实体:CopyTrading、CopyTradingTemplate
|
||||
- 数据库迁移:新增 V18 迁移脚本,自动升级数据库字段精度
|
||||
|
||||
## 🔧 功能优化
|
||||
|
||||
### 移除刷新代理钱包接口
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy` - 刷新单个账户的代理地址
|
||||
- `POST /api/accounts/refresh-all-proxies` - 刷新所有账户的代理地址
|
||||
- **原因**:代理地址应在账户导入时自动计算,无需手动刷新
|
||||
- **影响范围**:AccountController、AccountService
|
||||
- **向后兼容性**:这些接口已不再使用,移除不影响现有功能
|
||||
|
||||
### 前端跟单比例配置优化
|
||||
- **最小比例**:从 10% 降低到 0.01%,支持更灵活的跟单比例设置
|
||||
- **最大比例**:增加到 10000%,满足大比例跟单需求
|
||||
- **显示格式**:比例模式显示为百分比(如 "100%" 而不是 "1x")
|
||||
- **输入验证**:增强输入验证,确保比例在合理范围内
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **提交数量**:3 个提交
|
||||
- **文件变更**:15 个文件
|
||||
- **代码变更**:+575 行 / -194 行(净增加 381 行)
|
||||
|
||||
### 详细文件变更
|
||||
|
||||
**后端变更**:
|
||||
- `AccountController.kt` - 移除刷新代理钱包接口(-59 行)
|
||||
- `AccountService.kt` - 移除刷新代理钱包方法(-79 行)
|
||||
- `CopyTrading.kt` - 增加 copyRatio 精度
|
||||
- `CopyTradingTemplate.kt` - 增加 copyRatio 精度
|
||||
- `CopyOrderTrackingService.kt` - 优化按比例跟单金额计算逻辑(+45 行)
|
||||
- `MathExt.kt` - 扩展 div 函数支持精度和舍入模式(+20 行)
|
||||
- `V18__increase_copy_ratio_precision.sql` - 数据库迁移脚本(+14 行)
|
||||
|
||||
**前端变更**:
|
||||
- `CopyTradingAdd.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingEdit.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `CopyTradingList.tsx` - 优化比例显示格式
|
||||
- `CopyTradingOrders/EditModal.tsx` - 优化 formatter、优化比例配置(+106 行)
|
||||
- `TemplateAdd.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateEdit.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `TemplateList.tsx` - 优化 formatter、优化比例配置(+65 行)
|
||||
- `utils/index.ts` - 添加 formatNumber 工具函数(+31 行)
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### 数据库变更
|
||||
- **迁移脚本**:`V18__increase_copy_ratio_precision.sql`
|
||||
- **变更内容**:
|
||||
- `copy_trading.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- `copy_trading_templates.copy_ratio`: DECIMAL(10,2) → DECIMAL(20,8)
|
||||
- **自动执行**:升级时会自动执行迁移脚本
|
||||
|
||||
### API 变更
|
||||
- **移除接口**:
|
||||
- `POST /api/accounts/refresh-proxy`
|
||||
- `POST /api/accounts/refresh-all-proxies`
|
||||
- **无新增接口**
|
||||
|
||||
### 前端变更
|
||||
- **工具函数**:新增 `formatNumber()` 函数,用于格式化数字显示
|
||||
- **组件更新**:所有数值输入框统一使用新的 formatter 函数
|
||||
- **显示优化**:跟单模式的比例显示为百分比格式
|
||||
|
||||
## 📝 升级说明
|
||||
|
||||
### 数据库升级
|
||||
本次版本包含数据库迁移脚本,升级时会自动执行:
|
||||
- 自动增加 `copy_ratio` 字段的精度
|
||||
- 现有数据不受影响,精度升级是向后兼容的
|
||||
|
||||
### 配置变更
|
||||
无需额外配置变更。
|
||||
|
||||
### 兼容性
|
||||
- **向后兼容**:所有变更都是向后兼容的
|
||||
- **API 兼容**:移除的接口不影响现有功能(这些接口已不再使用)
|
||||
- **数据兼容**:数据库字段精度升级不会影响现有数据
|
||||
|
||||
## 🎯 主要改进
|
||||
|
||||
1. **优化输入框格式化**:优化数值输入框的显示逻辑
|
||||
2. **优化跟单金额计算**:确保按比例跟单的金额满足最小限制要求
|
||||
3. **提升精度支持**:支持更精确的跟单比例设置(0.01% - 10000%)
|
||||
4. **代码清理**:移除不再使用的刷新代理钱包接口
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- [GitHub Tag](https://github.com/WrBug/PolyHermes/releases/tag/v1.1.5)
|
||||
- [变更日志](https://github.com/WrBug/PolyHermes/compare/v1.1.4...v1.1.5)
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和测试用户的反馈与支持!
|
||||
|
||||
---
|
||||
|
||||
# v1.1.2
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🐛 修复内存泄漏问题
|
||||
- 修复 Retrofit/OkHttpClient 实例重复创建导致的内存泄漏问题
|
||||
- 为不需要认证的 API 创建共享的 OkHttpClient 实例(Gamma API、Data API、GitHub API 等)
|
||||
- 带认证的 CLOB API 按钱包地址缓存(每个账户一个客户端)
|
||||
- RPC API 按 RPC URL 缓存,Builder Relayer API 按 relayerUrl 缓存
|
||||
- 添加 `@PreDestroy` 方法清理缓存,确保资源正确释放
|
||||
- **效果**:内存占用从运行几小时后从 400MB 涨到 1GB+ 变为保持稳定,大幅减少内存占用
|
||||
|
||||
### 📊 市场价格服务优化
|
||||
- 移除降级查询逻辑,仅保留链上 RPC 查询和 CLOB 订单簿查询
|
||||
- 移除 CLOB Trades、Gamma Market Status、Gamma Market Price 查询逻辑
|
||||
- 如果所有数据源都失败,抛出明确的异常信息
|
||||
- 价格截位到 4 位小数(向下截断,不四舍五入)
|
||||
- 简化代码逻辑,提高查询效率和准确性
|
||||
|
||||
### 🔧 代码架构优化
|
||||
- 统一 Gson 使用,改为依赖注入方式
|
||||
- 在 `GsonConfig` 中统一配置 Gson Bean(lenient 模式)
|
||||
- 所有 Service 类通过构造函数注入 Gson 实例
|
||||
- 移除所有 `GsonConverterFactory.create()` 无参调用,统一使用注入的 Gson
|
||||
- 提高代码一致性和可维护性
|
||||
|
||||
### 🗑️ 功能清理
|
||||
- 移除下单失败存储数据库的功能
|
||||
- 删除 `FailedTrade` 实体类和 `FailedTradeRepository`
|
||||
- 从 `CopyOrderTrackingService` 中移除失败交易存储逻辑
|
||||
- 创建 Flyway migration V16 删除 `failed_trade` 表
|
||||
- 下单失败时仅记录日志,不再存储到数据库,简化数据模型
|
||||
|
||||
### 🚀 部署优化
|
||||
- 自动使用当前分支名作为 Docker 版本号
|
||||
- 分支名中的 `/` 自动替换为 `-`(Docker tag 不支持 `/)
|
||||
- `docker-compose.yml` 启用 build args,从环境变量读取版本号
|
||||
- 前端页面将显示当前分支名作为版本号
|
||||
- 如果没有 Git 仓库或获取失败,使用默认值 `dev`
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复 Flyway Migration 问题
|
||||
- 恢复 V1 migration 文件,避免 checksum 不匹配
|
||||
- 保持 `V1__init_database.sql` 的原有内容不变
|
||||
- `failed_trade` 表的删除通过 V16 migration 处理
|
||||
- 确保已有数据库的 migration checksum 保持一致
|
||||
|
||||
### 修复前端编译错误
|
||||
- 修复 `PositionList.tsx` 中引用不存在的 `bestBid` 属性导致的编译错误
|
||||
- 使用 `currentPrice` 替代 `bestBid`,确保前端代码可以正常编译
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 新增智能资金分析文档(`docs/zh/smart-money-analysis.md`)
|
||||
- 详细说明智能资金分析功能的使用方法和策略
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 优化 `RetrofitFactory`,实现客户端实例缓存和复用
|
||||
- 优化 `CopyOrderTrackingService`,移除失败交易相关逻辑
|
||||
- 优化 `OrderStatusUpdateService`,增强订单状态更新功能
|
||||
- 优化 `TelegramNotificationService`,改进通知逻辑
|
||||
- 优化 `PositionCheckService`,简化代码结构
|
||||
- 优化 `PolymarketClobService`,改进 API 调用逻辑
|
||||
|
||||
## 📦 数据库变更
|
||||
|
||||
- 删除 `failed_trade` 表(Migration: V16)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.2
|
||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.1...v1.1.2
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **文件变更**: 29 个文件
|
||||
- **代码变更**: +1597 行 / -678 行
|
||||
- **主要提交**: 8 个提交
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||
|
||||
### ✅ 官方 Docker Hub 镜像
|
||||
|
||||
**官方镜像地址**:`wrbug/polyhermes`
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用官方镜像
|
||||
docker pull wrbug/polyhermes:v1.1.2
|
||||
|
||||
# ❌ 错误:不要使用其他来源的镜像
|
||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||
```
|
||||
|
||||
### 🔗 官方渠道
|
||||
|
||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||
|
||||
---
|
||||
|
||||
# v1.1.1
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🔗 链上 WebSocket 监听优化
|
||||
- 创建 `UnifiedOnChainWsService` 统一管理 WebSocket 连接,所有服务共享同一个连接
|
||||
- 创建 `OnChainWsUtils` 工具类,提取公共的链上 WebSocket 相关功能
|
||||
- 创建 `AccountOnChainMonitorService` 监听账户链上卖出和赎回事件
|
||||
- 优化 `OnChainWsService`,复用公共代码,减少代码重复
|
||||
- 支持通过链上 WebSocket 实时监听账户的卖出和赎回交易,自动更新订单状态
|
||||
|
||||
### 📊 市场状态查询优化
|
||||
- 优化市场结算状态查询,优先使用链上查询 `ConditionalTokens.getCondition`
|
||||
- 如果链上查询失败,自动降级到 Gamma API 查询
|
||||
- 提供更实时和准确的市场结算结果
|
||||
|
||||
### 🔕 自动订单通知优化
|
||||
- 自动生成的订单(AUTO_、AUTO_FIFO_、AUTO_WS_ 前缀)不再发送 Telegram 通知
|
||||
- 优化 `OrderStatusUpdateService`,跳过自动生成订单的通知处理
|
||||
- 减少不必要的通知,提升用户体验
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复移动端 API 健康页面缺少数据显示
|
||||
- 移动端添加 URL 地址显示
|
||||
- 移动端添加状态文本显示(正常/异常/未配置)
|
||||
- 移动端添加消息/状态信息显示
|
||||
- 移动端和桌面端显示信息保持一致
|
||||
|
||||
## 🔧 功能优化
|
||||
|
||||
### 优化 Telegram 推送消息格式
|
||||
- 添加价格和数量截位处理:
|
||||
* 价格保留最多4位小数(截断,不四舍五入)
|
||||
* 数量保留最多2位小数(截断,不四舍五入)
|
||||
- 优化账户信息显示格式:
|
||||
* 有账户名和钱包地址时显示:账户名(0x123...123)
|
||||
* 只有账户名时显示账户名
|
||||
* 只有钱包地址时显示脱敏后的地址
|
||||
* 都没有时显示未知账户
|
||||
|
||||
### 配置优化
|
||||
- 移除 `polygon.rpc.url` 配置,使用 RpcNodeService 统一管理 RPC 节点
|
||||
- 删除无用的 `position.push.polling-interval` 和 `position.push.heartbeat-timeout` 配置项
|
||||
- 修正日志配置中的包名(polyhermes -> polymarketbot)
|
||||
- 更新 `ApiHealthCheckService` 直接使用 `RpcNodeService.getHttpUrl()`
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 统一发布说明文件,使用 RELEASE.md 替代版本化文件(RELEASE_v1.0.1.md、RELEASE_v1.1.0.md)
|
||||
- 更新所有部署文档,移除 POLYGON_RPC_URL 相关说明
|
||||
- 更新所有 Docker Compose 配置文件,移除 POLYGON_RPC_URL 环境变量
|
||||
- 更新所有部署脚本,移除 POLYGON_RPC_URL 环境变量定义
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 重构链上 WebSocket 服务,提取公共代码到 `OnChainWsUtils`
|
||||
- 创建统一的 WebSocket 连接管理服务 `UnifiedOnChainWsService`
|
||||
- 添加链上查询市场结算结果的功能(`BlockchainService.getCondition`)
|
||||
- 添加 ABI 编码/解码工具方法(`EthereumUtils.decodeConditionResult`)
|
||||
- 优化代码结构,减少代码重复,提高可维护性
|
||||
|
||||
---
|
||||
|
||||
# v1.1.0
|
||||
|
||||
## 🚀 主要功能
|
||||
|
||||
### 🔗 链上 WebSocket 实时监听
|
||||
- 实现通过 Polygon RPC `eth_subscribe` 实时监听链上交易
|
||||
- 支持监听 USDC Transfer 和 ERC1155 Transfer 事件
|
||||
- 实现并行监控策略:链上 WebSocket 和轮询同时运行,哪个数据先返回用哪个
|
||||
- 支持通过 `eth_unsubscribe` 取消单个 Leader 的订阅,无需重新连接
|
||||
- 优化 WebSocket 连接管理:只创建一个连接,没有跟单配置时自动取消
|
||||
- 跟单配置生效/失效时及时更新 WebSocket 订阅
|
||||
- 使用 Gson 替换所有 JSON 解析,提高解析稳定性
|
||||
- 添加 Mutex 保证线程安全,防止并发处理导致的数据重复
|
||||
|
||||
### 📊 RPC 节点管理
|
||||
- 实现 RPC 节点管理功能,支持添加、编辑、删除自定义 RPC 节点
|
||||
- 支持 RPC 节点启用/禁用功能,禁用的节点会被自动忽略
|
||||
- 前端添加启用/禁用开关,支持实时切换节点状态
|
||||
- 健康检查只检查启用的节点,提高检查效率
|
||||
- 节点选择时自动过滤禁用的节点
|
||||
|
||||
### 💰 卖出订单价格轮询更新
|
||||
- 添加 `price_updated` 字段到 `sell_match_record` 表,用于标记价格是否已更新
|
||||
- 创建 `OrderStatusUpdateService` 定时任务服务,每 5 秒轮询一次:
|
||||
- 更新卖出订单的实际成交价(通过 orderId 查询订单详情)
|
||||
- 清理已删除账户的订单记录
|
||||
- 支持加权平均价格计算,处理部分成交的订单
|
||||
- 添加 orderId 格式验证:非 0x 开头的直接标记为已更新,0x 开头的等待定时任务更新
|
||||
- 下单完成后不再立即查询价格,直接保存,等待定时任务更新
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复跟单卖出订单的 API 凭证解密问题
|
||||
- 修复 `processSellTrade` 中 API 凭证未解密的问题,与 `processBuyTrade` 保持一致
|
||||
- 确保卖出订单能够正常使用 API 凭证进行认证
|
||||
|
||||
### 修复 SELL 订单精度问题
|
||||
- 修复 SELL 订单的 `makerAmount` 和 `takerAmount` 精度问题:
|
||||
- `makerAmount` (shares) 最多 2 位小数(符合 API 要求)
|
||||
- `takerAmount` (USDC) 最多 4 位小数(符合 API 要求)
|
||||
- 确保订单能够正常提交到 Polymarket API
|
||||
|
||||
## 📚 文档更新
|
||||
|
||||
- 添加 Docker 版本更新说明(中英文)
|
||||
- 添加链上 WebSocket 监听策略文档
|
||||
- 添加跟单逻辑总结文档
|
||||
- 更新部署文档,包含详细的版本更新步骤
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
- 使用 Gson 替换 ObjectMapper,提高 JSON 解析稳定性
|
||||
- `JsonRpcResponse.result` 使用 `JsonElement` 类型,支持灵活的 JSON 结构
|
||||
- 优化 WebSocket 连接管理,减少不必要的连接
|
||||
- 添加线程安全机制,使用 Kotlin Coroutines Mutex
|
||||
- 启用 Spring 定时任务功能(`@EnableScheduling`)
|
||||
|
||||
## 📦 数据库变更
|
||||
|
||||
- 新增 `price_updated` 字段到 `sell_match_record` 表(Migration: V13)
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
- **GitHub Release**: https://github.com/WrBug/PolyHermes/releases/tag/v1.1.1
|
||||
- **完整更新日志**: https://github.com/WrBug/PolyHermes/compare/v1.1.0...v1.1.1
|
||||
- **Docker Hub**: https://hub.docker.com/r/wrbug/polyhermes
|
||||
|
||||
## 📊 统计信息
|
||||
|
||||
- **文件变更**: 32 个文件
|
||||
- **代码变更**: +1872 行 / -1503 行
|
||||
- **主要提交**: 7 个提交
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**请务必使用官方 Docker 镜像源,避免财产损失!**
|
||||
|
||||
### ✅ 官方 Docker Hub 镜像
|
||||
|
||||
**官方镜像地址**:`wrbug/polyhermes`
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用官方镜像
|
||||
docker pull wrbug/polyhermes:v1.1.1
|
||||
|
||||
# ❌ 错误:不要使用其他来源的镜像
|
||||
# 任何非官方来源的镜像都可能包含恶意代码,导致您的私钥和资产被盗
|
||||
```
|
||||
|
||||
### 🔗 官方渠道
|
||||
|
||||
请通过以下**唯一官方渠道**获取 PolyHermes:
|
||||
|
||||
* **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
||||
* **Twitter**:@polyhermes
|
||||
* **Telegram 群组**:https://t.me/polyhermes
|
||||
|
||||
---
|
||||
|
||||
**⭐ 如果这个项目对您有帮助,请给个 Star 支持一下!**
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# PolyHermes v2.0.0 Release Notes
|
||||
|
||||
## 🎉 重大更新
|
||||
|
||||
PolyHermes v2.0.0 是一个重要版本更新,带来了系统动态更新功能、优化的用户体验和多项技术改进。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新功能
|
||||
|
||||
### 🔄 系统动态更新(核心功能)
|
||||
|
||||
**无需重启 Docker 容器即可更新系统**,大幅提升部署和维护效率。
|
||||
|
||||
- ✅ **在线更新**:在 Web UI 中一键检查并应用更新,无需手动操作
|
||||
- ✅ **零停机更新**:更新过程约 30-60 秒,系统自动处理,无需重启容器
|
||||
- ✅ **自动回滚**:更新失败时自动恢复到旧版本,确保系统稳定性
|
||||
- ✅ **版本管理**:清晰显示当前版本和可用更新,支持 Pre-release 版本检测
|
||||
- ✅ **更新内容展示**:支持 Markdown 格式的更新说明,美观易读
|
||||
|
||||
**技术特性**:
|
||||
- 独立的 Python Flask 更新服务(端口 9090),与主应用隔离
|
||||
- 单一更新包(tar.gz),包含前后端完整更新
|
||||
- 自动备份和版本管理
|
||||
- 管理员权限验证,确保安全性
|
||||
|
||||
### 📦 Release 管理工具
|
||||
|
||||
- **自动化发布脚本** (`create-release.sh`):
|
||||
- 自动创建 Git 标签
|
||||
- 发布 GitHub Release
|
||||
- 支持 Pre-release 标记
|
||||
- 自动拼接版本号后缀(Pre-release 自动添加 `-beta`)
|
||||
- 支持非交互模式,便于 CI/CD 集成
|
||||
|
||||
### 🎨 版本号显示优化
|
||||
|
||||
- **Tag 格式显示**:版本号使用 Git Tag 格式(如 `v2.0.0-beta`)
|
||||
- **智能颜色提示**:
|
||||
- 🟡 **黄色 Tag**:有新版本可用(点击可跳转到系统更新页面)
|
||||
- 🟢 **绿色 Tag**:当前已是最新版本
|
||||
- **镂空样式**:更小巧美观的版本号标签
|
||||
- **自动检查**:系统自动检查更新,有新版本时在导航栏显示提示
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX 优化
|
||||
|
||||
### 系统更新页面
|
||||
|
||||
- **美化界面**:全新的渐变背景、卡片样式和图标设计
|
||||
- **Markdown 支持**:更新内容支持完整的 Markdown 渲染(标题、列表、代码块、表格等)
|
||||
- **进度显示**:美观的进度条和状态提示
|
||||
- **优化布局**:系统更新模块移至系统设置页面最上方,更易访问
|
||||
|
||||
### 版本号显示
|
||||
|
||||
- 使用镂空 Tag 样式,字号 8px
|
||||
- 与标题垂直居中对齐
|
||||
- 响应式设计,完美支持移动端和桌面端
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术改进
|
||||
|
||||
### 构建系统
|
||||
|
||||
- **修复 Docker 构建问题**:
|
||||
- 修复 `BUILD_IN_DOCKER=false` 时找不到前端产物的问题
|
||||
- 优化 `.dockerignore` 配置,确保外部构建产物可被使用
|
||||
- 修复 GitHub Actions 构建流程
|
||||
|
||||
- **版本号注入**:
|
||||
- 修复前端构建时版本号未正确传递的问题
|
||||
- 支持在构建时注入 Git Tag 和版本信息
|
||||
|
||||
- **Gradle Wrapper**:
|
||||
- 修复 GitHub Actions 构建错误
|
||||
- 正确配置 Gradle Wrapper JAR
|
||||
|
||||
### 代码质量
|
||||
|
||||
- 修复 TypeScript 编译错误
|
||||
- 清理未使用的导入和组件
|
||||
- 优化代码结构
|
||||
|
||||
---
|
||||
|
||||
## 📝 新增文档
|
||||
|
||||
- `docs/zh/DYNAMIC_UPDATE.md` - 动态更新技术方案文档
|
||||
- `docs/zh/DOCKER_VERSION.md` - Docker 版本管理说明
|
||||
- `docs/zh/DYNAMIC_UPDATE_CHECK.md` - 动态更新检查机制文档
|
||||
- `scripts/README_RELEASE.md` - Release 脚本使用说明
|
||||
- `scripts/CHANGELOG_TEMPLATE.md` - 更新日志模板
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级指南
|
||||
|
||||
### 从 v1.1.16 升级到 v2.0.0
|
||||
|
||||
#### 方式一:使用动态更新功能(推荐)
|
||||
|
||||
1. 登录系统,进入 **系统设置** → **系统更新**
|
||||
2. 点击 **检查更新**
|
||||
3. 如果有新版本,点击 **立即升级**
|
||||
4. 等待更新完成(约 30-60 秒)
|
||||
5. 页面会自动刷新,更新完成
|
||||
|
||||
#### 方式二:重新部署 Docker 容器
|
||||
|
||||
```bash
|
||||
# 1. 停止当前容器
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# 2. 拉取新版本镜像
|
||||
docker pull wrbug/polyhermes:v2.0.0
|
||||
|
||||
# 3. 更新 docker-compose.prod.yml 中的镜像标签
|
||||
# image: wrbug/polyhermes:v2.0.0
|
||||
|
||||
# 4. 重新启动
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 注意事项
|
||||
|
||||
- ⚠️ **数据备份**:虽然更新不会删除数据,但建议在更新前备份数据库
|
||||
- ⚠️ **权限要求**:执行动态更新需要管理员权限
|
||||
- ✅ **向后兼容**:v2.0.0 完全兼容 v1.1.16 的数据结构和配置
|
||||
|
||||
---
|
||||
|
||||
## 📊 变更统计
|
||||
|
||||
- **新增文件**:17 个
|
||||
- **修改文件**:13 个
|
||||
- **代码变更**:+5251 行,-163 行
|
||||
|
||||
### 主要新增文件
|
||||
|
||||
- `docker/update-service.py` - 更新服务(Python Flask)
|
||||
- `frontend/src/pages/SystemUpdate.tsx` - 系统更新页面
|
||||
- `create-release.sh` - Release 创建脚本
|
||||
- `docs/zh/DYNAMIC_UPDATE.md` - 动态更新技术文档
|
||||
|
||||
---
|
||||
|
||||
## 🐛 修复的问题
|
||||
|
||||
- 修复 Docker 构建时找不到前端产物的问题
|
||||
- 修复前端构建时版本号未正确传递的问题
|
||||
- 修复 GitHub Actions 构建错误(Gradle Wrapper)
|
||||
- 修复 TypeScript 编译错误
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [动态更新技术方案](docs/zh/DYNAMIC_UPDATE.md)
|
||||
- [Docker 版本管理](docs/zh/DOCKER_VERSION.md)
|
||||
- [部署指南](docs/zh/DEPLOYMENT.md)
|
||||
- [Release 脚本使用说明](scripts/README_RELEASE.md)
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有使用 PolyHermes 的用户和贡献者!
|
||||
|
||||
---
|
||||
|
||||
**下载地址**:
|
||||
- Docker Hub: `wrbug/polyhermes:v2.0.0`
|
||||
- GitHub Releases: [v2.0.0](https://github.com/WrBug/PolyHermes/releases/tag/v2.0.0)
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
# PolyHermes v2.0.1 Release Notes
|
||||
|
||||
## 📋 版本信息
|
||||
- **版本号**: v2.0.1
|
||||
- **发布日期**: 2026-01-28
|
||||
- **基础版本**: v2.0.0
|
||||
|
||||
## 🎯 改动摘要
|
||||
|
||||
本次版本主要修复了订单状态检查、RPC 节点管理、订单金额计算等关键问题,提升了系统稳定性和用户体验。
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 1. 修复订单状态检查中缓存清除导致计时重置的问题
|
||||
|
||||
**问题描述**:
|
||||
- 订单详情为 `null` 时,缓存清除时机不当导致计时被重置
|
||||
- 订单超过 60 秒后无法正常删除
|
||||
- 部分卖出订单等待时间过长(之前等待 1 小时)
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复缓存清除时机问题,避免计时被重置
|
||||
- ✅ 统一部分卖出和未成交订单的删除逻辑,都使用 60 秒重试窗口
|
||||
- ✅ 删除未使用的常量 `PARTIAL_SOLD_CLEANUP_WINDOW_MS`
|
||||
- ✅ 优化日志输出,区分部分卖出和未成交订单的日志信息
|
||||
|
||||
**影响范围**:
|
||||
- `OrderStatusUpdateService.kt` - 订单状态更新服务
|
||||
|
||||
**提交**: 7e87965
|
||||
|
||||
---
|
||||
|
||||
### 2. 修复禁用 RPC 节点后仍被使用的问题
|
||||
|
||||
**问题描述**:
|
||||
- 禁用 RPC 节点后,节点仍可能被系统使用
|
||||
- 节点状态更新不及时
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复节点禁用逻辑,确保禁用后立即生效
|
||||
- ✅ 优化节点状态检查机制
|
||||
- ✅ 改进节点选择逻辑
|
||||
|
||||
**影响范围**:
|
||||
- `RpcNodeService.kt` - RPC 节点服务
|
||||
|
||||
**提交**: dd39e59
|
||||
|
||||
---
|
||||
|
||||
### 3. 修复订单金额计算和价格范围验证问题
|
||||
|
||||
**问题描述**:
|
||||
- 订单金额计算可能存在精度问题
|
||||
- 价格范围验证逻辑不完善
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复订单金额计算逻辑
|
||||
- ✅ 完善价格范围验证
|
||||
- ✅ 优化数值计算精度处理
|
||||
|
||||
**影响范围**:
|
||||
- `OrderSigningService.kt` - 订单签名服务
|
||||
|
||||
**提交**: 2b20f4b
|
||||
|
||||
---
|
||||
|
||||
### 4. 修复系统更新 API 路由和改进健康检查逻辑
|
||||
|
||||
**问题描述**:
|
||||
- 系统更新 API 路由可能存在问题
|
||||
- 健康检查逻辑需要优化
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复系统更新 API 路由
|
||||
- ✅ 改进健康检查逻辑
|
||||
- ✅ 优化错误处理
|
||||
|
||||
**提交**: 4aa85a9
|
||||
|
||||
---
|
||||
|
||||
### 5. 修复 Docker 构建相关问题
|
||||
|
||||
**问题描述**:
|
||||
- 本地 Docker 构建时 `frontend/dist` 和 `backend/build` 不存在导致构建失败
|
||||
- Dockerfile 中前端产物复制逻辑有问题
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复本地 Docker 构建时目录不存在的问题
|
||||
- ✅ 修复 Dockerfile 中前端产物复制逻辑
|
||||
- ✅ 优化构建流程
|
||||
|
||||
**影响范围**:
|
||||
- `Dockerfile` - Docker 构建文件
|
||||
- `deploy.sh` - 部署脚本
|
||||
|
||||
**提交**: 419c68c, 8889803
|
||||
|
||||
---
|
||||
|
||||
### 6. 补充缺失的多语言 key
|
||||
|
||||
**问题描述**:
|
||||
- 部分多语言 key 缺失,导致界面显示异常
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 补充缺失的多语言 key
|
||||
- ✅ 完善多语言支持
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/locales/` - 多语言文件
|
||||
|
||||
**提交**: 2efc04a
|
||||
|
||||
---
|
||||
|
||||
## 🔧 优化改进
|
||||
|
||||
### 优化过滤原因文案的数值显示格式
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 优化过滤原因文案的数值显示格式
|
||||
- ✅ 新增日期工具函数,统一日期格式化
|
||||
- ✅ 提升用户体验
|
||||
|
||||
**影响范围**:
|
||||
- `CopyTradingFilterService.kt` - 跟单过滤服务
|
||||
- `DateUtils.kt` - 日期工具类(新增)
|
||||
|
||||
**提交**: e115d45
|
||||
|
||||
---
|
||||
|
||||
## 📊 文件变更统计
|
||||
|
||||
- **修改文件数**: 10+
|
||||
- **新增文件数**: 1 (DateUtils.kt)
|
||||
- **新增行数**: 200+
|
||||
- **删除行数**: 100+
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级建议
|
||||
|
||||
1. **直接部署**:无需特殊操作,直接部署即可
|
||||
2. **验证订单处理**:建议验证订单状态检查是否正常工作
|
||||
3. **检查 RPC 节点**:确认 RPC 节点状态管理是否正常
|
||||
4. **验证订单金额**:确认订单金额计算是否正确
|
||||
|
||||
---
|
||||
|
||||
## 📝 完整提交列表
|
||||
|
||||
- 7e87965 - fix: 修复订单状态检查中缓存清除导致计时重置的问题
|
||||
- e115d45 - refactor: 优化过滤原因文案的数值显示格式
|
||||
- dd39e59 - fix: 修复禁用RPC节点后仍被使用的问题
|
||||
- 2efc04a - fix: 补充缺失的多语言key
|
||||
- 2b20f4b - fix: 修复订单金额计算和价格范围验证问题
|
||||
- 419c68c - fix: 修复本地 Docker 构建时 frontend/dist 和 backend/build 不存在的问题
|
||||
- 8889803 - fix: 修复 Dockerfile 中前端产物复制逻辑
|
||||
- 4aa85a9 - fix: 修复系统更新 API 路由和改进健康检查逻辑
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和用户的支持与反馈!
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# PolyHermes v2.0.2 Release Notes
|
||||
|
||||
## 📋 版本信息
|
||||
- **版本号**: v2.0.2
|
||||
- **发布日期**: 2026-01-29
|
||||
- **基础版本**: v2.0.1
|
||||
|
||||
## 🎯 改动摘要
|
||||
|
||||
本次版本主要修复了买入订单金额精度问题,并优化了构建流程和通知机制。
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 1. 修复买入订单金额精度问题
|
||||
|
||||
**问题描述**:
|
||||
- 市场买入订单创建时出现 `invalid amounts` 错误
|
||||
- Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
|
||||
- 之前的实现不符合 API 要求,导致订单创建失败
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复市场买入订单 makerAmount 和 takerAmount 的精度限制
|
||||
- ✅ makerAmount (USDC) 限制为最多 2 位小数(之前为 4 位)
|
||||
- ✅ takerAmount (shares) 限制为最多 4 位小数(之前为 2 位)
|
||||
- ✅ 符合 Polymarket API 的要求,解决 'invalid amounts' 错误
|
||||
|
||||
**影响范围**:
|
||||
- `OrderSigningService.kt` - 订单签名服务
|
||||
|
||||
**提交**: 42472f6
|
||||
|
||||
---
|
||||
|
||||
## 🔧 优化改进
|
||||
|
||||
### 优化构建流程和通知机制
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 添加 workflow_dispatch 支持手动触发构建
|
||||
- ✅ Release 事件默认只打包产物(在线升级),不构建 Docker
|
||||
- ✅ 手动触发可选择构建类型:在线升级或 Docker 升级
|
||||
- ✅ 优化 Telegram 通知文案,区分构建类型和升级路径
|
||||
- ✅ 在线升级任务不发送开始通知,只发送完成通知
|
||||
- ✅ 手动触发时产物上传为 Artifact,Release 事件上传到 Release Assets
|
||||
|
||||
**影响范围**:
|
||||
- `.github/workflows/docker-build.yml` - GitHub Actions 工作流
|
||||
|
||||
**提交**: e5992b5
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 潜在问题和注意事项
|
||||
|
||||
### 1. 买入订单金额精度调整可能带来的影响
|
||||
|
||||
**问题说明**:
|
||||
本次修复调整了买入订单的金额精度限制,可能会带来以下影响:
|
||||
|
||||
#### 1.1 makerAmount 精度降低(4 位 → 2 位小数)
|
||||
|
||||
**影响**:
|
||||
- **订单金额可能被向下舍入**:由于 makerAmount (USDC) 从 4 位小数降低到 2 位小数,订单金额可能会被向下舍入
|
||||
- **实际支付金额可能略低于预期**:例如,如果计算出的金额是 `0.5985 USDC`,现在会被舍入为 `0.59 USDC`,实际支付金额可能比预期少 `0.0085 USDC`
|
||||
- **可能影响固定金额模式的跟单**:在固定金额模式下,如果金额被舍入,实际买入的数量可能会略少于预期
|
||||
|
||||
**建议**:
|
||||
- 在固定金额模式下,建议设置稍大一点的金额,以补偿可能的舍入损失
|
||||
- 监控订单创建情况,确认金额是否符合预期
|
||||
- 如果发现金额差异较大,可以适当调整跟单配置
|
||||
|
||||
#### 1.2 takerAmount 精度提高(2 位 → 4 位小数)
|
||||
|
||||
**影响**:
|
||||
- **订单数量精度提高**:takerAmount (shares) 从 2 位小数提高到 4 位小数,可以更精确地指定买入数量
|
||||
- **可能增加订单复杂度**:更高的精度可能导致一些边缘情况,需要确保数量计算正确
|
||||
|
||||
**建议**:
|
||||
- 验证订单数量是否符合预期
|
||||
- 确认跟单数量计算逻辑是否正确
|
||||
|
||||
#### 1.3 订单创建成功率变化
|
||||
|
||||
**影响**:
|
||||
- **修复前**:订单创建可能因为精度问题失败
|
||||
- **修复后**:订单创建成功率应该提高,但金额可能略有变化
|
||||
|
||||
**建议**:
|
||||
- 升级后监控订单创建成功率
|
||||
- 对比升级前后的订单金额,确认差异是否在可接受范围内
|
||||
|
||||
### 2. 构建流程变更
|
||||
|
||||
**影响**:
|
||||
- Release 事件默认只打包产物,不构建 Docker
|
||||
- 如果需要 Docker 镜像,需要手动触发构建并选择 Docker 升级类型
|
||||
|
||||
**建议**:
|
||||
- 了解新的构建流程,根据需要选择合适的构建方式
|
||||
- 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
|
||||
|
||||
---
|
||||
|
||||
## 📊 文件变更统计
|
||||
|
||||
- **修改文件数**: 2
|
||||
- **新增行数**: 127
|
||||
- **删除行数**: 27
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级建议
|
||||
|
||||
1. **测试买入订单创建**:
|
||||
- 升级后先测试少量买入订单,确认金额和数量是否符合预期
|
||||
- 特别关注固定金额模式的跟单,确认金额是否被正确舍入
|
||||
|
||||
2. **监控订单创建成功率**:
|
||||
- 升级后监控订单创建成功率,确认是否解决了 `invalid amounts` 错误
|
||||
- 对比升级前后的订单金额,确认差异是否在可接受范围内
|
||||
|
||||
3. **验证金额计算**:
|
||||
- 验证 makerAmount 是否被正确限制为 2 位小数
|
||||
- 验证 takerAmount 是否被正确限制为 4 位小数
|
||||
|
||||
4. **了解构建流程变更**:
|
||||
- 了解新的构建流程,根据需要选择合适的构建方式
|
||||
- 如果需要 Docker 镜像,使用 workflow_dispatch 手动触发
|
||||
|
||||
---
|
||||
|
||||
## 📝 完整提交列表
|
||||
|
||||
- 42472f6 - fix: 修复买入订单金额精度问题
|
||||
- e5992b5 - feat(workflow): 优化构建流程和通知机制
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和用户的支持与反馈!
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# PolyHermes v2.0.3 Release Notes
|
||||
|
||||
## 📋 版本信息
|
||||
- **版本号**: v2.0.3
|
||||
- **发布日期**: 2026-01-31
|
||||
- **基础版本**: v2.0.2
|
||||
|
||||
## 🎯 改动摘要
|
||||
|
||||
本次版本主要优化了用户界面显示,包括数值格式化、Leader列表优化、移除不必要的配置项,提升了用户体验。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新功能
|
||||
|
||||
### 1. 为所有数值显示添加千分位分隔符
|
||||
|
||||
**功能描述**:
|
||||
- ✅ 重构 `formatNumber` 和 `formatUSDC` 函数,默认添加千分位分隔符
|
||||
- ✅ 所有数值(金额、数量、价格等)现在默认显示千分位
|
||||
- ✅ 自动去除尾随零,提升可读性
|
||||
- ✅ 示例:`1234567.89` 显示为 `1,234,567.89`
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/utils/index.ts` - 工具函数
|
||||
- `frontend/src/pages/Statistics.tsx` - 统计页面
|
||||
- `frontend/src/pages/CopyTradingStatistics.tsx` - 跟单统计页面
|
||||
- `frontend/src/pages/PositionList.tsx` - 持仓列表
|
||||
- `frontend/src/pages/AccountList.tsx` - 账户列表
|
||||
|
||||
**提交**: 40081c2
|
||||
|
||||
---
|
||||
|
||||
### 2. 在创建跟单配置时显示Leader资产信息
|
||||
|
||||
**功能描述**:
|
||||
- ✅ 选择Leader后自动获取并显示资产信息
|
||||
- ✅ 显示总资产、可用余额、仓位资产
|
||||
- ✅ 使用Card和Statistic组件美观展示
|
||||
- ✅ 支持中英文多语言
|
||||
- ✅ 使用formatUSDC格式化显示金额
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加跟单配置弹窗
|
||||
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑跟单配置弹窗
|
||||
- `frontend/src/locales/**/common.json` - 多语言文件
|
||||
|
||||
**提交**: 390b3ee
|
||||
|
||||
---
|
||||
|
||||
### 3. Leader列表显示仓位资产
|
||||
|
||||
**功能描述**:
|
||||
- ✅ Leader列表新增仓位资产显示
|
||||
- ✅ 显示Leader的总资产、可用余额、仓位资产
|
||||
- ✅ 优化资产信息展示方式
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderDto.kt` - Leader DTO
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
|
||||
|
||||
**提交**: 3350039
|
||||
|
||||
---
|
||||
|
||||
## 🔧 优化改进
|
||||
|
||||
### 1. Leader列表优化
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 后端过滤价值为0的仓位
|
||||
- ✅ 持仓列表显示市场名称而非ID
|
||||
- ✅ 列表移除分类和创建时间列
|
||||
- ✅ 文案'跟单关系数'改为'跟单数'
|
||||
- ✅ 持仓DTO添加title字段
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaders/LeaderService.kt` - Leader服务
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt` - 区块链服务
|
||||
|
||||
**提交**: 0bdc0c7
|
||||
|
||||
---
|
||||
|
||||
### 2. 列表只显示可用余额
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 账户列表和Leader列表只显示可用余额
|
||||
- ✅ 简化界面,减少信息冗余
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/AccountList.tsx` - 账户列表
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt` - 账户服务
|
||||
|
||||
**提交**: 17eea01
|
||||
|
||||
---
|
||||
|
||||
### 3. 移除仓位资产列
|
||||
|
||||
**改进内容**:
|
||||
- ✅ 移除不必要的仓位资产列显示
|
||||
- ✅ 简化界面布局
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/PositionList.tsx` - 持仓列表
|
||||
|
||||
**提交**: 6980781
|
||||
|
||||
---
|
||||
|
||||
## 🗑️ 移除功能
|
||||
|
||||
### 移除跟单最大仓位数量(maxPositionCount)配置
|
||||
|
||||
**移除原因**:
|
||||
- 该配置项使用频率低,且增加了系统复杂度
|
||||
- 简化跟单配置,提升用户体验
|
||||
|
||||
**移除内容**:
|
||||
- ✅ 数据库:创建迁移文件 V26 删除 `max_position_count` 字段
|
||||
- ✅ 后端:移除实体类、DTO、服务中的 `maxPositionCount` 相关代码
|
||||
- ✅ 后端:移除 `FilterResult` 中的 `FAILED_MAX_POSITION_COUNT` 状态
|
||||
- ✅ 后端:移除 `CopyTradingFilterService` 中的最大仓位数量检查逻辑
|
||||
- ✅ 前端:移除类型定义、表单字段和国际化翻译
|
||||
- ✅ 前端:移除过滤订单列表中的 `MAX_POSITION_COUNT` 类型
|
||||
|
||||
**影响范围**:
|
||||
- `backend/src/main/resources/db/migration/V26__remove_max_position_count.sql` - 数据库迁移
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CopyTrading.kt` - 实体类
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/dto/CopyTradingDto.kt` - DTO
|
||||
- `backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/configs/CopyTradingFilterService.kt` - 过滤服务
|
||||
- `frontend/src/pages/CopyTradingOrders/AddModal.tsx` - 添加表单
|
||||
- `frontend/src/pages/CopyTradingOrders/EditModal.tsx` - 编辑表单
|
||||
- `frontend/src/types/index.ts` - 类型定义
|
||||
|
||||
**提交**: e8fd1b5
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug 修复
|
||||
|
||||
### 修复TypeScript类型错误
|
||||
|
||||
**修复内容**:
|
||||
- ✅ 修复编译时的TypeScript类型错误
|
||||
- ✅ 修复Spin导入问题
|
||||
- ✅ 修复Table fixed类型问题
|
||||
- ✅ 修复size类型问题
|
||||
|
||||
**影响范围**:
|
||||
- `frontend/src/pages/LeaderList.tsx` - Leader列表页面
|
||||
|
||||
**提交**: 8097660
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 潜在问题和注意事项
|
||||
|
||||
### 1. 数值格式化变更
|
||||
|
||||
**影响**:
|
||||
- 所有数值现在默认显示千分位分隔符
|
||||
- 如果之前有代码依赖特定的数值格式,可能需要调整
|
||||
|
||||
**建议**:
|
||||
- 检查是否有代码依赖特定的数值格式
|
||||
- 确认数值显示是否符合预期
|
||||
|
||||
### 2. 移除maxPositionCount配置
|
||||
|
||||
**影响**:
|
||||
- 如果之前使用了最大仓位数量限制功能,升级后将不再可用
|
||||
- 需要手动调整跟单策略
|
||||
|
||||
**建议**:
|
||||
- 升级前检查是否有跟单配置使用了最大仓位数量限制
|
||||
- 如有需要,可以手动调整跟单策略
|
||||
|
||||
### 3. Leader列表显示变更
|
||||
|
||||
**影响**:
|
||||
- Leader列表现在只显示价值大于0的仓位
|
||||
- 列表布局和显示内容有所调整
|
||||
|
||||
**建议**:
|
||||
- 升级后检查Leader列表显示是否符合预期
|
||||
- 确认仓位信息是否正确显示
|
||||
|
||||
---
|
||||
|
||||
## 📊 文件变更统计
|
||||
|
||||
- **修改文件数**: 28
|
||||
- **新增行数**: 1490
|
||||
- **删除行数**: 897
|
||||
|
||||
---
|
||||
|
||||
## 🔄 升级建议
|
||||
|
||||
1. **检查数值显示**:
|
||||
- 升级后检查所有数值显示是否符合预期
|
||||
- 确认千分位分隔符显示正确
|
||||
|
||||
2. **检查跟单配置**:
|
||||
- 如果有使用最大仓位数量限制的配置,需要手动调整
|
||||
- 确认跟单功能正常工作
|
||||
|
||||
3. **检查Leader列表**:
|
||||
- 升级后检查Leader列表显示是否正确
|
||||
- 确认仓位信息是否完整
|
||||
|
||||
4. **数据库迁移**:
|
||||
- 升级时会自动执行数据库迁移 V26
|
||||
- 迁移会删除 `max_position_count` 字段
|
||||
- 建议在升级前备份数据库
|
||||
|
||||
---
|
||||
|
||||
## 📝 完整提交列表
|
||||
|
||||
- 40081c2 - feat: 为所有数值显示添加千分位分隔符
|
||||
- e8fd1b5 - 移除跟单最大仓位数量(maxPositionCount)配置
|
||||
- 390b3ee - feat: 在创建跟单配置时显示Leader资产信息
|
||||
- 8097660 - fix: 修复TypeScript类型错误
|
||||
- 17eea01 - refactor: 列表只显示可用余额
|
||||
- 6980781 - refactor: 移除仓位资产列
|
||||
- 3350039 - feat: Leader列表显示仓位资产
|
||||
- 0bdc0c7 - feat: Leader列表优化
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和用户的支持与反馈!
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# 清理 Flyway V29 失败记录的脚本
|
||||
|
||||
echo "=== 清理 Flyway V29 失败记录 ==="
|
||||
echo ""
|
||||
echo "请确保 MySQL 正在运行,然后输入数据库密码"
|
||||
echo ""
|
||||
|
||||
# 数据库配置
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="3306"
|
||||
DB_NAME="polymarket_bot"
|
||||
DB_USER="root"
|
||||
|
||||
# 检查 MySQL 命令是否可用
|
||||
if ! command -v mysql &> /dev/null; then
|
||||
echo "❌ 错误: 未找到 mysql 命令"
|
||||
echo ""
|
||||
echo "请使用数据库客户端(如 Navicat、DataGrip 等)执行以下 SQL:"
|
||||
echo ""
|
||||
echo "-- 1. 查看 Flyway 历史记录"
|
||||
echo "SELECT version, description, installed_on, success "
|
||||
echo "FROM flyway_schema_history "
|
||||
echo "WHERE version >= 28"
|
||||
echo "ORDER BY installed_rank;"
|
||||
echo ""
|
||||
echo "-- 2. 删除 V29 的失败记录"
|
||||
echo "DELETE FROM flyway_schema_history WHERE version = '29';"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 执行清理
|
||||
echo "正在连接数据库..."
|
||||
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p "$DB_NAME" << 'EOF'
|
||||
-- 查看当前状态
|
||||
SELECT '=== 当前 Flyway 历史记录 ===' as '';
|
||||
SELECT version, description, installed_on, success
|
||||
FROM flyway_schema_history
|
||||
WHERE version >= 28
|
||||
ORDER BY installed_rank;
|
||||
|
||||
-- 删除 V29 失败记录
|
||||
SELECT '=== 删除 V29 记录 ===' as '';
|
||||
DELETE FROM flyway_schema_history WHERE version = '29';
|
||||
|
||||
-- 确认删除结果
|
||||
SELECT CONCAT('已删除 ', ROW_COUNT(), ' 条记录') as result;
|
||||
|
||||
-- 再次查看状态
|
||||
SELECT '=== 清理后的 Flyway 历史记录 ===' as '';
|
||||
SELECT version, description, installed_on, success
|
||||
FROM flyway_schema_history
|
||||
WHERE version >= 28
|
||||
ORDER BY installed_rank;
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "✅ 清理完成!现在可以重启应用了"
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 币安现货公开 API(K 线等)
|
||||
* Base URL: https://api.binance.com
|
||||
* 文档: https://developers.binance.com/docs/binance-spot-api-docs/rest-api
|
||||
*/
|
||||
interface BinanceApi {
|
||||
|
||||
/**
|
||||
* K 线数据
|
||||
* 返回每根 K 线: [openTime, open, high, low, close, volume, closeTime, ...]
|
||||
*/
|
||||
@GET("/api/v3/klines")
|
||||
fun getKlines(
|
||||
@Query("symbol") symbol: String,
|
||||
@Query("interval") interval: String,
|
||||
@Query("limit") limit: Int = 30,
|
||||
@Query("startTime") startTime: Long? = null,
|
||||
@Query("endTime") endTime: Long? = null
|
||||
): Call<List<List<Any>>>
|
||||
}
|
||||
@@ -41,6 +41,17 @@ interface BuilderRelayerApi {
|
||||
@Query("address") address: String,
|
||||
@Query("type") type: String
|
||||
): Response<NoncePayload>
|
||||
|
||||
/**
|
||||
* 获取 Relay Payload(PROXY 类型执行时使用)
|
||||
* GET /relay-payload?address={address}&type=PROXY
|
||||
* 参考: builder-relayer-client endpoints GET_RELAY_PAYLOAD
|
||||
*/
|
||||
@GET("/relay-payload")
|
||||
suspend fun getRelayPayload(
|
||||
@Query("address") address: String,
|
||||
@Query("type") type: String
|
||||
): Response<RelayPayload>
|
||||
|
||||
/**
|
||||
* 获取交易状态
|
||||
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
|
||||
/**
|
||||
* 签名参数
|
||||
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
|
||||
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
|
||||
*/
|
||||
data class SignatureParams(
|
||||
@SerializedName("gasPrice")
|
||||
@@ -114,7 +126,19 @@ interface BuilderRelayerApi {
|
||||
val gasToken: String? = null,
|
||||
|
||||
@SerializedName("refundReceiver")
|
||||
val refundReceiver: String? = null
|
||||
val refundReceiver: String? = null,
|
||||
|
||||
@SerializedName("relayerFee")
|
||||
val relayerFee: String? = null,
|
||||
|
||||
@SerializedName("gasLimit")
|
||||
val gasLimit: String? = null,
|
||||
|
||||
@SerializedName("relayHub")
|
||||
val relayHub: String? = null,
|
||||
|
||||
@SerializedName("relay")
|
||||
val relay: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -142,6 +166,17 @@ interface BuilderRelayerApi {
|
||||
@SerializedName("nonce")
|
||||
val nonce: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Relay Payload(PROXY 执行时获取 relay 地址与 nonce)
|
||||
* 参考: builder-relayer-client types RelayPayload
|
||||
*/
|
||||
data class RelayPayload(
|
||||
@SerializedName("address")
|
||||
val address: String,
|
||||
@SerializedName("nonce")
|
||||
val nonce: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Relayer 交易详情
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.api
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
@@ -164,10 +165,10 @@ interface PolymarketClobApi {
|
||||
|
||||
/**
|
||||
* 获取服务器时间
|
||||
* 端点: /time
|
||||
* 端点: /time 返回纯数字(Unix 时间戳),非 JSON
|
||||
*/
|
||||
@GET("/time")
|
||||
suspend fun getServerTime(): Response<ServerTimeResponse>
|
||||
suspend fun getServerTime(): Response<ResponseBody>
|
||||
}
|
||||
|
||||
// 请求和响应数据类
|
||||
@@ -334,7 +335,8 @@ data class TradeResponse(
|
||||
val timestamp: String, // ISO 8601 格式字符串或时间戳
|
||||
val user: String?,
|
||||
val outcomeIndex: Int? = null, // 结果索引(0=YES, 1=NO)
|
||||
val outcome: String? = null // 结果名称(如 "Up", "Down")
|
||||
val outcome: String? = null, // 结果名称(如 "Up", "Down")
|
||||
val tokenId: String? = null // CLOB tokenId(链上解析时从 ERC1155 取得,与 Gamma clobTokenIds 一致,用于下单)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -363,13 +365,6 @@ data class ApiKeyResponse(
|
||||
val passphrase: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 服务器时间响应
|
||||
*/
|
||||
data class ServerTimeResponse(
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 费率响应
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.api
|
||||
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
@@ -26,23 +27,56 @@ interface PolymarketGammaApi {
|
||||
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||
@Query("include_tag") includeTag: Boolean? = null
|
||||
): Response<List<MarketResponse>>
|
||||
|
||||
/**
|
||||
* 根据 slug 获取事件(用于 5/15 分钟加密市场)
|
||||
* GET /events/slug/{slug},如 btc-updown-5m-1771007400
|
||||
* 返回事件含 markets(conditionId、endDate、clobTokenIds 等)
|
||||
*/
|
||||
@GET("/events/slug/{slug}")
|
||||
suspend fun getEventBySlug(@Path("slug") slug: String): Response<GammaEventBySlugResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gamma 按 slug 返回的事件结构
|
||||
*/
|
||||
data class GammaEventBySlugResponse(
|
||||
val id: String? = null,
|
||||
val slug: String? = null,
|
||||
val title: String? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val markets: List<GammaEventMarketItem>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件下的市场项(5/15 分钟市场为二元,通常两个 outcome)
|
||||
*/
|
||||
data class GammaEventMarketItem(
|
||||
val conditionId: String? = null,
|
||||
val question: String? = null,
|
||||
val endDate: String? = null,
|
||||
val startDate: String? = null,
|
||||
val clobTokenIds: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 事件响应(从 MarketResponse.events 解析)
|
||||
* Gamma API Event 含 negRisk,用于判断是否使用 Neg Risk Exchange 签约
|
||||
*/
|
||||
data class EventResponse(
|
||||
val id: String? = null,
|
||||
val ticker: String? = null,
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val slug: String? = null,
|
||||
val title: String? = null,
|
||||
val category: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val closed: Boolean? = null,
|
||||
val archived: Boolean? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val createdAt: String? = null
|
||||
val createdAt: String? = null,
|
||||
val negRisk: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -74,6 +108,8 @@ data class MarketResponse(
|
||||
val events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug)
|
||||
// 以下字段可能存在于响应中,但不在标准文档中
|
||||
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
|
||||
val clob_token_ids: String? = null // 下划线格式(兼容不同 API 版本)
|
||||
val clob_token_ids: String? = null, // 下划线格式(兼容不同 API 版本)
|
||||
val negRisk: Boolean? = null, // 事件级 neg risk(部分 API 直接返回在 market)
|
||||
val negRiskOther: Boolean? = null // Market 级 neg risk 标记
|
||||
)
|
||||
|
||||
|
||||
+54
-7
@@ -23,6 +23,50 @@ class AccountController(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(AccountController::class.java)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项(用于导入前选择代理类型)
|
||||
*/
|
||||
@PostMapping("/check-proxy-options")
|
||||
fun checkProxyOptions(@RequestBody request: CheckProxyOptionsRequest): ResponseEntity<ApiResponse<CheckProxyOptionsResponse>> {
|
||||
return try {
|
||||
if (request.walletAddress.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_WALLET_ADDRESS_EMPTY, messageSource = messageSource))
|
||||
}
|
||||
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "必须提供私钥或助记词", messageSource))
|
||||
}
|
||||
|
||||
val result = runBlocking { accountService.checkProxyOptions(request) }
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("检查代理地址选项失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
else -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查代理地址选项异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过私钥导入账户
|
||||
*/
|
||||
@@ -45,14 +89,17 @@ class AccountController(
|
||||
onFailure = { e ->
|
||||
logger.error("导入账户失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.PARAM_ERROR,
|
||||
e.message,
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
package com.wrbug.polymarketbot.controller.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.backtest.BacktestService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
/**
|
||||
* 回测管理控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/backtest")
|
||||
class BacktestController(
|
||||
private val backtestService: BacktestService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BacktestController::class.java)
|
||||
|
||||
/**
|
||||
* 创建回测任务
|
||||
*/
|
||||
@PostMapping("/tasks")
|
||||
fun createBacktestTask(@RequestBody request: BacktestCreateRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
|
||||
return try {
|
||||
logger.info("创建回测任务: taskName=${request.taskName}, leaderId=${request.leaderId}")
|
||||
|
||||
val result = runBlocking {
|
||||
backtestService.createBacktestTask(request)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { dto ->
|
||||
logger.info("回测任务创建成功: taskId=${dto.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(dto))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("创建回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.PARAM_ERROR
|
||||
else -> ErrorCode.SERVER_BACKTEST_CREATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_CREATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务列表
|
||||
*/
|
||||
@PostMapping("/tasks/list")
|
||||
fun getBacktestTaskList(@RequestBody request: BacktestListRequest): ResponseEntity<ApiResponse<BacktestListResponse>> {
|
||||
return try {
|
||||
val result = backtestService.getBacktestTaskList(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("查询回测任务列表成功: total=${response.total}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询回测任务列表失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务列表异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务详情
|
||||
*/
|
||||
@PostMapping("/tasks/detail")
|
||||
fun getBacktestTaskDetail(@RequestBody request: BacktestDetailRequest): ResponseEntity<ApiResponse<BacktestDetailResponse>> {
|
||||
return try {
|
||||
val result = backtestService.getBacktestTaskDetail(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("查询回测任务详情成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询回测任务详情失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
else -> ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务详情异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DETAIL_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测交易记录
|
||||
*/
|
||||
@PostMapping("/tasks/trades")
|
||||
fun getBacktestTrades(@RequestBody request: BacktestTradeListRequest): ResponseEntity<ApiResponse<BacktestTradeListResponse>> {
|
||||
return try {
|
||||
val result = backtestService.getBacktestTrades(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
logger.info("查询回测交易记录成功: taskId=${request.taskId}")
|
||||
ResponseEntity.ok(ApiResponse.success(response))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询回测交易记录失败", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测交易记录异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_TRADES_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除回测任务
|
||||
*/
|
||||
@PostMapping("/tasks/delete")
|
||||
fun deleteBacktestTask(@RequestBody request: BacktestDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
logger.info("删除回测任务: taskId=${request.id}")
|
||||
|
||||
val result = backtestService.deleteBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("回测任务删除成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("删除回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||
else -> ErrorCode.SERVER_BACKTEST_DELETE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止回测任务
|
||||
*/
|
||||
@PostMapping("/tasks/stop")
|
||||
fun stopBacktestTask(@RequestBody request: BacktestStopRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
logger.info("停止回测任务: taskId=${request.id}")
|
||||
|
||||
val result = backtestService.stopBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("回测任务停止成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("停止回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||
else -> ErrorCode.SERVER_BACKTEST_STOP_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("停止回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_STOP_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试回测任务
|
||||
*/
|
||||
@PostMapping("/tasks/retry")
|
||||
fun retryBacktestTask(@RequestBody request: BacktestRetryRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
logger.info("重试回测任务: taskId=${request.id}")
|
||||
|
||||
val result = backtestService.retryBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
logger.info("回测任务重试成功: taskId=${request.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("重试回测任务失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_RUNNING
|
||||
else -> ErrorCode.SERVER_BACKTEST_RETRY_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重试回测任务异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RETRY_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试:基于已完成的回测任务创建相同配置的新任务(仅支持已完成任务)
|
||||
*/
|
||||
@PostMapping("/tasks/rerun")
|
||||
fun rerunBacktestTask(@RequestBody request: BacktestRerunRequest): ResponseEntity<ApiResponse<BacktestTaskDto>> {
|
||||
return try {
|
||||
logger.info("按配置重新测试: sourceTaskId=${request.id}, newTaskName=${request.taskName}")
|
||||
|
||||
val result = backtestService.rerunBacktestTask(request)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { dto ->
|
||||
logger.info("重新测试任务创建成功: newTaskId=${dto.id}")
|
||||
ResponseEntity.ok(ApiResponse.success(dto))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("按配置重新测试失败", e)
|
||||
val errorCode = when (e) {
|
||||
is IllegalArgumentException -> ErrorCode.BACKTEST_TASK_NOT_FOUND
|
||||
is IllegalStateException -> ErrorCode.BACKTEST_TASK_NOT_COMPLETED
|
||||
else -> ErrorCode.SERVER_BACKTEST_RERUN_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("按配置重新测试异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_BACKTEST_RERUN_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package com.wrbug.polymarketbot.controller.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDeleteRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyDto
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyListResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyTriggerListResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailStrategyUpdateRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/crypto-tail-strategy")
|
||||
class CryptoTailStrategyController(
|
||||
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyController::class.java)
|
||||
|
||||
@PostMapping("/list")
|
||||
fun list(@RequestBody request: CryptoTailStrategyListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyListResponse>> {
|
||||
return try {
|
||||
val result = cryptoTailStrategyService.list(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询尾盘策略列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询尾盘策略列表异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
fun create(@RequestBody request: CryptoTailStrategyCreateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||
return try {
|
||||
val result = cryptoTailStrategyService.create(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("创建尾盘策略失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/update")
|
||||
fun update(@RequestBody request: CryptoTailStrategyUpdateRequest): ResponseEntity<ApiResponse<CryptoTailStrategyDto>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.update(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("更新尾盘策略失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED
|
||||
ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey -> ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID
|
||||
else -> ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, messageSource = messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
fun delete(@RequestBody request: CryptoTailStrategyDeleteRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
val strategyId = request.strategyId
|
||||
if (strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.delete(strategyId)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
||||
onFailure = { e ->
|
||||
logger.error("删除尾盘策略失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除尾盘策略异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/triggers")
|
||||
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.getTriggerRecords(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/market-options")
|
||||
fun getMarketOptions(): ResponseEntity<ApiResponse<List<CryptoTailMarketOptionDto>>> {
|
||||
return try {
|
||||
val options = listOf(
|
||||
CryptoTailMarketOptionDto(slug = "btc-updown-5m", title = "Bitcoin Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||
CryptoTailMarketOptionDto(slug = "btc-updown-15m", title = "Bitcoin Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
|
||||
)
|
||||
ResponseEntity.ok(ApiResponse.success(options))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取市场选项异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动最小价差预览:按「当前周期」计算一次并返回,仅用于前端展示参考。
|
||||
* 实际触发时按每个周期在需要时计算,不依赖此接口。
|
||||
*/
|
||||
@PostMapping("/auto-min-spread")
|
||||
fun getAutoMinSpread(@RequestBody request: java.util.Map<String, Any>): ResponseEntity<ApiResponse<CryptoTailAutoMinSpreadResponse>> {
|
||||
return try {
|
||||
val intervalSeconds = (request["intervalSeconds"] as? Number)?.toInt() ?: 300
|
||||
if (intervalSeconds != 300 && intervalSeconds != 900) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, messageSource = messageSource))
|
||||
}
|
||||
val periodStartUnix = (request["periodStartUnix"] as? Number)?.toLong()
|
||||
?: (System.currentTimeMillis() / 1000 / intervalSeconds) * intervalSeconds
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
||||
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
|
||||
val body = CryptoTailAutoMinSpreadResponse(
|
||||
minSpreadUp = pair.first.toPlainString(),
|
||||
minSpreadDown = pair.second.toPlainString()
|
||||
)
|
||||
ResponseEntity.ok(ApiResponse.success(body))
|
||||
} catch (e: Exception) {
|
||||
logger.error("计算自动最小价差异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,37 @@ data class AccountImportRequest(
|
||||
val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包)
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项请求
|
||||
*/
|
||||
data class CheckProxyOptionsRequest(
|
||||
val walletAddress: String, // EOA 地址(必需)
|
||||
val privateKey: String? = null, // 私钥(加密,私钥导入时提供)
|
||||
val mnemonic: String? = null // 助记词(加密,助记词导入时提供)
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理地址选项信息
|
||||
*/
|
||||
data class ProxyOptionDto(
|
||||
val walletType: String, // "magic" 或 "safe"
|
||||
val proxyAddress: String, // 代理地址
|
||||
val descriptionKey: String, // 说明文案的多语言 key(如 "accountImport.proxyOption.magic.description")
|
||||
val availableBalance: String, // 可用余额
|
||||
val positionBalance: String, // 仓位余额
|
||||
val totalBalance: String, // 总余额
|
||||
val positionCount: Int, // 持仓数量
|
||||
val hasAssets: Boolean, // 是否有资产(余额>0 或持仓>0)
|
||||
val error: String? = null // 获取失败时的错误信息(可选)
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查代理地址选项响应
|
||||
*/
|
||||
data class CheckProxyOptionsResponse(
|
||||
val options: List<ProxyOptionDto> // 代理地址选项列表(私钥导入返回2个,助记词返回1个)
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 回测任务创建请求
|
||||
*/
|
||||
data class BacktestCreateRequest(
|
||||
val taskName: String, // 回测任务名称
|
||||
val leaderId: Long, // Leader ID
|
||||
val initialBalance: String, // 初始资金
|
||||
val backtestDays: Int, // 回测天数 (1-30)
|
||||
// 跟单配置(与 CopyTrading 一致,但不包含 max_position_count)
|
||||
val copyMode: String? = null, // "RATIO" 或 "FIXED"
|
||||
val copyRatio: String? = null, // 仅在 copyMode="RATIO" 时生效
|
||||
val fixedAmount: String? = null, // 仅在 copyMode="FIXED" 时生效
|
||||
val maxOrderSize: String? = null,
|
||||
val minOrderSize: String? = null,
|
||||
val maxDailyLoss: String? = null,
|
||||
val maxDailyOrders: Int? = null,
|
||||
val supportSell: Boolean? = null,
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
val pageForResume: Int? = null // 用于恢复中断任务,从指定页码开始获取历史数据(从1开始)
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务列表请求
|
||||
*/
|
||||
data class BacktestListRequest(
|
||||
val leaderId: Long? = null, // Leader ID(可选)
|
||||
val status: String? = null, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
val sortBy: String? = null, // profitAmount / profitRate / createdAt
|
||||
val sortOrder: String? = null, // asc / desc
|
||||
val page: Int = 1, // 页码,从1开始
|
||||
val size: Int = 20 // 每页数量
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务详情请求
|
||||
*/
|
||||
data class BacktestDetailRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测交易记录请求
|
||||
*/
|
||||
data class BacktestTradeListRequest(
|
||||
val taskId: Long, // 回测任务ID
|
||||
val page: Int = 1, // 页码,从1开始
|
||||
val size: Int = 20 // 每页数量
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测进度查询请求
|
||||
*/
|
||||
data class BacktestProgressRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务停止请求
|
||||
*/
|
||||
data class BacktestStopRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务删除请求
|
||||
*/
|
||||
data class BacktestDeleteRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务重试请求
|
||||
*/
|
||||
data class BacktestRetryRequest(
|
||||
val id: Long // 回测任务ID
|
||||
)
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试请求(仅支持已完成任务)
|
||||
*/
|
||||
data class BacktestRerunRequest(
|
||||
val id: Long, // 源回测任务ID
|
||||
val taskName: String? = null // 新任务名称,为空时使用「原名称 (副本)」
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务列表响应
|
||||
*/
|
||||
data class BacktestListResponse(
|
||||
val list: List<BacktestTaskDto>,
|
||||
val total: Long,
|
||||
val page: Int,
|
||||
val size: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务详情响应
|
||||
*/
|
||||
data class BacktestDetailResponse(
|
||||
val task: BacktestTaskDto,
|
||||
val config: BacktestConfigDto,
|
||||
val statistics: BacktestStatisticsDto
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测交易记录列表响应
|
||||
*/
|
||||
data class BacktestTradeListResponse(
|
||||
val list: List<BacktestTradeDto>,
|
||||
val total: Long,
|
||||
val page: Int,
|
||||
val size: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测进度响应
|
||||
*/
|
||||
data class BacktestProgressResponse(
|
||||
val progress: Int, // 执行进度 (0-100)
|
||||
val currentBalance: String, // 当前余额
|
||||
val totalTrades: Int, // 总交易笔数
|
||||
val status: String // 任务状态
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测任务 DTO
|
||||
*/
|
||||
data class BacktestTaskDto(
|
||||
val id: Long,
|
||||
val taskName: String,
|
||||
val leaderId: Long,
|
||||
val leaderName: String?,
|
||||
val leaderAddress: String?,
|
||||
val initialBalance: String,
|
||||
val finalBalance: String?,
|
||||
val profitAmount: String?,
|
||||
val profitRate: String?,
|
||||
val backtestDays: Int,
|
||||
val startTime: Long,
|
||||
val endTime: Long?,
|
||||
val status: String, // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
val progress: Int,
|
||||
val totalTrades: Int,
|
||||
val createdAt: Long,
|
||||
val executionStartedAt: Long?,
|
||||
val executionFinishedAt: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测配置 DTO
|
||||
*/
|
||||
data class BacktestConfigDto(
|
||||
val copyMode: String,
|
||||
val copyRatio: String,
|
||||
val fixedAmount: String?,
|
||||
val maxOrderSize: String,
|
||||
val minOrderSize: String,
|
||||
val maxDailyLoss: String,
|
||||
val maxDailyOrders: Int,
|
||||
val supportSell: Boolean,
|
||||
val keywordFilterMode: String?,
|
||||
val keywords: List<String>?,
|
||||
val maxPositionValue: String?,
|
||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||
val maxPrice: String? // 最高价格(可选),NULL表示不限制最高价
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测统计信息 DTO
|
||||
*/
|
||||
data class BacktestStatisticsDto(
|
||||
val totalTrades: Int, // 总交易笔数
|
||||
val buyTrades: Int, // 买入笔数
|
||||
val sellTrades: Int, // 卖出笔数
|
||||
val winTrades: Int, // 盈利交易笔数
|
||||
val lossTrades: Int, // 亏损交易笔数
|
||||
val winRate: String, // 胜率(%)
|
||||
val maxProfit: String, // 最大单笔盈利
|
||||
val maxLoss: String, // 最大单笔亏损
|
||||
val maxDrawdown: String, // 最大回撤
|
||||
val avgHoldingTime: Long? // 平均持仓时间(毫秒)
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测交易记录 DTO
|
||||
*/
|
||||
data class BacktestTradeDto(
|
||||
val id: Long,
|
||||
val tradeTime: Long,
|
||||
val marketId: String,
|
||||
val marketTitle: String?,
|
||||
val side: String, // BUY/SELL/SETTLEMENT
|
||||
val outcome: String,
|
||||
val outcomeIndex: Int?,
|
||||
val quantity: String,
|
||||
val price: String,
|
||||
val amount: String,
|
||||
val fee: String,
|
||||
val profitLoss: String?,
|
||||
val balanceAfter: String,
|
||||
val leaderTradeId: String?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 尾盘策略创建请求
|
||||
* 金额与价格使用 String,后端转为 BigDecimal
|
||||
*/
|
||||
data class CryptoTailStrategyCreateRequest(
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val intervalSeconds: Int = 300,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略更新请求
|
||||
*/
|
||||
data class CryptoTailStrategyUpdateRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val windowStartSeconds: Int? = null,
|
||||
val windowEndSeconds: Int? = null,
|
||||
val minPrice: String? = null,
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String? = null,
|
||||
val amountValue: String? = null,
|
||||
val minSpreadMode: String? = null,
|
||||
val minSpreadValue: String? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略列表请求
|
||||
*/
|
||||
data class CryptoTailStrategyListRequest(
|
||||
val accountId: Long? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略 DTO(列表与详情)
|
||||
*/
|
||||
data class CryptoTailStrategyDto(
|
||||
val id: Long = 0L,
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val marketTitle: String? = null,
|
||||
val intervalSeconds: Int = 0,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String = "1",
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
val lastTriggerAt: Long? = null,
|
||||
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
|
||||
val totalRealizedPnl: String? = null,
|
||||
/** 已结算笔数(用于胜率分母) */
|
||||
val settledCount: Long = 0L,
|
||||
/** 已结算中赢的笔数(用于胜率分子) */
|
||||
val winCount: Long = 0L,
|
||||
/** 胜率 0~1(已结算时 = winCount/settledCount,无结算为 null) */
|
||||
val winRate: String? = null,
|
||||
val createdAt: Long = 0L,
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略列表响应
|
||||
*/
|
||||
data class CryptoTailStrategyListResponse(
|
||||
val list: List<CryptoTailStrategyDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略删除请求
|
||||
*/
|
||||
data class CryptoTailStrategyDeleteRequest(
|
||||
val strategyId: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录列表请求
|
||||
* @param startDate 开始日期(当天 00:00:00.000 的时间戳毫秒),为 null 表示不限制
|
||||
* @param endDate 结束日期(当天 23:59:59.999 的时间戳毫秒),为 null 表示不限制
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 20,
|
||||
val status: String? = null,
|
||||
val startDate: Long? = null,
|
||||
val endDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录 DTO
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerDto(
|
||||
val id: Long = 0L,
|
||||
val strategyId: Long = 0L,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val marketTitle: String? = null,
|
||||
val outcomeIndex: Int = 0,
|
||||
val triggerPrice: String = "0",
|
||||
val amountUsdc: String = "0",
|
||||
val orderId: String? = null,
|
||||
val status: String = "success",
|
||||
val failReason: String? = null,
|
||||
/** 是否已结算 */
|
||||
val resolved: Boolean = false,
|
||||
/** 已实现盈亏 USDC(结算后有值) */
|
||||
val realizedPnl: String? = null,
|
||||
/** 市场赢家 outcome 索引(结算后有值) */
|
||||
val winnerOutcomeIndex: Int? = null,
|
||||
val settledAt: Long? = null,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录分页响应
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListResponse(
|
||||
val list: List<CryptoTailStrategyTriggerDto> = emptyList(),
|
||||
val total: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 自动最小价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||
*/
|
||||
data class CryptoTailAutoMinSpreadResponse(
|
||||
val minSpreadUp: String = "0",
|
||||
val minSpreadDown: String = "0"
|
||||
)
|
||||
|
||||
/**
|
||||
* 5/15 分钟市场项(供前端选择市场)
|
||||
*/
|
||||
data class CryptoTailMarketOptionDto(
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
val intervalSeconds: Int = 0,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val endDate: String? = null
|
||||
)
|
||||
@@ -54,6 +54,7 @@ data class LeaderDto(
|
||||
val remark: String? = null, // Leader 备注(可选)
|
||||
val website: String? = null, // Leader 网站(可选)
|
||||
val copyTradingCount: Long = 0, // 跟单关系数量
|
||||
val backtestCount: Long = 0, // 回测数量
|
||||
val totalOrders: Long? = null, // 总订单数(可选)
|
||||
val totalPnl: String? = null, // 总盈亏(可选)
|
||||
val createdAt: Long,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 用户交易数据
|
||||
* 用于回测功能,从 Polymarket API 获取的用户交易历史
|
||||
*/
|
||||
data class TradeData(
|
||||
val tradeId: String, // 交易 ID
|
||||
val marketId: String, // 市场 ID
|
||||
val marketTitle: String?, // 市场标题
|
||||
val marketSlug: String?, // 市场 Slug
|
||||
val side: String, // 交易方向: BUY/SELL
|
||||
val outcome: String, // 结果: YES/NO 或 outcomeIndex
|
||||
val outcomeIndex: Int?, // 结果索引
|
||||
val price: BigDecimal, // 成交价格
|
||||
val size: BigDecimal, // 成交数量
|
||||
val amount: BigDecimal, // 成交金额
|
||||
val timestamp: Long // 交易时间戳
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is TradeData) return false
|
||||
return tradeId == other.tradeId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return tradeId.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ data class Account(
|
||||
@Column(name = "private_key", nullable = false, length = 500)
|
||||
val privateKey: String, // 私钥(AES 加密存储)
|
||||
|
||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
||||
@Column(name = "wallet_address", nullable = false, length = 42)
|
||||
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
|
||||
|
||||
@Column(name = "proxy_address", nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
||||
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
|
||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
|
||||
|
||||
@Column(name = "api_key", length = 500)
|
||||
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 回测任务实体
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "backtest_task")
|
||||
data class BacktestTask(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "task_name", nullable = false, length = 100)
|
||||
val taskName: String,
|
||||
|
||||
@Column(name = "leader_id", nullable = false)
|
||||
val leaderId: Long,
|
||||
|
||||
// 回测参数
|
||||
@Column(name = "initial_balance", nullable = false, precision = 20, scale = 8)
|
||||
val initialBalance: BigDecimal,
|
||||
|
||||
@Column(name = "final_balance", precision = 20, scale = 8)
|
||||
var finalBalance: BigDecimal? = null,
|
||||
|
||||
@Column(name = "profit_amount", precision = 20, scale = 8)
|
||||
var profitAmount: BigDecimal? = null,
|
||||
|
||||
@Column(name = "profit_rate", precision = 10, scale = 4)
|
||||
var profitRate: BigDecimal? = null, // 收益率(%)
|
||||
|
||||
@Column(name = "backtest_days", nullable = false)
|
||||
val backtestDays: Int,
|
||||
|
||||
@Column(name = "start_time", nullable = false)
|
||||
val startTime: Long, // 回测开始时间(历史时间),创建时计算;执行时以当前时间为基准用局部变量重算窗口
|
||||
|
||||
@Column(name = "end_time")
|
||||
var endTime: Long? = null, // 回测结束时间(历史时间)
|
||||
|
||||
// 跟单配置 (复制CopyTrading表结构,但不包含 max_position_count)
|
||||
@Column(name = "copy_mode", nullable = false, length = 10)
|
||||
val copyMode: String = "RATIO", // "RATIO" 或 "FIXED"
|
||||
|
||||
@Column(name = "copy_ratio", nullable = false, precision = 20, scale = 8)
|
||||
val copyRatio: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "fixed_amount", precision = 20, scale = 8)
|
||||
val fixedAmount: BigDecimal? = null,
|
||||
|
||||
@Column(name = "max_order_size", nullable = false, precision = 20, scale = 8)
|
||||
val maxOrderSize: BigDecimal = "1000".toSafeBigDecimal(),
|
||||
|
||||
@Column(name = "min_order_size", nullable = false, precision = 20, scale = 8)
|
||||
val minOrderSize: BigDecimal = "1".toSafeBigDecimal(),
|
||||
|
||||
@Column(name = "max_daily_loss", nullable = false, precision = 20, scale = 8)
|
||||
val maxDailyLoss: BigDecimal = "10000".toSafeBigDecimal(),
|
||||
|
||||
@Column(name = "max_daily_orders", nullable = false)
|
||||
val maxDailyOrders: Int = 100,
|
||||
|
||||
@Column(name = "support_sell", nullable = false)
|
||||
val supportSell: Boolean = true,
|
||||
|
||||
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||
val keywordFilterMode: String = "DISABLED", // DISABLED/WHITELIST/BLACKLIST
|
||||
|
||||
@Column(name = "keywords", columnDefinition = "JSON")
|
||||
val keywords: String? = null,
|
||||
|
||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
|
||||
@Column(name = "min_price", precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||
|
||||
@Column(name = "max_price", precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
|
||||
// 统计字段
|
||||
@Column(name = "avg_holding_time")
|
||||
var avgHoldingTime: Long? = null, // 平均持仓时间(毫秒)
|
||||
|
||||
@Column(name = "data_source", length = 50)
|
||||
var dataSource: String = "MIXED", // INTERNAL/API/MIXED
|
||||
|
||||
// 执行状态
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: String = "PENDING", // PENDING/RUNNING/COMPLETED/STOPPED/FAILED
|
||||
|
||||
@Column(name = "progress", nullable = false)
|
||||
var progress: Int = 0, // 执行进度(0-100)
|
||||
|
||||
@Column(name = "total_trades", nullable = false)
|
||||
var totalTrades: Int = 0,
|
||||
|
||||
@Column(name = "buy_trades", nullable = false)
|
||||
var buyTrades: Int = 0,
|
||||
|
||||
@Column(name = "sell_trades", nullable = false)
|
||||
var sellTrades: Int = 0,
|
||||
|
||||
@Column(name = "win_trades", nullable = false)
|
||||
var winTrades: Int = 0,
|
||||
|
||||
@Column(name = "loss_trades", nullable = false)
|
||||
var lossTrades: Int = 0,
|
||||
|
||||
@Column(name = "win_rate", precision = 5, scale = 2)
|
||||
var winRate: BigDecimal? = null, // 胜率(%)
|
||||
|
||||
@Column(name = "max_profit", precision = 20, scale = 8)
|
||||
var maxProfit: BigDecimal? = null, // 最大单笔盈利
|
||||
|
||||
@Column(name = "max_loss", precision = 20, scale = 8)
|
||||
var maxLoss: BigDecimal? = null, // 最大单笔亏损
|
||||
|
||||
@Column(name = "max_drawdown", precision = 20, scale = 8)
|
||||
var maxDrawdown: BigDecimal? = null, // 最大回撤
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
var errorMessage: String? = null,
|
||||
|
||||
// 时间字段
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "execution_started_at")
|
||||
var executionStartedAt: Long? = null,
|
||||
|
||||
@Column(name = "execution_finished_at")
|
||||
var executionFinishedAt: Long? = null,
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "last_processed_trade_time")
|
||||
var lastProcessedTradeTime: Long? = null,
|
||||
|
||||
@Column(name = "last_processed_trade_index")
|
||||
var lastProcessedTradeIndex: Int? = null,
|
||||
|
||||
@Column(name = "processed_trade_count")
|
||||
var processedTradeCount: Int = 0
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 回测交易记录实体
|
||||
* 用于记录回测过程中的每笔模拟交易
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "backtest_trade")
|
||||
data class BacktestTrade(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "backtest_task_id", nullable = false)
|
||||
val backtestTaskId: Long,
|
||||
|
||||
@Column(name = "trade_time", nullable = false)
|
||||
val tradeTime: Long,
|
||||
|
||||
@Column(name = "market_id", nullable = false, length = 100)
|
||||
val marketId: String,
|
||||
|
||||
@Column(name = "market_title", length = 500)
|
||||
val marketTitle: String? = null,
|
||||
|
||||
@Column(name = "side", nullable = false, length = 20)
|
||||
val side: String, // BUY/SELL/SETTLEMENT
|
||||
|
||||
@Column(name = "outcome", nullable = false, length = 50)
|
||||
val outcome: String, // YES/NO 或 outcomeIndex
|
||||
|
||||
@Column(name = "outcome_index")
|
||||
val outcomeIndex: Int? = null, // 结果索引(0, 1, 2, ...),支持多元市场
|
||||
|
||||
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||
val quantity: BigDecimal,
|
||||
|
||||
@Column(name = "price", nullable = false, precision = 20, scale = 8)
|
||||
val price: BigDecimal,
|
||||
|
||||
@Column(name = "amount", nullable = false, precision = 20, scale = 8)
|
||||
val amount: BigDecimal,
|
||||
|
||||
@Column(name = "fee", nullable = false, precision = 20, scale = 8)
|
||||
val fee: BigDecimal = BigDecimal.ZERO, // 手续费(回测不计算,默认为0)
|
||||
|
||||
@Column(name = "profit_loss", precision = 20, scale = 8)
|
||||
val profitLoss: BigDecimal? = null, // 盈亏(仅卖出时)
|
||||
|
||||
@Column(name = "balance_after", nullable = false, precision = 20, scale = 8)
|
||||
val balanceAfter: BigDecimal, // 交易后余额
|
||||
|
||||
@Column(name = "leader_trade_id", length = 100)
|
||||
val leaderTradeId: String? = null, // Leader 原始交易ID
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 加密市场尾盘策略实体
|
||||
* 5/15 分钟 Up or Down 市场,在周期内时间窗口、价格进入区间时市价买入
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "crypto_tail_strategy")
|
||||
data class CryptoTailStrategy(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "account_id", nullable = false)
|
||||
val accountId: Long = 0L,
|
||||
|
||||
@Column(name = "name", length = 255)
|
||||
val name: String? = null,
|
||||
|
||||
@Column(name = "market_slug_prefix", nullable = false, length = 64)
|
||||
val marketSlugPrefix: String = "",
|
||||
|
||||
@Column(name = "interval_seconds", nullable = false)
|
||||
val intervalSeconds: Int = 300,
|
||||
|
||||
@Column(name = "window_start_seconds", nullable = false)
|
||||
val windowStartSeconds: Int = 0,
|
||||
|
||||
@Column(name = "window_end_seconds", nullable = false)
|
||||
val windowEndSeconds: Int = 0,
|
||||
|
||||
@Column(name = "min_price", nullable = false, precision = 20, scale = 8)
|
||||
val minPrice: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "max_price", nullable = false, precision = 20, scale = 8)
|
||||
val maxPrice: BigDecimal = BigDecimal.ONE,
|
||||
|
||||
@Column(name = "amount_mode", nullable = false, length = 10)
|
||||
val amountMode: String = "RATIO",
|
||||
|
||||
@Column(name = "amount_value", nullable = false, precision = 20, scale = 8)
|
||||
val amountValue: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "min_spread_mode", nullable = false, length = 16)
|
||||
val minSpreadMode: String = "NONE",
|
||||
|
||||
@Column(name = "min_spread_value", precision = 20, scale = 8)
|
||||
val minSpreadValue: BigDecimal? = null,
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
val enabled: Boolean = true,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
|
||||
/**
|
||||
* 尾盘策略触发记录
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "crypto_tail_strategy_trigger")
|
||||
data class CryptoTailStrategyTrigger(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "strategy_id", nullable = false)
|
||||
val strategyId: Long = 0L,
|
||||
|
||||
@Column(name = "period_start_unix", nullable = false)
|
||||
val periodStartUnix: Long = 0L,
|
||||
|
||||
@Column(name = "market_title", length = 500)
|
||||
val marketTitle: String? = null,
|
||||
|
||||
@Column(name = "outcome_index", nullable = false)
|
||||
val outcomeIndex: Int = 0,
|
||||
|
||||
@Column(name = "trigger_price", nullable = false, precision = 20, scale = 8)
|
||||
val triggerPrice: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "amount_usdc", nullable = false, precision = 20, scale = 8)
|
||||
val amountUsdc: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "order_id", length = 128)
|
||||
val orderId: String? = null,
|
||||
|
||||
@Column(name = "condition_id", length = 66)
|
||||
val conditionId: String? = null,
|
||||
|
||||
@Column(name = "resolved", nullable = false)
|
||||
val resolved: Boolean = false,
|
||||
|
||||
@Column(name = "winner_outcome_index")
|
||||
val winnerOutcomeIndex: Int? = null,
|
||||
|
||||
@Column(name = "realized_pnl", precision = 20, scale = 8)
|
||||
val realizedPnl: BigDecimal? = null,
|
||||
|
||||
@Column(name = "settled_at")
|
||||
val settledAt: Long? = null,
|
||||
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
val status: String = "success",
|
||||
|
||||
@Column(name = "fail_reason", length = 500)
|
||||
val failReason: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "notification_sent", nullable = false)
|
||||
var notificationSent: Boolean = false
|
||||
)
|
||||
@@ -158,6 +158,13 @@ enum class ErrorCode(
|
||||
ACCOUNT_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
||||
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
||||
|
||||
// 尾盘策略 (4710-4729)
|
||||
CRYPTO_TAIL_STRATEGY_NOT_FOUND(4710, "尾盘策略不存在", "error.crypto_tail_strategy_not_found"),
|
||||
CRYPTO_TAIL_STRATEGY_WINDOW_INVALID(4711, "时间区间开始不能大于结束", "error.crypto_tail_strategy_window_invalid"),
|
||||
CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED(4712, "时间区间不能超过周期长度", "error.crypto_tail_strategy_window_exceed"),
|
||||
CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID(4713, "周期仅支持 300 或 900 秒", "error.crypto_tail_strategy_interval_invalid"),
|
||||
CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID(4714, "投入方式仅支持 RATIO 或 FIXED", "error.crypto_tail_strategy_amount_mode_invalid"),
|
||||
|
||||
// 统计相关 (4801-4899)
|
||||
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
|
||||
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_fetch_failed"),
|
||||
@@ -231,7 +238,33 @@ enum class ErrorCode(
|
||||
SERVER_ORDER_TRACKING_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
|
||||
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_failed"),
|
||||
SERVER_ORDER_TRACKING_SELL_FAILED(5903, "处理卖出订单失败", "error.server.order_tracking_sell_failed"),
|
||||
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed");
|
||||
SERVER_ORDER_TRACKING_MATCH_FAILED(5904, "订单匹配失败", "error.server.order_tracking_match_failed"),
|
||||
|
||||
// 回测服务错误 (4601-4699)
|
||||
BACKTEST_TASK_NOT_FOUND(4601, "回测任务不存在", "error.backtest.task_not_found"),
|
||||
BACKTEST_LEADER_NOT_FOUND(4602, "Leader不存在", "error.backtest.leader_not_found"),
|
||||
BACKTEST_DAYS_INVALID(4603, "回测天数超出限制", "error.backtest.days_invalid"),
|
||||
BACKTEST_INITIAL_BALANCE_INVALID(4604, "初始金额无效", "error.backtest.initial_balance_invalid"),
|
||||
BACKTEST_TASK_RUNNING(4605, "回测任务正在运行,无法删除", "error.backtest.task_running"),
|
||||
BACKTEST_TASK_NOT_COMPLETED(4606, "仅支持对已完成的回测任务重新测试", "error.backtest.task_not_completed"),
|
||||
SERVER_BACKTEST_CREATE_FAILED(5603, "创建回测任务失败", "error.server.backtest_create_failed"),
|
||||
SERVER_BACKTEST_UPDATE_FAILED(5604, "更新回测任务失败", "error.server.backtest_update_failed"),
|
||||
SERVER_BACKTEST_DELETE_FAILED(5605, "删除回测任务失败", "error.server.backtest_delete_failed"),
|
||||
SERVER_BACKTEST_LIST_FETCH_FAILED(5606, "查询回测列表失败", "error.server.backtest_list_fetch_failed"),
|
||||
SERVER_BACKTEST_DETAIL_FETCH_FAILED(5607, "查询回测详情失败", "error.server.backtest_detail_fetch_failed"),
|
||||
SERVER_BACKTEST_TRADES_FETCH_FAILED(5608, "查询回测交易记录失败", "error.server.backtest_trades_fetch_failed"),
|
||||
SERVER_BACKTEST_EXECUTE_FAILED(5609, "回测执行失败", "error.server.backtest_execute_failed"),
|
||||
SERVER_BACKTEST_HISTORICAL_DATA_FETCH_FAILED(5610, "历史数据获取失败", "error.server.backtest_historical_data_fetch_failed"),
|
||||
SERVER_BACKTEST_STOP_FAILED(5611, "停止回测任务失败", "error.server.backtest_stop_failed"),
|
||||
SERVER_BACKTEST_RETRY_FAILED(5612, "重试回测任务失败", "error.server.backtest_retry_failed"),
|
||||
SERVER_BACKTEST_RERUN_FAILED(5613, "按配置重新测试失败", "error.server.backtest_rerun_failed"),
|
||||
|
||||
// 尾盘策略服务 (5620-5629)
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_CREATE_FAILED(5620, "创建尾盘策略失败", "error.server.crypto_tail_strategy_create_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_UPDATE_FAILED(5621, "更新尾盘策略失败", "error.server.crypto_tail_strategy_update_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_DELETE_FAILED(5622, "删除尾盘策略失败", "error.server.crypto_tail_strategy_delete_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_LIST_FETCH_FAILED(5623, "查询尾盘策略列表失败", "error.server.crypto_tail_strategy_list_fetch_failed"),
|
||||
SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED(5624, "查询触发记录失败", "error.server.crypto_tail_strategy_triggers_fetch_failed");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
/**
|
||||
* 钱包类型枚举
|
||||
*/
|
||||
enum class WalletType(val value: String, val description: String) {
|
||||
/**
|
||||
* Magic 钱包(邮箱/OAuth 登录)
|
||||
* 使用 PROXY 代理合约,通过 Builder Relayer 执行 Gasless 交易
|
||||
*/
|
||||
MAGIC("magic", "Magic(邮箱/OAuth登录)"),
|
||||
|
||||
/**
|
||||
* Safe 钱包(MetaMask 等 Web3 钱包)
|
||||
* 使用 Gnosis Safe 代理合约,支持 Builder Relayer Gasless 或手动交易
|
||||
*/
|
||||
SAFE("safe", "Safe(Web3钱包)");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 从字符串值解析钱包类型(不区分大小写)
|
||||
*/
|
||||
fun fromString(value: String?): WalletType {
|
||||
if (value.isNullOrBlank()) {
|
||||
return SAFE // 默认返回 SAFE
|
||||
}
|
||||
return values().find { it.value.equals(value, ignoreCase = true) }
|
||||
?: throw IllegalArgumentException("未知的钱包类型: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全地从字符串值解析钱包类型(不区分大小写),解析失败返回默认值
|
||||
*/
|
||||
fun fromStringOrDefault(value: String?, default: WalletType = SAFE): WalletType {
|
||||
if (value.isNullOrBlank()) {
|
||||
return default
|
||||
}
|
||||
return values().find { it.value.equals(value, ignoreCase = true) } ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字符串是否为有效的钱包类型
|
||||
*/
|
||||
fun isValid(value: String?): Boolean {
|
||||
if (value.isNullOrBlank()) {
|
||||
return false
|
||||
}
|
||||
return values().any { it.value.equals(value, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.wrbug.polymarketbot.event
|
||||
|
||||
import org.springframework.context.ApplicationEvent
|
||||
|
||||
/**
|
||||
* 尾盘策略创建/更新/启用状态变更后发布,用于立即触发一轮执行检查。
|
||||
*/
|
||||
class CryptoTailStrategyChangedEvent(source: Any) : ApplicationEvent(source)
|
||||
@@ -29,5 +29,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
||||
* 检查钱包地址是否存在
|
||||
*/
|
||||
fun existsByWalletAddress(walletAddress: String): Boolean
|
||||
|
||||
/**
|
||||
* 检查代理地址是否存在
|
||||
*/
|
||||
fun existsByProxyAddress(proxyAddress: String): Boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 回测任务Repository
|
||||
*/
|
||||
@Repository
|
||||
interface BacktestTaskRepository : JpaRepository<BacktestTask, Long> {
|
||||
|
||||
/**
|
||||
* 根据 Leader ID 查询回测任务
|
||||
*/
|
||||
fun findByLeaderId(leaderId: Long): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据状态查询回测任务
|
||||
*/
|
||||
fun findByStatus(status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据 Leader ID 和状态查询回测任务
|
||||
*/
|
||||
fun findByLeaderIdAndStatus(leaderId: Long, status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据 Leader ID、收益率排序查询
|
||||
*/
|
||||
@Query("SELECT t FROM BacktestTask t WHERE t.leaderId = :leaderId AND t.status = :status ORDER BY t.profitRate DESC")
|
||||
fun findByLeaderIdAndStatusOrderByProfitRateDesc(leaderId: Long, status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 根据状态和创建时间倒序查询
|
||||
*/
|
||||
@Query("SELECT t FROM BacktestTask t WHERE t.status = :status ORDER BY t.createdAt DESC")
|
||||
fun findByStatusOrderByCreatedAtDesc(status: String): List<BacktestTask>
|
||||
|
||||
/**
|
||||
* 更新回测任务状态
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BacktestTask t SET t.status = :status, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||
fun updateStatus(id: Long, status: String, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
/**
|
||||
* 更新回测任务状态和错误信息
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BacktestTask t SET t.status = :status, t.errorMessage = :errorMessage, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||
fun updateStatusAndError(id: Long, status: String, errorMessage: String?, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
/**
|
||||
* 更新回测任务进度
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE BacktestTask t SET t.progress = :progress, t.updatedAt = :updatedAt WHERE t.id = :id")
|
||||
fun updateProgress(id: Long, progress: Int, updatedAt: Long = System.currentTimeMillis())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* 回测交易记录Repository
|
||||
*/
|
||||
@Repository
|
||||
interface BacktestTradeRepository : JpaRepository<BacktestTrade, Long> {
|
||||
|
||||
/**
|
||||
* 根据回测任务ID查询所有交易记录
|
||||
*/
|
||||
fun findByBacktestTaskIdOrderByTradeTime(backtestTaskId: Long): List<BacktestTrade>
|
||||
|
||||
/**
|
||||
* 根据回测任务ID分页查询交易记录
|
||||
*/
|
||||
@Query("SELECT t FROM BacktestTrade t WHERE t.backtestTaskId = :backtestTaskId ORDER BY t.tradeTime")
|
||||
fun findByBacktestTaskId(
|
||||
backtestTaskId: Long,
|
||||
pageable: org.springframework.data.domain.Pageable
|
||||
): org.springframework.data.domain.Page<BacktestTrade>
|
||||
|
||||
/**
|
||||
* 根据回测任务ID统计交易数量
|
||||
*/
|
||||
fun countByBacktestTaskId(backtestTaskId: Long): Long
|
||||
|
||||
/**
|
||||
* 删除回测任务的所有交易记录(由级联删除处理)
|
||||
*/
|
||||
fun deleteByBacktestTaskId(backtestTaskId: Long)
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CryptoTailStrategyRepository : JpaRepository<CryptoTailStrategy, Long> {
|
||||
|
||||
fun findAllByAccountId(accountId: Long): List<CryptoTailStrategy>
|
||||
fun findAllByEnabledTrue(): List<CryptoTailStrategy>
|
||||
fun findByAccountIdAndEnabled(accountId: Long, enabled: Boolean): List<CryptoTailStrategy>
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.math.BigDecimal
|
||||
|
||||
interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategyTrigger, Long> {
|
||||
|
||||
fun findByStrategyIdAndPeriodStartUnix(strategyId: Long, periodStartUnix: Long): CryptoTailStrategyTrigger?
|
||||
fun findAllByStrategyIdOrderByCreatedAtDesc(strategyId: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
|
||||
|
||||
fun findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||
fun countByStrategyIdAndCreatedAtBetween(strategyId: Long, startInclusive: Long, endInclusive: Long): Long
|
||||
fun countByStrategyIdAndStatusAndCreatedAtBetween(strategyId: Long, status: String, startInclusive: Long, endInclusive: Long): Long
|
||||
|
||||
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
|
||||
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
||||
|
||||
/** 根据订单 ID 查询尾盘触发记录 */
|
||||
fun findByOrderId(orderId: String): CryptoTailStrategyTrigger?
|
||||
|
||||
/** 轮询发 TG:status=success、orderId 非空、未发过通知,按创建时间正序 */
|
||||
fun findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc(status: String): List<CryptoTailStrategyTrigger>
|
||||
|
||||
/** 策略已结算订单的总已实现盈亏(用于收益统计) */
|
||||
@Query("SELECT COALESCE(SUM(t.realizedPnl), 0) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
||||
fun sumRealizedPnlByStrategyId(@Param("strategyId") strategyId: Long): BigDecimal?
|
||||
|
||||
/** 策略已结算订单笔数(用于胜率分母) */
|
||||
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
||||
fun countResolvedByStrategyId(@Param("strategyId") strategyId: Long): Long
|
||||
|
||||
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index) */
|
||||
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true AND t.outcomeIndex = t.winnerOutcomeIndex")
|
||||
fun countWinsByStrategyId(@Param("strategyId") strategyId: Long): Long
|
||||
}
|
||||
+223
-28
@@ -3,10 +3,12 @@ package com.wrbug.polymarketbot.service.accounts
|
||||
import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.getEventSlug
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
@@ -66,11 +68,6 @@ class AccountService(
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 检查地址是否已存在
|
||||
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
|
||||
}
|
||||
|
||||
// 3. 验证私钥和地址的对应关系
|
||||
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
||||
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
||||
@@ -104,7 +101,8 @@ class AccountService(
|
||||
// 5. 获取代理地址(必须成功,否则导入失败)
|
||||
// 根据用户选择的钱包类型计算代理地址
|
||||
val proxyAddress = runBlocking {
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType)
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||
val proxyResult = blockchainService.getProxyAddress(request.walletAddress, walletTypeEnum)
|
||||
if (proxyResult.isSuccess) {
|
||||
val address = proxyResult.getOrNull()
|
||||
if (address != null) {
|
||||
@@ -120,25 +118,31 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
|
||||
if (accountRepository.existsByProxyAddress(proxyAddress)) {
|
||||
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
|
||||
}
|
||||
|
||||
// 7. 加密敏感信息
|
||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
|
||||
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
|
||||
|
||||
// 8. 生成账户名称(如果未提供,使用钱包地址后四位)
|
||||
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
||||
val accountName = if (request.accountName.isNullOrBlank()) {
|
||||
val walletAddress = request.walletAddress.trim()
|
||||
// 取地址后四位(去掉 0x 前缀后取后四位)
|
||||
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
|
||||
walletAddress.substring(2)
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||
val typeLabel = walletTypeEnum.name.uppercase()
|
||||
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
|
||||
proxyAddress.substring(2)
|
||||
} else {
|
||||
walletAddress
|
||||
proxyAddress
|
||||
}
|
||||
if (addressWithoutPrefix.length >= 4) {
|
||||
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
|
||||
val suffix = if (proxyWithoutPrefix.length >= 4) {
|
||||
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
|
||||
} else {
|
||||
addressWithoutPrefix.uppercase()
|
||||
proxyWithoutPrefix.uppercase()
|
||||
}
|
||||
"$typeLabel-$suffix"
|
||||
} else {
|
||||
request.accountName.trim()
|
||||
}
|
||||
@@ -171,6 +175,192 @@ class AccountService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理地址选项(用于账户导入前选择代理类型)
|
||||
* 私钥导入:返回 Magic 和 Safe 两个选项
|
||||
* 助记词导入:仅返回 Safe 选项
|
||||
*/
|
||||
suspend fun checkProxyOptions(request: CheckProxyOptionsRequest): Result<CheckProxyOptionsResponse> {
|
||||
return try {
|
||||
// 1. 验证钱包地址格式
|
||||
if (!isValidWalletAddress(request.walletAddress)) {
|
||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||
}
|
||||
|
||||
// 2. 验证至少提供了私钥或助记词之一
|
||||
if (request.privateKey.isNullOrBlank() && request.mnemonic.isNullOrBlank()) {
|
||||
return Result.failure(IllegalArgumentException("必须提供私钥或助记词"))
|
||||
}
|
||||
|
||||
val options = mutableListOf<ProxyOptionDto>()
|
||||
|
||||
// 3. 判断导入类型
|
||||
val isPrivateKeyImport = !request.privateKey.isNullOrBlank()
|
||||
|
||||
if (isPrivateKeyImport) {
|
||||
// 私钥导入:并行获取 Magic 和 Safe 代理地址及资产
|
||||
coroutineScope {
|
||||
val magicDeferred = async {
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.MAGIC).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
ProxyOptionDto(
|
||||
walletType = WalletType.MAGIC.value,
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
ProxyOptionDto(
|
||||
walletType = "magic",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Magic 代理地址失败"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Magic 代理地址或资产失败: ${e.message}", e)
|
||||
ProxyOptionDto(
|
||||
walletType = "magic",
|
||||
proxyAddress = blockchainService.calculateMagicProxyAddress(request.walletAddress),
|
||||
descriptionKey = "accountImport.proxyOption.magic.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val safeDeferred = async {
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
ProxyOptionDto(
|
||||
walletType = WalletType.SAFE.value,
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
} else {
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Safe 代理地址失败"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val magicOption = magicDeferred.await()
|
||||
val safeOption = safeDeferred.await()
|
||||
// Safe 在前,Magic 在后
|
||||
options.add(safeOption)
|
||||
options.add(magicOption)
|
||||
}
|
||||
} else {
|
||||
// 助记词导入:仅获取 Safe 代理地址及资产
|
||||
try {
|
||||
val proxyAddress = blockchainService.getProxyAddress(request.walletAddress, WalletType.SAFE).getOrNull()
|
||||
if (proxyAddress != null) {
|
||||
val balance = blockchainService.getWalletBalance(proxyAddress).getOrNull()
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = proxyAddress,
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = balance?.availableBalance ?: "0",
|
||||
positionBalance = balance?.positionBalance ?: "0",
|
||||
totalBalance = balance?.totalBalance ?: "0",
|
||||
positionCount = balance?.positions?.size ?: 0,
|
||||
hasAssets = (balance?.availableBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positionBalance?.toSafeBigDecimal()?.gt(BigDecimal.ZERO) == true) ||
|
||||
(balance?.positions?.isNotEmpty() == true),
|
||||
error = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取 Safe 代理地址失败"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取 Safe 代理地址或资产失败: ${e.message}", e)
|
||||
options.add(
|
||||
ProxyOptionDto(
|
||||
walletType = "safe",
|
||||
proxyAddress = "",
|
||||
descriptionKey = "accountImport.proxyOption.safe.description",
|
||||
availableBalance = "0",
|
||||
positionBalance = "0",
|
||||
totalBalance = "0",
|
||||
positionCount = 0,
|
||||
hasAssets = false,
|
||||
error = "获取资产信息失败: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Result.success(CheckProxyOptionsResponse(options = options))
|
||||
} catch (e: Exception) {
|
||||
logger.error("检查代理地址选项失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@@ -827,7 +1017,7 @@ class AccountService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -836,7 +1026,7 @@ class AccountService(
|
||||
side = "SELL",
|
||||
price = sellPrice,
|
||||
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
||||
signatureType = 2, // Browser Wallet(与正确订单数据一致)
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = expiration
|
||||
@@ -1189,13 +1379,6 @@ class AccountService(
|
||||
*/
|
||||
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
|
||||
return try {
|
||||
// 检查 Builder API Key 是否已配置
|
||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
|
||||
)
|
||||
}
|
||||
|
||||
if (request.positions.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
|
||||
}
|
||||
@@ -1217,7 +1400,17 @@ class AccountService(
|
||||
accounts[accountId] = account
|
||||
}
|
||||
|
||||
// 4. 验证并收集要赎回的仓位信息(按账户分组)
|
||||
// 4. 若涉及 Magic 账户,必须已配置 Builder API Key(提前判断,避免执行到深层再报错)
|
||||
val hasMagicAccount = accounts.values.any {
|
||||
WalletType.fromStringOrDefault(it.walletType, WalletType.SAFE) == WalletType.MAGIC
|
||||
}
|
||||
if (hasMagicAccount && !relayClientService.isBuilderApiKeyConfigured()) {
|
||||
return Result.failure(
|
||||
IllegalStateException("Builder API Key 未配置,无法执行 Magic 账户赎回(Gasless)。请前往系统设置页面配置 Builder API Key。")
|
||||
)
|
||||
}
|
||||
|
||||
// 5. 验证并收集要赎回的仓位信息(按账户分组)
|
||||
val accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
|
||||
val accountRedeemedInfo =
|
||||
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
|
||||
@@ -1260,7 +1453,7 @@ class AccountService(
|
||||
accountRedeemedInfo[accountId] = accountInfo
|
||||
}
|
||||
|
||||
// 5. 对每个账户执行赎回
|
||||
// 6. 对每个账户执行赎回(Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
|
||||
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
|
||||
var totalRedeemedValue = BigDecimal.ZERO
|
||||
|
||||
@@ -1280,11 +1473,13 @@ class AccountService(
|
||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||
|
||||
// 调用区块链服务赎回仓位
|
||||
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||
val redeemResult = blockchainService.redeemPositions(
|
||||
privateKey = decryptedPrivateKey,
|
||||
proxyAddress = account.proxyAddress,
|
||||
conditionId = marketId,
|
||||
indexSets = indexSets
|
||||
indexSets = indexSets,
|
||||
walletType = walletTypeEnum
|
||||
)
|
||||
|
||||
redeemResult.fold(
|
||||
@@ -1315,7 +1510,7 @@ class AccountService(
|
||||
)
|
||||
}
|
||||
|
||||
// 6. 发送赎回推送通知(异步,不阻塞)
|
||||
// 7. 发送赎回推送通知(异步,不阻塞)
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取当前语言设置
|
||||
|
||||
+13
-24
@@ -380,14 +380,10 @@ class PositionCheckService(
|
||||
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
||||
|
||||
for ((accountId, positions) in positionsByAccount) {
|
||||
// 查找该账户下所有启用的跟单配置
|
||||
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如尾盘策略账户也会执行赎回)
|
||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||
.filter { it.enabled }
|
||||
|
||||
if (copyTradings.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 过滤掉已经处理过的仓位(去重,避免重复赎回)
|
||||
val now = System.currentTimeMillis()
|
||||
val positionsToRedeem = positions.filter { position ->
|
||||
@@ -430,27 +426,20 @@ class PositionCheckService(
|
||||
processedRedeemablePositions[positionKey] = now
|
||||
}
|
||||
|
||||
// 赎回成功后,再查找订单并更新订单状态
|
||||
// 赎回成功后,按每个跟单配置分别查找未卖出订单并更新状态
|
||||
// 同一账户同一市场可能同时跟多个 Leader,需按 copyTradingId 分别生成自动卖出记录(如 leader1 对应 20 share,leader2 对应 16 share)
|
||||
for (position in positionsToRedeem) {
|
||||
// 查找相同仓位的未卖出订单(remaining_quantity > 0)
|
||||
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
|
||||
for (copyTrading in copyTradings) {
|
||||
if (position.outcomeIndex != null) {
|
||||
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||
copyTrading.id!!,
|
||||
position.marketId,
|
||||
position.outcomeIndex
|
||||
)
|
||||
unmatchedOrders.addAll(orders)
|
||||
}
|
||||
if (position.outcomeIndex == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果有未卖出订单,更新订单状态
|
||||
if (unmatchedOrders.isNotEmpty()) {
|
||||
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId)
|
||||
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
|
||||
if (copyTradingId != null) {
|
||||
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
|
||||
for (copyTrading in copyTradings) {
|
||||
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||
copyTrading.id!!,
|
||||
position.marketId,
|
||||
position.outcomeIndex
|
||||
)
|
||||
if (orders.isNotEmpty()) {
|
||||
updateOrdersAsSoldAfterRedeem(orders, position, copyTrading.id!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.dto.TradeData
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.delay
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 基于 start 游标的一批历史交易结果
|
||||
* @param trades 本批交易列表(已按时间升序)
|
||||
* @param nextCursorSeconds 下一页游标(API 的 start 参数,秒级);若本批不足 limit 条则为 null 表示最后一页
|
||||
*/
|
||||
data class LeaderTradesBatchResult(
|
||||
val trades: List<TradeData>,
|
||||
val nextCursorSeconds: Long?
|
||||
)
|
||||
|
||||
/**
|
||||
* 回测数据服务
|
||||
* 直接从 Polymarket Data API 获取 Leader 历史交易,使用 start 游标分页(避免 offset 过大报错)
|
||||
*/
|
||||
@Service
|
||||
class BacktestDataService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestDataService::class.java)
|
||||
|
||||
/**
|
||||
* 按 start 游标获取一批 Leader 历史交易
|
||||
* 规则:limit 固定为 500;若返回 500 条则取本批最大时间戳(秒)作为下一页 start,不加 1(同一秒可能多笔订单,由下游按 tradeId 去重);不足 500 则为最后一页
|
||||
*
|
||||
* @param leaderId Leader ID
|
||||
* @param startTime 回测开始时间(毫秒)
|
||||
* @param endTime 回测结束时间(毫秒)
|
||||
* @param cursorStartSeconds 本页游标(API 的 start,秒);首次传 startTime/1000
|
||||
* @param limit 每批条数,建议 500
|
||||
* @return 本批交易与下一页游标(null 表示没有下一页)
|
||||
*/
|
||||
suspend fun getLeaderHistoricalTradesBatch(
|
||||
leaderId: Long,
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
cursorStartSeconds: Long,
|
||||
limit: Int
|
||||
): LeaderTradesBatchResult {
|
||||
logger.info("获取 Leader 历史交易批次: leaderId=$leaderId, cursorStart=$cursorStartSeconds, limit=$limit")
|
||||
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: throw IllegalArgumentException("Leader 不存在: $leaderId")
|
||||
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
val endSeconds = endTime / 1000
|
||||
val maxRetries = 5
|
||||
val retryDelay = 1000L
|
||||
|
||||
var lastException: Exception? = null
|
||||
for (attempt in 1..maxRetries) {
|
||||
try {
|
||||
val response = dataApi.getUserActivity(
|
||||
user = leader.leaderAddress,
|
||||
type = listOf("TRADE"),
|
||||
start = cursorStartSeconds,
|
||||
end = endSeconds,
|
||||
limit = limit,
|
||||
offset = null,
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "ASC"
|
||||
)
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("从 Data API 获取用户活动失败: code=${response.code()}, message=${response.message()}")
|
||||
}
|
||||
|
||||
val activities = response.body()!!
|
||||
logger.info("本批获取 ${activities.size} 条活动(第 $attempt 次尝试)")
|
||||
|
||||
val trades = activities.mapNotNull { activity ->
|
||||
try {
|
||||
if (activity.type != "TRADE") return@mapNotNull null
|
||||
if (activity.side == null || activity.price == null || activity.size == null || activity.usdcSize == null) {
|
||||
logger.warn("活动数据缺少必要字段,跳过: activity=$activity")
|
||||
return@mapNotNull null
|
||||
}
|
||||
val tradeTimestamp = activity.timestamp * 1000
|
||||
if (tradeTimestamp < startTime || tradeTimestamp > endTime) {
|
||||
logger.debug("交易时间超出范围,跳过: timestamp=$tradeTimestamp")
|
||||
return@mapNotNull null
|
||||
}
|
||||
TradeData(
|
||||
tradeId = activity.transactionHash ?: "${activity.timestamp}_${activity.conditionId}_${activity.side}",
|
||||
marketId = activity.conditionId,
|
||||
marketTitle = activity.title,
|
||||
marketSlug = activity.slug,
|
||||
side = activity.side.uppercase(),
|
||||
outcome = activity.outcome ?: activity.outcomeIndex?.toString() ?: "",
|
||||
outcomeIndex = activity.outcomeIndex,
|
||||
price = activity.price.toSafeBigDecimal(),
|
||||
size = activity.size.toSafeBigDecimal(),
|
||||
amount = activity.usdcSize.toSafeBigDecimal(),
|
||||
timestamp = tradeTimestamp
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("转换活动数据失败: activity=$activity, error=${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// 下一页 start 用本批最大 timestamp(秒),不加 1:同一秒可能有多笔订单,依赖下游按 tradeId 去重
|
||||
val nextCursorSeconds: Long? = if (trades.size < limit) {
|
||||
null
|
||||
} else {
|
||||
val maxTs = trades.maxOf { it.timestamp }
|
||||
maxTs / 1000
|
||||
}
|
||||
return LeaderTradesBatchResult(trades = trades, nextCursorSeconds = nextCursorSeconds)
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
logger.warn("第 $attempt/$maxRetries 次获取批次失败: ${e.message}")
|
||||
if (attempt < maxRetries) {
|
||||
logger.info("等待 $retryDelay 毫秒后重试...")
|
||||
delay(retryDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
val errorMsg = "重试 $maxRetries 次后仍然失败,cursorStart=$cursorStartSeconds"
|
||||
logger.error(errorMsg, lastException)
|
||||
throw Exception(errorMsg, lastException)
|
||||
}
|
||||
}
|
||||
+792
@@ -0,0 +1,792 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.dto.TradeData
|
||||
import com.wrbug.polymarketbot.dto.BacktestStatisticsDto
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingFilterService
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@Service
|
||||
class BacktestExecutionService(
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val backtestTradeRepository: BacktestTradeRepository,
|
||||
private val backtestDataService: BacktestDataService,
|
||||
private val marketPriceService: MarketPriceService,
|
||||
private val marketService: MarketService,
|
||||
private val copyTradingFilterService: CopyTradingFilterService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestExecutionService::class.java)
|
||||
|
||||
/**
|
||||
* 持仓数据结构
|
||||
* @param marketEndDate 市场结束时间(毫秒),用于到期结算判断,null 表示未知
|
||||
*/
|
||||
data class Position(
|
||||
val marketId: String,
|
||||
val outcome: String,
|
||||
val outcomeIndex: Int?,
|
||||
var quantity: BigDecimal,
|
||||
val avgPrice: BigDecimal,
|
||||
val leaderBuyQuantity: BigDecimal?,
|
||||
val marketEndDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 将回测任务转换为虚拟的 CopyTrading 配置用于执行
|
||||
* 注意:回测场景使用历史数据,不需要实时跟单的相关配置
|
||||
*/
|
||||
private fun taskToCopyTrading(task: BacktestTask): CopyTrading {
|
||||
return CopyTrading(
|
||||
id = task.id,
|
||||
accountId = 0L,
|
||||
leaderId = task.leaderId,
|
||||
enabled = true,
|
||||
copyMode = task.copyMode,
|
||||
copyRatio = task.copyRatio,
|
||||
fixedAmount = null,
|
||||
maxOrderSize = task.maxOrderSize,
|
||||
minOrderSize = task.minOrderSize,
|
||||
maxDailyLoss = task.maxDailyLoss,
|
||||
maxDailyOrders = task.maxDailyOrders,
|
||||
priceTolerance = BigDecimal.ZERO, // 回测使用历史价格,不需要容忍度
|
||||
delaySeconds = 0, // 回测按时间线执行,无需延迟
|
||||
pollIntervalSeconds = 5,
|
||||
useWebSocket = false,
|
||||
websocketReconnectInterval = 5000,
|
||||
websocketMaxRetries = 10,
|
||||
supportSell = task.supportSell,
|
||||
minOrderDepth = null, // 回测无实时订单簿数据
|
||||
maxSpread = null, // 回测无实时价差数据
|
||||
maxPositionValue = task.maxPositionValue,
|
||||
minPrice = task.minPrice, // 最低价格
|
||||
maxPrice = task.maxPrice, // 最高价格
|
||||
keywordFilterMode = task.keywordFilterMode,
|
||||
keywords = task.keywords,
|
||||
configName = null,
|
||||
pushFailedOrders = false,
|
||||
pushFilteredOrders = false,
|
||||
createdAt = task.createdAt,
|
||||
updatedAt = task.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行回测任务(支持分页和恢复)
|
||||
* 自动处理所有页面的数据,支持中断恢复
|
||||
*/
|
||||
/** 每批请求 API 的条数(基于 start 游标分页,避免 offset 过大) */
|
||||
private val backtestBatchLimit = 500
|
||||
|
||||
@Transactional
|
||||
suspend fun executeBacktest(task: BacktestTask, page: Int = 1, size: Int = 100) {
|
||||
try {
|
||||
logger.info("开始执行回测任务: taskId=${task.id}, taskName=${task.taskName}, batchLimit=$backtestBatchLimit")
|
||||
|
||||
// 1. 更新任务状态为 RUNNING
|
||||
task.status = "RUNNING"
|
||||
task.executionStartedAt = System.currentTimeMillis()
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
// 2. 初始化
|
||||
var currentBalance = task.initialBalance
|
||||
val positions = mutableMapOf<String, Position>()
|
||||
val trades = mutableListOf<BacktestTrade>()
|
||||
val dailyOrderCountCache = mutableMapOf<String, Int>()
|
||||
val dailyLossCache = mutableMapOf<String, BigDecimal>()
|
||||
val seenTradeIds = mutableSetOf<String>()
|
||||
|
||||
// 3. 回测时间范围:首次执行以当前时间为基准取最近 backtestDays 天;断点续跑保留原 startTime,仅 endTime 延到当前
|
||||
val endTime = System.currentTimeMillis()
|
||||
val startTime = if (task.lastProcessedTradeTime == null) {
|
||||
endTime - (task.backtestDays * 24L * 3600 * 1000)
|
||||
} else {
|
||||
task.startTime
|
||||
}
|
||||
|
||||
logger.info("回测时间范围: ${formatTimestamp(startTime)} - ${formatTimestamp(endTime)} (${task.backtestDays} 天), " +
|
||||
"初始余额: ${task.initialBalance.toPlainString()}")
|
||||
|
||||
// 4. 游标分页:恢复时也从 lastProcessedTradeTime 所在秒开始拉(不加 1),与分页规则一致;已处理的通过 timestamp 跳过,不依赖内存 seenTradeIds
|
||||
var cursorSeconds = if (task.lastProcessedTradeTime != null) {
|
||||
task.lastProcessedTradeTime!! / 1000
|
||||
} else {
|
||||
startTime / 1000
|
||||
}
|
||||
val endSeconds = endTime / 1000
|
||||
val resumeThresholdMs = task.lastProcessedTradeTime ?: 0L
|
||||
|
||||
logger.info("开始游标分页:cursorStart=$cursorSeconds(恢复则跳过 timestamp<=${resumeThresholdMs}ms)")
|
||||
|
||||
var terminateBacktest = false
|
||||
while (true) {
|
||||
if (terminateBacktest) {
|
||||
logger.info("余额已为负或不足,终止回测循环")
|
||||
break
|
||||
}
|
||||
val currentTaskStatus = backtestTaskRepository.findById(task.id!!).orElse(null)
|
||||
if (currentTaskStatus == null || currentTaskStatus.status != "RUNNING") {
|
||||
logger.info("回测任务状态已变更: ${currentTaskStatus?.status},停止执行")
|
||||
break
|
||||
}
|
||||
|
||||
logger.info("正在获取批次数据 cursorStart=$cursorSeconds (${formatTimestamp(cursorSeconds * 1000)}) ...")
|
||||
|
||||
val currentPageTrades = mutableListOf<BacktestTrade>()
|
||||
|
||||
try {
|
||||
val batch = backtestDataService.getLeaderHistoricalTradesBatch(
|
||||
task.leaderId,
|
||||
startTime,
|
||||
endTime,
|
||||
cursorSeconds,
|
||||
backtestBatchLimit
|
||||
)
|
||||
val pageTrades = batch.trades
|
||||
|
||||
if (pageTrades.isEmpty()) {
|
||||
logger.info("本批无数据,所有数据处理完成")
|
||||
break
|
||||
}
|
||||
|
||||
logger.info("本批获取 ${pageTrades.size} 条交易,是否有下一页: ${batch.nextCursorSeconds != null}")
|
||||
|
||||
val countAtBatchStart = task.processedTradeCount
|
||||
var lastProcessedIndexInPage: Int? = null
|
||||
var processedInBatch = 0
|
||||
for (localIndex in pageTrades.indices) {
|
||||
val leaderTrade = pageTrades[localIndex]
|
||||
if (leaderTrade.tradeId in seenTradeIds) {
|
||||
logger.debug("跳过重复交易: ${leaderTrade.tradeId}")
|
||||
continue
|
||||
}
|
||||
if (resumeThresholdMs > 0 && leaderTrade.timestamp <= resumeThresholdMs) {
|
||||
logger.debug("恢复时跳过已处理时间戳: tradeId=${leaderTrade.tradeId}, timestamp=${leaderTrade.timestamp}")
|
||||
continue
|
||||
}
|
||||
seenTradeIds.add(leaderTrade.tradeId)
|
||||
|
||||
val index = countAtBatchStart + processedInBatch
|
||||
lastProcessedIndexInPage = index
|
||||
processedInBatch++
|
||||
|
||||
// 进度按时间比例:(当前订单时间 - 开始时间) / (结束时间 - 开始时间) * 100,运行中上限 99
|
||||
val timeRange = endTime - startTime
|
||||
val progress = if (timeRange > 0) {
|
||||
val elapsed = (leaderTrade.timestamp - startTime).coerceIn(0L, timeRange)
|
||||
min(99, ((elapsed * 100) / timeRange).toInt())
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if (progress > task.progress) {
|
||||
task.progress = progress
|
||||
task.processedTradeCount = index + 1
|
||||
backtestTaskRepository.save(task)
|
||||
}
|
||||
|
||||
try {
|
||||
// 5.1 实时检查并结算已到期的市场
|
||||
currentBalance = settleExpiredPositions(task, positions, currentBalance, trades, leaderTrade.timestamp, currentPageTrades)
|
||||
|
||||
// 5.2 检查余额和持仓状态
|
||||
if (currentBalance <= BigDecimal.ONE) {
|
||||
logger.info(
|
||||
if (currentBalance < BigDecimal.ZERO) "余额已为负,直接终止回测: $currentBalance"
|
||||
else "余额<=1,停止回测: $currentBalance"
|
||||
)
|
||||
terminateBacktest = true
|
||||
break
|
||||
}
|
||||
|
||||
// 5.3 应用过滤规则
|
||||
val copyTrading = taskToCopyTrading(task)
|
||||
val filterResult = copyTradingFilterService.checkFilters(
|
||||
copyTrading,
|
||||
tokenId = "",
|
||||
tradePrice = leaderTrade.price,
|
||||
copyOrderAmount = null,
|
||||
marketId = leaderTrade.marketId,
|
||||
marketTitle = leaderTrade.marketTitle,
|
||||
marketEndDate = null,
|
||||
outcomeIndex = leaderTrade.outcomeIndex
|
||||
)
|
||||
|
||||
if (!filterResult.isPassed) {
|
||||
logger.debug("交易被过滤: ${leaderTrade.tradeId}")
|
||||
continue
|
||||
}
|
||||
|
||||
// 5.4 每日订单数检查 - 使用缓存,只统计 BUY 订单
|
||||
val tradeDate = formatDate(leaderTrade.timestamp)
|
||||
val dailyOrderCount = dailyOrderCountCache.getOrDefault(tradeDate, 0)
|
||||
|
||||
if (dailyOrderCount >= task.maxDailyOrders) {
|
||||
logger.info("已达到每日最大 BUY 订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 5.6 计算跟单金额
|
||||
val followAmount = calculateFollowAmount(task, leaderTrade)
|
||||
|
||||
// 5.6.1 检查订单大小限制
|
||||
val finalFollowAmount = if (followAmount > task.maxOrderSize) {
|
||||
logger.info("跟单金额超过最大限制: $followAmount > ${task.maxOrderSize},调整为最大值")
|
||||
task.maxOrderSize
|
||||
} else if (followAmount < task.minOrderSize) {
|
||||
logger.info("跟单金额低于最小限制: $followAmount < ${task.minOrderSize},调整为最小值")
|
||||
task.minOrderSize
|
||||
} else {
|
||||
followAmount
|
||||
}
|
||||
|
||||
// 5.6.2 检查每日最大亏损(买入订单)- 使用缓存
|
||||
val dailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
|
||||
if (dailyLoss > task.maxDailyLoss) {
|
||||
logger.info("已达到每日最大亏损限制: $dailyLoss / ${task.maxDailyLoss},跳过买入订单")
|
||||
continue
|
||||
}
|
||||
|
||||
// 5.7 处理买卖逻辑
|
||||
if (leaderTrade.side == "BUY") {
|
||||
// 余额不足时按最大可用余额交易,仍须满足最小订单金额
|
||||
val actualBuyAmount = if (currentBalance < finalFollowAmount) {
|
||||
logger.debug("余额不足,按最大余额买入: balance=$currentBalance, 原需=$finalFollowAmount, marketId=${leaderTrade.marketId}")
|
||||
currentBalance
|
||||
} else {
|
||||
finalFollowAmount
|
||||
}
|
||||
if (actualBuyAmount < task.minOrderSize) {
|
||||
logger.debug("可用金额低于最小订单限制跳过: actual=$actualBuyAmount, minOrderSize=${task.minOrderSize}")
|
||||
continue
|
||||
}
|
||||
val quantity = actualBuyAmount.divide(leaderTrade.price, 8, java.math.RoundingMode.DOWN)
|
||||
if (quantity <= BigDecimal.ZERO) {
|
||||
logger.debug("计算数量为0跳过: actualBuyAmount=$actualBuyAmount, price=${leaderTrade.price}")
|
||||
continue
|
||||
}
|
||||
val totalCost = actualBuyAmount
|
||||
|
||||
// 5.6.3 检查最大仓位限制(如果配置了)
|
||||
if (task.maxPositionValue != null) {
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val currentPosition = positions[positionKey]
|
||||
val currentPositionValue = if (currentPosition != null) {
|
||||
currentPosition.quantity.multiply(currentPosition.avgPrice)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
val totalValueAfterOrder = currentPositionValue.add(actualBuyAmount)
|
||||
|
||||
if (totalValueAfterOrder.gt(task.maxPositionValue)) {
|
||||
val currentPositionValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
|
||||
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
|
||||
val maxValueStr = task.maxPositionValue.stripTrailingZeros().toPlainString()
|
||||
logger.info("超过最大仓位金额限制: 市场=${leaderTrade.marketId}, 方向=${leaderTrade.outcomeIndex}, 当前仓位=${currentPositionValueStr} USDC, 买入金额=${actualBuyAmount} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 更新余额和持仓(同市场同 outcome 多次买入合并:数量相加、加权均价、leaderBuyQuantity 相加)
|
||||
currentBalance -= totalCost
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val price = leaderTrade.price.toSafeBigDecimal()
|
||||
val leaderSize = leaderTrade.size.toSafeBigDecimal()
|
||||
val existing = positions[positionKey]
|
||||
positions[positionKey] = if (existing != null) {
|
||||
val newQuantity = existing.quantity.add(quantity)
|
||||
val newAvgPrice = if (newQuantity > BigDecimal.ZERO) {
|
||||
existing.quantity.multiply(existing.avgPrice).add(quantity.multiply(price))
|
||||
.divide(newQuantity, 8, java.math.RoundingMode.HALF_UP)
|
||||
} else {
|
||||
price
|
||||
}
|
||||
val newLeaderBuyQuantity = (existing.leaderBuyQuantity ?: BigDecimal.ZERO).add(leaderSize)
|
||||
Position(
|
||||
marketId = leaderTrade.marketId,
|
||||
outcome = leaderTrade.outcome ?: "",
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = newQuantity,
|
||||
avgPrice = newAvgPrice,
|
||||
leaderBuyQuantity = newLeaderBuyQuantity,
|
||||
marketEndDate = existing.marketEndDate
|
||||
)
|
||||
} else {
|
||||
val market = marketService.getMarket(leaderTrade.marketId)
|
||||
Position(
|
||||
marketId = leaderTrade.marketId,
|
||||
outcome = leaderTrade.outcome ?: "",
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = quantity,
|
||||
avgPrice = price,
|
||||
leaderBuyQuantity = leaderSize,
|
||||
marketEndDate = market?.endDate
|
||||
)
|
||||
}
|
||||
|
||||
// 记录交易到当前页列表
|
||||
currentPageTrades.add(BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = leaderTrade.timestamp,
|
||||
marketId = leaderTrade.marketId,
|
||||
marketTitle = leaderTrade.marketTitle,
|
||||
side = "BUY",
|
||||
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = quantity,
|
||||
price = leaderTrade.price.toSafeBigDecimal(),
|
||||
amount = actualBuyAmount,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = null,
|
||||
balanceAfter = currentBalance,
|
||||
leaderTradeId = leaderTrade.tradeId
|
||||
))
|
||||
|
||||
// 更新每日订单数缓存
|
||||
dailyOrderCountCache[tradeDate] = dailyOrderCount + 1
|
||||
|
||||
} else {
|
||||
// SELL 逻辑
|
||||
if (!task.supportSell) {
|
||||
continue
|
||||
}
|
||||
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val position = positions[positionKey] ?: continue
|
||||
|
||||
// 计算卖出数量
|
||||
val sellQuantity = if (task.copyMode == "RATIO") {
|
||||
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
|
||||
position.quantity.multiply(
|
||||
leaderTrade.size.divide(position.leaderBuyQuantity, 8, java.math.RoundingMode.DOWN)
|
||||
)
|
||||
} else {
|
||||
position.quantity
|
||||
}
|
||||
} else {
|
||||
position.quantity
|
||||
}
|
||||
|
||||
val actualSellQuantity = if (sellQuantity > position.quantity) {
|
||||
position.quantity
|
||||
} else {
|
||||
sellQuantity
|
||||
}
|
||||
|
||||
// 计算卖出金额
|
||||
val sellAmount = actualSellQuantity.multiply(leaderTrade.price.toSafeBigDecimal())
|
||||
|
||||
// 5.6.2 检查卖出金额限制
|
||||
val finalSellAmount = if (sellAmount > task.maxOrderSize) {
|
||||
logger.info("卖出金额超过最大限制: $sellAmount > ${task.maxOrderSize},调整为最大值")
|
||||
task.maxOrderSize
|
||||
} else if (sellAmount < task.minOrderSize) {
|
||||
logger.info("卖出金额低于最小限制: $sellAmount < ${task.minOrderSize},调整为最小值")
|
||||
task.minOrderSize
|
||||
} else {
|
||||
sellAmount
|
||||
}
|
||||
|
||||
val netAmount = finalSellAmount
|
||||
|
||||
// 计算盈亏
|
||||
val cost = actualSellQuantity.multiply(position.avgPrice)
|
||||
val profitLoss = netAmount.subtract(cost)
|
||||
|
||||
// 更新余额和持仓
|
||||
currentBalance += netAmount
|
||||
if (position.quantity <= BigDecimal.ZERO) {
|
||||
positions.remove(positionKey)
|
||||
}
|
||||
|
||||
// 记录交易到当前页列表
|
||||
currentPageTrades.add(BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = leaderTrade.timestamp,
|
||||
marketId = leaderTrade.marketId,
|
||||
marketTitle = leaderTrade.marketTitle,
|
||||
side = "SELL",
|
||||
outcome = leaderTrade.outcome ?: leaderTrade.outcomeIndex.toString(),
|
||||
outcomeIndex = leaderTrade.outcomeIndex,
|
||||
quantity = actualSellQuantity,
|
||||
price = leaderTrade.price.toSafeBigDecimal(),
|
||||
amount = finalSellAmount,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = currentBalance,
|
||||
leaderTradeId = leaderTrade.tradeId
|
||||
))
|
||||
// SELL 订单不计入每日订单数限制
|
||||
|
||||
// 更新每日亏损缓存(只累加亏损,不累加盈利)
|
||||
if (profitLoss < BigDecimal.ZERO) {
|
||||
val currentDailyLoss = dailyLossCache.getOrDefault(tradeDate, BigDecimal.ZERO)
|
||||
dailyLossCache[tradeDate] = currentDailyLoss + profitLoss.negate()
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("处理交易失败: tradeId=${leaderTrade.tradeId}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存本批交易
|
||||
if (currentPageTrades.isNotEmpty()) {
|
||||
logger.info("保存本批交易,共 ${currentPageTrades.size} 笔")
|
||||
backtestTradeRepository.saveAll(currentPageTrades)
|
||||
|
||||
val lastTradeInPage = currentPageTrades.lastOrNull()
|
||||
if (lastTradeInPage != null && lastProcessedIndexInPage != null) {
|
||||
task.lastProcessedTradeTime = lastTradeInPage.tradeTime
|
||||
task.lastProcessedTradeIndex = lastProcessedIndexInPage
|
||||
task.processedTradeCount = lastProcessedIndexInPage + 1
|
||||
task.finalBalance = currentBalance
|
||||
backtestTaskRepository.save(task)
|
||||
logger.info("本批处理完成,lastProcessedTradeIndex=${task.lastProcessedTradeIndex}, 总处理数=${task.processedTradeCount}")
|
||||
}
|
||||
} else {
|
||||
logger.info("本批没有交易需要保存")
|
||||
}
|
||||
|
||||
trades.addAll(currentPageTrades)
|
||||
|
||||
if (batch.nextCursorSeconds == null) {
|
||||
logger.info("本批不足 $backtestBatchLimit 条,已是最后一页")
|
||||
break
|
||||
}
|
||||
cursorSeconds = batch.nextCursorSeconds!!
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取或处理本批数据失败: ${e.message}", e)
|
||||
// 重试失败,标记任务为 FAILED
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 处理回测结束时仍未到期的持仓
|
||||
val remainingSettlements = mutableListOf<BacktestTrade>()
|
||||
currentBalance = settleRemainingPositions(task, positions, currentBalance, trades, endTime, remainingSettlements)
|
||||
if (remainingSettlements.isNotEmpty()) {
|
||||
backtestTradeRepository.saveAll(remainingSettlements)
|
||||
logger.info("回测结束结算剩余持仓,持久化 ${remainingSettlements.size} 笔 SETTLEMENT(CLOSED)")
|
||||
}
|
||||
|
||||
// 7. 计算最终统计数据
|
||||
val statistics = calculateStatistics(trades)
|
||||
|
||||
// 8. 更新任务状态
|
||||
val profitAmount = currentBalance.subtract(task.initialBalance)
|
||||
val profitRate = if (task.initialBalance > BigDecimal.ZERO) {
|
||||
profitAmount.divide(task.initialBalance, 4, java.math.RoundingMode.HALF_UP).multiply(BigDecimal("100"))
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
val finalStatus = if (task.status == "STOPPED") "STOPPED" else "COMPLETED"
|
||||
|
||||
task.finalBalance = currentBalance
|
||||
task.profitAmount = profitAmount
|
||||
task.profitRate = profitRate
|
||||
task.endTime = endTime
|
||||
task.status = finalStatus
|
||||
task.progress = 100
|
||||
task.totalTrades = trades.size
|
||||
task.buyTrades = trades.count { it.side == "BUY" }
|
||||
task.sellTrades = trades.count { it.side == "SELL" }
|
||||
task.winTrades = statistics.winTrades
|
||||
task.lossTrades = statistics.lossTrades
|
||||
task.winRate = statistics.winRate.toSafeBigDecimal()
|
||||
task.maxProfit = statistics.maxProfit.toSafeBigDecimal()
|
||||
task.maxLoss = statistics.maxLoss.toSafeBigDecimal()
|
||||
task.maxDrawdown = statistics.maxDrawdown.toSafeBigDecimal()
|
||||
task.avgHoldingTime = statistics.avgHoldingTime
|
||||
task.executionFinishedAt = System.currentTimeMillis()
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
logger.info("回测任务执行完成: taskId=${task.id}, " +
|
||||
"最终余额=${currentBalance.toPlainString()}, " +
|
||||
"收益额=${task.profitAmount?.toPlainString()}, " +
|
||||
"收益率=${task.profitRate?.toPlainString()}%, " +
|
||||
"总交易数=${trades.size}, " +
|
||||
"盈利率=${task.winRate?.toPlainString()}%")
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("回测任务执行失败: taskId=${task.id}", e)
|
||||
task.status = "FAILED"
|
||||
task.errorMessage = e.message
|
||||
task.executionFinishedAt = System.currentTimeMillis()
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(task)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算已到期的市场
|
||||
* @param batchTradesToSave 本批要持久化的交易列表,到期结算(赎回/输)会追加到此列表并随本批一起落库
|
||||
*/
|
||||
private suspend fun settleExpiredPositions(
|
||||
task: BacktestTask,
|
||||
positions: MutableMap<String, Position>,
|
||||
currentBalance: BigDecimal,
|
||||
trades: MutableList<BacktestTrade>,
|
||||
currentTime: Long,
|
||||
batchTradesToSave: MutableList<BacktestTrade>
|
||||
): BigDecimal {
|
||||
var balance = currentBalance
|
||||
|
||||
for ((positionKey, position) in positions.toList()) {
|
||||
try {
|
||||
// 仅当市场已到期(结束时间 <= 当前回测时间)时才结算,避免未到期持仓被误结算
|
||||
if (position.marketEndDate == null || position.marketEndDate!! > currentTime) {
|
||||
logger.debug("持仓未到期跳过结算: marketId=${position.marketId}, endDate=${position.marketEndDate}, currentTime=$currentTime")
|
||||
continue
|
||||
}
|
||||
// 获取市场当前价格
|
||||
val marketPrice = marketPriceService.getCurrentMarketPrice(
|
||||
position.marketId,
|
||||
position.outcomeIndex ?: 0
|
||||
)
|
||||
|
||||
val price = marketPrice.toSafeBigDecimal()
|
||||
|
||||
// 通过市场价格判断结算价格
|
||||
val settlementPrice = when {
|
||||
price >= BigDecimal("0.95") -> BigDecimal.ONE
|
||||
price <= BigDecimal("0.05") -> BigDecimal.ZERO
|
||||
else -> position.avgPrice
|
||||
}
|
||||
|
||||
val settlementValue = position.quantity.multiply(settlementPrice)
|
||||
val profitLoss = settlementValue.subtract(position.quantity.multiply(position.avgPrice))
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
|
||||
val settlementTrade = BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = currentTime,
|
||||
marketId = position.marketId,
|
||||
marketTitle = marketTitle,
|
||||
side = "SETTLEMENT",
|
||||
outcome = when {
|
||||
settlementPrice == BigDecimal.ONE -> "WIN"
|
||||
settlementPrice == BigDecimal.ZERO -> "LOSE"
|
||||
else -> "UNKNOWN"
|
||||
},
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
quantity = position.quantity,
|
||||
price = settlementPrice,
|
||||
amount = settlementValue,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = balance,
|
||||
leaderTradeId = null
|
||||
)
|
||||
trades.add(settlementTrade)
|
||||
batchTradesToSave.add(settlementTrade)
|
||||
|
||||
// 移除已结算的持仓
|
||||
positions.remove(positionKey)
|
||||
} catch (e: Exception) {
|
||||
logger.error("结算市场失败: marketId=${position.marketId}, outcomeIndex=${position.outcomeIndex}", e)
|
||||
}
|
||||
}
|
||||
|
||||
return balance
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算未到期持仓(回测结束时剩余持仓按均价平仓)
|
||||
* @param settlementsToSave 本批结算记录会追加到此列表,调用方需落库
|
||||
*/
|
||||
private suspend fun settleRemainingPositions(
|
||||
task: BacktestTask,
|
||||
positions: MutableMap<String, Position>,
|
||||
currentBalance: BigDecimal,
|
||||
trades: MutableList<BacktestTrade>,
|
||||
currentTime: Long,
|
||||
settlementsToSave: MutableList<BacktestTrade>
|
||||
): BigDecimal {
|
||||
var balance = currentBalance
|
||||
|
||||
for ((positionKey, position) in positions.toList()) {
|
||||
val quantity = position.quantity
|
||||
val avgPrice = position.avgPrice
|
||||
val settlementPrice = avgPrice
|
||||
|
||||
val settlementValue = quantity.multiply(settlementPrice)
|
||||
val profitLoss = settlementValue.negate()
|
||||
|
||||
balance += settlementValue
|
||||
|
||||
val marketTitle = marketService.getMarket(position.marketId)?.title ?: ""
|
||||
val closedTrade = BacktestTrade(
|
||||
backtestTaskId = task.id!!,
|
||||
tradeTime = currentTime,
|
||||
marketId = position.marketId,
|
||||
marketTitle = marketTitle,
|
||||
side = "SETTLEMENT",
|
||||
outcome = "CLOSED",
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
quantity = quantity,
|
||||
price = avgPrice,
|
||||
amount = settlementValue,
|
||||
fee = BigDecimal.ZERO,
|
||||
profitLoss = profitLoss,
|
||||
balanceAfter = balance,
|
||||
leaderTradeId = null
|
||||
)
|
||||
trades.add(closedTrade)
|
||||
settlementsToSave.add(closedTrade)
|
||||
}
|
||||
|
||||
positions.clear()
|
||||
return balance
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算统计数据
|
||||
*/
|
||||
private fun calculateStatistics(trades: List<BacktestTrade>): BacktestStatisticsDto {
|
||||
val buyTrades = trades.count { it.side == "BUY" }
|
||||
val sellTrades = trades.count { it.side == "SELL" }
|
||||
val winTrades = trades.count { it.profitLoss != null && it.profitLoss > BigDecimal.ZERO }
|
||||
val lossTrades = trades.count { it.profitLoss != null && it.profitLoss < BigDecimal.ZERO }
|
||||
|
||||
var totalProfit = BigDecimal.ZERO
|
||||
var totalLoss = BigDecimal.ZERO
|
||||
var maxProfit = BigDecimal.ZERO
|
||||
var maxLoss = BigDecimal.ZERO
|
||||
|
||||
// 计算最大回撤
|
||||
var runningBalance = if (trades.isNotEmpty()) {
|
||||
trades[0].balanceAfter?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
var peakBalance = runningBalance
|
||||
var maxDrawdown = BigDecimal.ZERO
|
||||
|
||||
for (i in trades.indices) {
|
||||
val trade = trades[i]
|
||||
val balance = trade.balanceAfter?.toSafeBigDecimal() ?: continue
|
||||
|
||||
if (trade.profitLoss != null) {
|
||||
val pnl = trade.profitLoss.toSafeBigDecimal()
|
||||
if (pnl > BigDecimal.ZERO) {
|
||||
totalProfit += pnl
|
||||
if (pnl > maxProfit) maxProfit = pnl
|
||||
} else {
|
||||
totalLoss += pnl
|
||||
if (pnl < maxLoss) maxLoss = pnl
|
||||
}
|
||||
}
|
||||
|
||||
if (balance > peakBalance) {
|
||||
peakBalance = balance
|
||||
}
|
||||
val drawdown = peakBalance - runningBalance
|
||||
if (drawdown > maxDrawdown) {
|
||||
maxDrawdown = drawdown
|
||||
}
|
||||
|
||||
runningBalance = balance
|
||||
}
|
||||
|
||||
// 计算平均持仓时间
|
||||
var avgHoldingTime: Long? = null
|
||||
if (trades.size > 1) {
|
||||
var totalHoldingTime = 0L
|
||||
var count = 0
|
||||
for (i in 0 until trades.size - 1) {
|
||||
val currentTrade = trades[i]
|
||||
val nextTrade = trades[i + 1]
|
||||
|
||||
if (currentTrade.side == "BUY" && nextTrade.side == "SELL") {
|
||||
val holdingTime = nextTrade.tradeTime - currentTrade.tradeTime
|
||||
totalHoldingTime += holdingTime
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
avgHoldingTime = totalHoldingTime / count
|
||||
}
|
||||
}
|
||||
|
||||
return BacktestStatisticsDto(
|
||||
totalTrades = trades.size,
|
||||
buyTrades = buyTrades,
|
||||
sellTrades = sellTrades,
|
||||
winTrades = winTrades,
|
||||
lossTrades = lossTrades,
|
||||
winRate = if (buyTrades + sellTrades > 0) {
|
||||
(winTrades.toBigDecimal().divide((buyTrades + sellTrades).toBigDecimal(), 4, java.math.RoundingMode.HALF_UP))
|
||||
.multiply(BigDecimal("100"))
|
||||
.toPlainString()
|
||||
} else {
|
||||
BigDecimal.ZERO.toPlainString()
|
||||
},
|
||||
maxProfit = maxProfit.toPlainString(),
|
||||
maxLoss = maxLoss.toPlainString(),
|
||||
maxDrawdown = maxDrawdown.toPlainString(),
|
||||
avgHoldingTime = avgHoldingTime
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算跟单金额
|
||||
*/
|
||||
private fun calculateFollowAmount(task: BacktestTask, leaderTrade: TradeData): BigDecimal {
|
||||
return if (task.copyMode == "RATIO") {
|
||||
// 比例模式:Leader 成交金额 × 跟单比例
|
||||
leaderTrade.amount.toSafeBigDecimal().multiply(task.copyRatio)
|
||||
} else {
|
||||
// 固定金额模式:使用配置的固定金额
|
||||
task.fixedAmount ?: leaderTrade.amount.toSafeBigDecimal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否同一天
|
||||
*/
|
||||
private fun isSameDay(timestamp1: Long, timestamp2: Long): Boolean {
|
||||
val cal1 = Calendar.getInstance().apply { timeInMillis = timestamp1 }
|
||||
val cal2 = Calendar.getInstance().apply { timeInMillis = timestamp2 }
|
||||
return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
|
||||
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
*/
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期(用于缓存key)
|
||||
*/
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd")
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* 回测轮询服务
|
||||
* 定时获取待执行的回测任务并执行
|
||||
*/
|
||||
@Service
|
||||
class BacktestPollingService(
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val executionService: BacktestExecutionService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestPollingService::class.java)
|
||||
|
||||
// 线程池:同一时刻只执行一个任务
|
||||
private val executor: ExecutorService = Executors.newFixedThreadPool(1) as ThreadPoolExecutor
|
||||
|
||||
/**
|
||||
* 轮询待执行的回测任务
|
||||
* 每 10 秒执行一次
|
||||
* 规则:同一时刻只执行一个任务,如果有多个待执行任务,按创建时间先后执行最早创建的
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10000) // 10 秒
|
||||
fun pollPendingTasks() {
|
||||
try {
|
||||
// 1. 检查是否有长时间处于 RUNNING 状态的任务(可能是应用重启导致的)
|
||||
val runningTasks = backtestTaskRepository.findByStatus("RUNNING")
|
||||
if (runningTasks.isNotEmpty()) {
|
||||
val activeQueueSize = (executor as ThreadPoolExecutor).queue.size
|
||||
val activeCount = (executor as ThreadPoolExecutor).activeCount
|
||||
|
||||
// 如果有线程池中没有活跃任务但有 RUNNING 状态的任务,说明是应用重启导致的
|
||||
// 重置这些任务的状态为 PENDING,以便恢复执行
|
||||
if (activeCount == 0 && runningTasks.isNotEmpty()) {
|
||||
logger.info("检测到应用重启导致的异常 RUNNING 任务,重置为 PENDING 以便恢复")
|
||||
runningTasks.forEach { task ->
|
||||
val now = System.currentTimeMillis()
|
||||
val executionStartedAt = task.executionStartedAt
|
||||
val executionDuration = if (executionStartedAt != null) {
|
||||
now - executionStartedAt
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
|
||||
// 如果任务执行时间超过 1 分钟,认为是异常状态
|
||||
if (executionDuration > 60000) {
|
||||
logger.info("重置异常 RUNNING 任务: taskId=${task.id}, executionStartedAt=$executionStartedAt, duration=${executionDuration}ms")
|
||||
task.status = "PENDING"
|
||||
task.updatedAt = now
|
||||
backtestTaskRepository.save(task)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.debug("有 ${runningTasks.size} 个任务正在执行,跳过本次轮询")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查询所有 PENDING 状态的任务,按创建时间升序排序
|
||||
val pendingTasks = backtestTaskRepository.findByStatus("PENDING")
|
||||
.sortedBy { it.createdAt }
|
||||
|
||||
if (pendingTasks.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 只执行最早创建的任务
|
||||
val taskToExecute = pendingTasks.first()
|
||||
logger.info("找到 ${pendingTasks.size} 个待执行的回测任务,执行最早创建的任务: taskId=${taskToExecute.id}, createdAt=${taskToExecute.createdAt}")
|
||||
|
||||
// 4. 提交任务到线程池执行
|
||||
executor.submit {
|
||||
try {
|
||||
// 执行前再次检查任务状态(防止并发执行)
|
||||
val currentTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
|
||||
if (currentTask == null || currentTask.status != "PENDING") {
|
||||
logger.debug("任务状态已变更,跳过执行: taskId=${taskToExecute.id}, currentStatus=${currentTask?.status}")
|
||||
return@submit
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
// 使用 start 游标分页,恢复时由 lastProcessedTradeTime 决定从何时开始拉取
|
||||
logger.info("执行回测任务: taskId=${currentTask.id}(游标分页,limit=500)")
|
||||
executionService.executeBacktest(currentTask, page = 0, size = 500)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("回测任务执行失败: taskId=${taskToExecute.id}", e)
|
||||
// 更新任务状态为 FAILED
|
||||
val failedTask = backtestTaskRepository.findById(taskToExecute.id!!).orElse(null)
|
||||
if (failedTask != null) {
|
||||
failedTask.status = "FAILED"
|
||||
failedTask.errorMessage = e.message
|
||||
failedTask.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(failedTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("轮询回测任务失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package com.wrbug.polymarketbot.service.backtest
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.BacktestTask
|
||||
import com.wrbug.polymarketbot.entity.BacktestTrade
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import com.wrbug.polymarketbot.repository.BacktestTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.toJson
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 回测任务服务
|
||||
*/
|
||||
@Service
|
||||
class BacktestService(
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val backtestTradeRepository: BacktestTradeRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(BacktestService::class.java)
|
||||
|
||||
/**
|
||||
* 创建回测任务
|
||||
*/
|
||||
@Transactional
|
||||
fun createBacktestTask(request: BacktestCreateRequest): Result<BacktestTaskDto> {
|
||||
return try {
|
||||
// 1. 验证 Leader 是否存在
|
||||
val leader = leaderRepository.findById(request.leaderId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
// 2. 验证回测天数
|
||||
if (request.backtestDays < 1 || request.backtestDays > 15) {
|
||||
return Result.failure(IllegalArgumentException("回测天数必须在 1-15 之间"))
|
||||
}
|
||||
|
||||
// 3. 验证恢复页码(如果提供)
|
||||
if (request.pageForResume != null && request.pageForResume < 1) {
|
||||
return Result.failure(IllegalArgumentException("恢复页码必须大于 0"))
|
||||
}
|
||||
|
||||
// 4. 验证初始金额
|
||||
val initialBalance = request.initialBalance.toSafeBigDecimal()
|
||||
if (initialBalance <= BigDecimal.ZERO) {
|
||||
return Result.failure(IllegalArgumentException("初始金额必须大于 0"))
|
||||
}
|
||||
|
||||
// 4. 创建回测任务
|
||||
val task = BacktestTask(
|
||||
taskName = request.taskName.trim(),
|
||||
leaderId = request.leaderId,
|
||||
initialBalance = initialBalance,
|
||||
backtestDays = request.backtestDays,
|
||||
startTime = System.currentTimeMillis() - (request.backtestDays * 24 * 3600 * 1000),
|
||||
status = "PENDING",
|
||||
|
||||
// 跟单配置(不包含 max_position_count)
|
||||
copyMode = request.copyMode ?: "RATIO",
|
||||
copyRatio = request.copyRatio?.toSafeBigDecimal() ?: BigDecimal.ONE,
|
||||
fixedAmount = request.fixedAmount?.toSafeBigDecimal(),
|
||||
maxOrderSize = request.maxOrderSize?.toSafeBigDecimal() ?: "1000".toSafeBigDecimal(),
|
||||
minOrderSize = request.minOrderSize?.toSafeBigDecimal() ?: "1".toSafeBigDecimal(),
|
||||
maxDailyLoss = request.maxDailyLoss?.toSafeBigDecimal() ?: "10000".toSafeBigDecimal(),
|
||||
maxDailyOrders = request.maxDailyOrders ?: 100,
|
||||
supportSell = request.supportSell ?: true,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = if (request.keywords != null && request.keywords.isNotEmpty()) {
|
||||
request.keywords.toJson()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
// 5. 转换为 DTO 返回
|
||||
Result.success(task.toDto(leader))
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务列表
|
||||
*/
|
||||
fun getBacktestTaskList(request: BacktestListRequest): Result<BacktestListResponse> {
|
||||
return try {
|
||||
// 获取所有符合条件的任务
|
||||
val allTasks = when {
|
||||
request.leaderId != null && request.status != null -> {
|
||||
backtestTaskRepository.findByLeaderIdAndStatus(request.leaderId, request.status)
|
||||
}
|
||||
request.leaderId != null -> {
|
||||
backtestTaskRepository.findByLeaderId(request.leaderId)
|
||||
.filter { request.status == null || it.status == request.status }
|
||||
}
|
||||
request.status != null -> {
|
||||
backtestTaskRepository.findByStatus(request.status)
|
||||
}
|
||||
else -> {
|
||||
backtestTaskRepository.findAll()
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
val sortedTasks = when (request.sortBy) {
|
||||
"profitAmount" -> {
|
||||
if (request.sortOrder == "asc") {
|
||||
allTasks.sortedBy { it.profitAmount }
|
||||
} else {
|
||||
allTasks.sortedByDescending { it.profitAmount }
|
||||
}
|
||||
}
|
||||
"profitRate" -> {
|
||||
if (request.sortOrder == "asc") {
|
||||
allTasks.sortedBy { it.profitRate }
|
||||
} else {
|
||||
allTasks.sortedByDescending { it.profitRate }
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (request.sortOrder == "asc") {
|
||||
allTasks.sortedBy { it.createdAt }
|
||||
} else {
|
||||
allTasks.sortedByDescending { it.createdAt }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
val total = sortedTasks.size
|
||||
val pagedTasks = sortedTasks
|
||||
.drop((request.page - 1) * request.size)
|
||||
.take(request.size)
|
||||
|
||||
val list = pagedTasks.map { task ->
|
||||
val leader = leaderRepository.findById(task.leaderId).orElse(null)
|
||||
task.toDto(leader)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
BacktestListResponse(
|
||||
list = list,
|
||||
total = total.toLong(),
|
||||
page = request.page,
|
||||
size = request.size
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务列表失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测任务详情
|
||||
*/
|
||||
fun getBacktestTaskDetail(request: BacktestDetailRequest): Result<BacktestDetailResponse> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
val leader = leaderRepository.findById(task.leaderId).orElse(null)
|
||||
|
||||
val config = BacktestConfigDto(
|
||||
copyMode = task.copyMode,
|
||||
copyRatio = task.copyRatio.toPlainString(),
|
||||
fixedAmount = task.fixedAmount?.toPlainString(),
|
||||
maxOrderSize = task.maxOrderSize.toPlainString(),
|
||||
minOrderSize = task.minOrderSize.toPlainString(),
|
||||
maxDailyLoss = task.maxDailyLoss.toPlainString(),
|
||||
maxDailyOrders = task.maxDailyOrders,
|
||||
supportSell = task.supportSell,
|
||||
keywordFilterMode = task.keywordFilterMode,
|
||||
keywords = if (task.keywords != null) {
|
||||
task.keywords.fromJson<List<String>>()
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
maxPositionValue = task.maxPositionValue?.toPlainString(),
|
||||
minPrice = task.minPrice?.toPlainString(),
|
||||
maxPrice = task.maxPrice?.toPlainString()
|
||||
)
|
||||
|
||||
val statistics = BacktestStatisticsDto(
|
||||
totalTrades = task.totalTrades,
|
||||
buyTrades = task.buyTrades,
|
||||
sellTrades = task.sellTrades,
|
||||
winTrades = task.winTrades,
|
||||
lossTrades = task.lossTrades,
|
||||
winRate = task.winRate?.toPlainString() ?: "0.00",
|
||||
maxProfit = task.maxProfit?.toPlainString() ?: "0.00",
|
||||
maxLoss = task.maxLoss?.toPlainString() ?: "0.00",
|
||||
maxDrawdown = task.maxDrawdown?.toPlainString() ?: "0.00",
|
||||
avgHoldingTime = task.avgHoldingTime
|
||||
)
|
||||
|
||||
val taskDto = task.toDto(leader)
|
||||
|
||||
Result.success(
|
||||
BacktestDetailResponse(
|
||||
task = taskDto,
|
||||
config = config,
|
||||
statistics = statistics
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测任务详情失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询回测交易记录
|
||||
*/
|
||||
fun getBacktestTrades(request: BacktestTradeListRequest): Result<BacktestTradeListResponse> {
|
||||
return try {
|
||||
val pageRequest = PageRequest.of(
|
||||
request.page - 1,
|
||||
request.size,
|
||||
Sort.by(Sort.Order.asc("tradeTime"))
|
||||
)
|
||||
|
||||
val tradesPage = backtestTradeRepository.findByBacktestTaskId(
|
||||
request.taskId,
|
||||
pageRequest
|
||||
)
|
||||
|
||||
val list = tradesPage.content.map { trade ->
|
||||
BacktestTradeDto(
|
||||
id = trade.id!!,
|
||||
tradeTime = trade.tradeTime,
|
||||
marketId = trade.marketId,
|
||||
marketTitle = trade.marketTitle,
|
||||
side = trade.side,
|
||||
outcome = trade.outcome,
|
||||
outcomeIndex = trade.outcomeIndex,
|
||||
quantity = trade.quantity.toPlainString(),
|
||||
price = trade.price.toPlainString(),
|
||||
amount = trade.amount.toPlainString(),
|
||||
fee = trade.fee.toPlainString(),
|
||||
profitLoss = trade.profitLoss?.toPlainString(),
|
||||
balanceAfter = trade.balanceAfter.toPlainString(),
|
||||
leaderTradeId = trade.leaderTradeId
|
||||
)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
BacktestTradeListResponse(
|
||||
list = list,
|
||||
total = tradesPage.totalElements,
|
||||
page = request.page,
|
||||
size = request.size
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询回测交易记录失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除回测任务
|
||||
*/
|
||||
@Transactional
|
||||
fun deleteBacktestTask(request: BacktestDeleteRequest): Result<Unit> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (task.status == "RUNNING") {
|
||||
return Result.failure(IllegalStateException("回测任务正在运行,无法删除"))
|
||||
}
|
||||
|
||||
backtestTaskRepository.deleteById(request.id)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止回测任务
|
||||
*/
|
||||
@Transactional
|
||||
fun stopBacktestTask(request: BacktestStopRequest): Result<Unit> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (task.status != "RUNNING") {
|
||||
return Result.failure(IllegalArgumentException("回测任务未在运行中"))
|
||||
}
|
||||
|
||||
task.status = "STOPPED"
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("停止回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试回测任务
|
||||
* 从断点继续执行,保留已处理的交易记录
|
||||
*/
|
||||
@Transactional
|
||||
fun retryBacktestTask(request: BacktestRetryRequest): Result<Unit> {
|
||||
return try {
|
||||
val task = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (task.status == "RUNNING") {
|
||||
return Result.failure(IllegalArgumentException("回测任务正在运行中,无需重试"))
|
||||
}
|
||||
|
||||
// 重置任务状态为 PENDING,进度保持不变
|
||||
task.status = "PENDING"
|
||||
task.errorMessage = null
|
||||
task.updatedAt = System.currentTimeMillis()
|
||||
|
||||
// 不清理已处理的交易记录,保留恢复点
|
||||
backtestTaskRepository.save(task)
|
||||
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("重试回测任务失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前配置重新测试:基于已完成的回测任务创建一份相同配置的新任务(名称可修改)
|
||||
*/
|
||||
@Transactional
|
||||
fun rerunBacktestTask(request: BacktestRerunRequest): Result<BacktestTaskDto> {
|
||||
return try {
|
||||
val source = backtestTaskRepository.findById(request.id).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("回测任务不存在"))
|
||||
|
||||
if (source.status != "COMPLETED") {
|
||||
return Result.failure(IllegalStateException("仅支持对已完成的回测任务重新测试"))
|
||||
}
|
||||
|
||||
val newTaskName = request.taskName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: "${source.taskName} (副本)"
|
||||
|
||||
val newTask = BacktestTask(
|
||||
taskName = newTaskName,
|
||||
leaderId = source.leaderId,
|
||||
initialBalance = source.initialBalance,
|
||||
backtestDays = source.backtestDays,
|
||||
startTime = source.startTime,
|
||||
status = "PENDING",
|
||||
copyMode = source.copyMode,
|
||||
copyRatio = source.copyRatio,
|
||||
fixedAmount = source.fixedAmount,
|
||||
maxOrderSize = source.maxOrderSize,
|
||||
minOrderSize = source.minOrderSize,
|
||||
maxDailyLoss = source.maxDailyLoss,
|
||||
maxDailyOrders = source.maxDailyOrders,
|
||||
supportSell = source.supportSell,
|
||||
keywordFilterMode = source.keywordFilterMode,
|
||||
keywords = source.keywords,
|
||||
maxPositionValue = source.maxPositionValue,
|
||||
minPrice = source.minPrice,
|
||||
maxPrice = source.maxPrice
|
||||
)
|
||||
|
||||
backtestTaskRepository.save(newTask)
|
||||
val leader = leaderRepository.findById(newTask.leaderId).orElse(null)
|
||||
Result.success(newTask.toDto(leader))
|
||||
} catch (e: Exception) {
|
||||
logger.error("按配置重新测试失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展函数:BacktestTask 转 DTO
|
||||
*/
|
||||
private fun BacktestTask.toDto(leader: Leader?): BacktestTaskDto {
|
||||
return BacktestTaskDto(
|
||||
id = this.id!!,
|
||||
taskName = this.taskName,
|
||||
leaderId = this.leaderId,
|
||||
leaderName = leader?.leaderName,
|
||||
leaderAddress = leader?.leaderAddress,
|
||||
initialBalance = this.initialBalance.toPlainString(),
|
||||
finalBalance = this.finalBalance?.toPlainString(),
|
||||
profitAmount = this.profitAmount?.toPlainString(),
|
||||
profitRate = this.profitRate?.toPlainString(),
|
||||
backtestDays = this.backtestDays,
|
||||
startTime = this.startTime,
|
||||
endTime = this.endTime,
|
||||
status = this.status,
|
||||
progress = this.progress,
|
||||
totalTrades = this.totalTrades,
|
||||
createdAt = this.createdAt,
|
||||
executionStartedAt = this.executionStartedAt,
|
||||
executionFinishedAt = this.executionFinishedAt
|
||||
)
|
||||
}
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.wrbug.polymarketbot.service.binance
|
||||
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 自动最小价差:按周期计算。每个周期首次需要时,拉取该周期前的 20 根已收盘 K 线,按方向筛选、IQR 剔除后求平均,缓存 100% 基准值 (interval, period)。
|
||||
* 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。
|
||||
*/
|
||||
@Service
|
||||
class BinanceKlineAutoSpreadService(
|
||||
private val retrofitFactory: RetrofitFactory
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
|
||||
|
||||
private val symbol = "BTCUSDC"
|
||||
private val historyLimit = 20
|
||||
private val minSamplesAfterIqr = 3
|
||||
|
||||
/** (intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
||||
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||
|
||||
private fun cacheKey(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
|
||||
|
||||
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
|
||||
fun getAutoMinSpreadBase(intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
||||
val key = cacheKey(intervalSeconds, periodStartUnix)
|
||||
val (up, down) = cache[key] ?: run {
|
||||
computeAndCache(intervalSeconds, periodStartUnix) ?: return null
|
||||
}
|
||||
return if (outcomeIndex == 0) up else down
|
||||
}
|
||||
|
||||
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
|
||||
fun computeAndCache(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
|
||||
val endTimeMs = periodStartUnix * 1000L
|
||||
val klines = fetchKlines(intervalStr, historyLimit, endTime = endTimeMs) ?: return null
|
||||
val spreadsUp = mutableListOf<BigDecimal>()
|
||||
val spreadsDown = mutableListOf<BigDecimal>()
|
||||
for (k in klines) {
|
||||
if (k.size < 5) continue
|
||||
val openP = k.getOrNull(1)?.toString()?.toSafeBigDecimal() ?: continue
|
||||
val closeP = k.getOrNull(4)?.toString()?.toSafeBigDecimal() ?: continue
|
||||
if (closeP > openP) spreadsUp.add(closeP.subtract(openP))
|
||||
if (closeP < openP) spreadsDown.add(openP.subtract(closeP))
|
||||
}
|
||||
val baseUp = averageAfterIqr(spreadsUp).setScale(8, RoundingMode.HALF_UP)
|
||||
val baseDown = averageAfterIqr(spreadsDown).setScale(8, RoundingMode.HALF_UP)
|
||||
cache[cacheKey(intervalSeconds, periodStartUnix)] = baseUp to baseDown
|
||||
logger.info(
|
||||
"尾盘自动价差已计算并缓存(100%基准): interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
|
||||
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
|
||||
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
|
||||
)
|
||||
return baseUp to baseDown
|
||||
}
|
||||
|
||||
private fun fetchKlines(interval: String, limit: Int, endTime: Long? = null): List<List<Any>>? {
|
||||
return try {
|
||||
val api = retrofitFactory.createBinanceApi()
|
||||
val call = api.getKlines(symbol = symbol, interval = interval, limit = limit, endTime = endTime)
|
||||
val response = call.execute()
|
||||
if (response.isSuccessful && response.body() != null) response.body() else null
|
||||
} catch (e: Exception) {
|
||||
logger.warn("拉取币安 K 线失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IQR 剔除异常值后求平均;若剔除后样本数 < minSamplesAfterIqr 则不剔除,用全量求平均。
|
||||
*/
|
||||
private fun averageAfterIqr(list: List<BigDecimal>): BigDecimal {
|
||||
if (list.isEmpty()) return BigDecimal.ZERO
|
||||
val sorted = list.sorted()
|
||||
val n = sorted.size
|
||||
val q1Idx = (n * 0.25).toInt().coerceIn(0, n - 1)
|
||||
val q3Idx = (n * 0.75).toInt().coerceIn(0, n - 1)
|
||||
val q1 = sorted[q1Idx]
|
||||
val q3 = sorted[q3Idx]
|
||||
val iqr = q3.subtract(q1)
|
||||
val lower = q1.subtract(iqr.multiply(BigDecimal("1.5")))
|
||||
val upper = q3.add(iqr.multiply(BigDecimal("1.5")))
|
||||
val filtered = sorted.filter { it >= lower && it <= upper }
|
||||
val use = if (filtered.size < minSamplesAfterIqr) sorted else filtered
|
||||
return use.fold(BigDecimal.ZERO) { a, b -> a.add(b) }.divide(BigDecimal(use.size), 18, RoundingMode.HALF_UP)
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package com.wrbug.polymarketbot.service.binance
|
||||
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* 币安 K 线 WebSocket:订阅 BTCUSDC 5m/15m,维护当前周期 (open, close),供尾盘策略价差校验使用。
|
||||
*/
|
||||
@Service
|
||||
class BinanceKlineService {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(BinanceKlineService::class.java)
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val wsBase = "wss://stream.binance.com:9443"
|
||||
private val client = createClient().build()
|
||||
|
||||
/** (intervalSeconds, periodStartUnix) -> (open, close) */
|
||||
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||
private var ws5m: WebSocket? = null
|
||||
private var ws15m: WebSocket? = null
|
||||
private var reconnectJob: Job? = null
|
||||
private val connected5m = AtomicBoolean(false)
|
||||
private val connected15m = AtomicBoolean(false)
|
||||
|
||||
init {
|
||||
connectAll()
|
||||
}
|
||||
|
||||
private fun key(intervalSeconds: Int, periodStartUnix: Long): String = "$intervalSeconds-$periodStartUnix"
|
||||
|
||||
fun getCurrentOpenClose(intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||
return openCloseByPeriod[key(intervalSeconds, periodStartUnix)]
|
||||
}
|
||||
|
||||
/** 供 API 健康检查使用:5m / 15m 连接是否正常 */
|
||||
fun getConnectionStatuses(): Map<String, Boolean> = mapOf(
|
||||
"5m" to connected5m.get(),
|
||||
"15m" to connected15m.get()
|
||||
)
|
||||
|
||||
private fun connectAll() {
|
||||
if (ws5m != null && ws15m != null) return
|
||||
connectStream("btcusdc@kline_5m") { intervalSec, tMs, openP, closeP ->
|
||||
val periodSec = tMs / 1000
|
||||
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
|
||||
}.also { ws5m = it }
|
||||
connectStream("btcusdc@kline_15m") { intervalSec, tMs, openP, closeP ->
|
||||
val periodSec = tMs / 1000
|
||||
openCloseByPeriod[key(intervalSec, periodSec)] = openP to closeP
|
||||
}.also { ws15m = it }
|
||||
}
|
||||
|
||||
private fun connectStream(
|
||||
streamName: String,
|
||||
onKline: (intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
|
||||
): WebSocket {
|
||||
val url = "$wsBase/ws/$streamName"
|
||||
val intervalSeconds = when {
|
||||
streamName.contains("kline_5m") -> 300
|
||||
streamName.contains("kline_15m") -> 900
|
||||
else -> 300
|
||||
}
|
||||
val request = Request.Builder().url(url).build()
|
||||
val connectedFlag = when {
|
||||
streamName.contains("kline_5m") -> connected5m
|
||||
streamName.contains("kline_15m") -> connected15m
|
||||
else -> null
|
||||
}
|
||||
val ws = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
connectedFlag?.set(true)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
parseKlineMessage(text, intervalSeconds)?.let { (tMs, o, c) ->
|
||||
onKline(intervalSeconds, tMs, o, c)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
connectedFlag?.set(false)
|
||||
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
connectedFlag?.set(false)
|
||||
if (code != 1000) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
connectedFlag?.set(false)
|
||||
}
|
||||
})
|
||||
logger.info("币安 K 线 WS 已连接: $streamName")
|
||||
return ws
|
||||
}
|
||||
|
||||
private fun parseKlineMessage(text: String, intervalSeconds: Int): Triple<Long, BigDecimal, BigDecimal>? {
|
||||
return try {
|
||||
val json = com.google.gson.JsonParser.parseString(text).asJsonObject
|
||||
if (json.get("e")?.asString != "kline") return null
|
||||
val k = json.getAsJsonObject("k") ?: return null
|
||||
val tMs = k.get("t")?.asLong ?: return null
|
||||
val o = k.get("o")?.asString?.toSafeBigDecimal() ?: return null
|
||||
val c = k.get("c")?.asString?.toSafeBigDecimal() ?: return null
|
||||
Triple(tMs, o, c)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("解析币安 K 线消息失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
delay(3_000)
|
||||
reconnectJob = null
|
||||
ws5m?.close(1000, "reconnect")
|
||||
ws15m?.close(1000, "reconnect")
|
||||
ws5m = null
|
||||
ws15m = null
|
||||
connected5m.set(false)
|
||||
connected15m.set(false)
|
||||
logger.info("币安 K 线 WS 尝试重连")
|
||||
connectAll()
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
reconnectJob?.cancel()
|
||||
ws5m?.close(1000, "shutdown")
|
||||
ws15m?.close(1000, "shutdown")
|
||||
ws5m = null
|
||||
ws15m = null
|
||||
}
|
||||
}
|
||||
+14
-17
@@ -10,6 +10,7 @@ import com.wrbug.polymarketbot.api.ValueResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.dto.PositionDto
|
||||
import com.wrbug.polymarketbot.dto.WalletBalanceResponse
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -93,13 +94,13 @@ class BlockchainService(
|
||||
* 2. Safe Proxy(MetaMask 钱包用户)- 通过合约调用获取地址
|
||||
*
|
||||
* @param walletAddress 用户的钱包地址(EOA)
|
||||
* @param walletType 钱包类型:"magic"(默认)或 "safe"
|
||||
* @param walletType 钱包类型:MAGIC(默认)或 SAFE
|
||||
* @return 代理钱包地址
|
||||
*/
|
||||
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
|
||||
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
|
||||
return try {
|
||||
when (walletType.lowercase()) {
|
||||
"safe" -> {
|
||||
when (walletType) {
|
||||
WalletType.SAFE -> {
|
||||
// Safe Proxy(MetaMask 用户)
|
||||
val safeProxyResult = getSafeProxyAddress(walletAddress)
|
||||
if (safeProxyResult.isSuccess) {
|
||||
@@ -110,7 +111,7 @@ class BlockchainService(
|
||||
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
WalletType.MAGIC -> {
|
||||
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
|
||||
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
|
||||
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
|
||||
@@ -580,39 +581,35 @@ class BlockchainService(
|
||||
|
||||
/**
|
||||
* 赎回仓位
|
||||
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
|
||||
*
|
||||
* 使用 RelayClientService 实现,完全参考 TypeScript 项目的实现方式
|
||||
*
|
||||
* Safe 账户通过代理 execTransaction 调用,Magic 账户通过 Builder Relayer PROXY(Gasless)执行
|
||||
*
|
||||
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
|
||||
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
|
||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0,[2] 表示 outcome 1)
|
||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex)
|
||||
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun redeemPositions(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
conditionId: String,
|
||||
indexSets: List<BigInteger>
|
||||
indexSets: List<BigInteger>,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 验证参数
|
||||
if (indexSets.isEmpty()) {
|
||||
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
|
||||
}
|
||||
|
||||
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
|
||||
}
|
||||
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 使用 RelayClientService 创建赎回交易并执行
|
||||
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
|
||||
relayClientService.execute(privateKey, proxyAddress, redeemTx)
|
||||
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
|
||||
} catch (e: Exception) {
|
||||
logger.error("赎回仓位失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
|
||||
+61
-3
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
|
||||
/**
|
||||
* 市场价格服务
|
||||
@@ -27,6 +29,21 @@ class MarketPriceService(
|
||||
|
||||
private val logger = LoggerFactory.getLogger(MarketPriceService::class.java)
|
||||
|
||||
/**
|
||||
* 已结算市场的价格缓存
|
||||
* Key: "marketId:outcomeIndex"
|
||||
* Value: BigDecimal (1.0 或 0.0)
|
||||
*
|
||||
* 缓存策略:
|
||||
* - 最大缓存 10,000 个已结算市场
|
||||
* - 永不过期(已结算的市场状态永不改变)
|
||||
* - 内存占用约: 10,000 * ~100 bytes = ~1MB
|
||||
*/
|
||||
private val settledMarketCache: Cache<String, BigDecimal> = Caffeine.newBuilder()
|
||||
.maximumSize(10_000)
|
||||
.recordStats() // 启用统计信息
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 获取当前市场最新价
|
||||
* 优先级:
|
||||
@@ -83,9 +100,20 @@ class MarketPriceService(
|
||||
* - payout == 0(输了)→ 返回 0.0
|
||||
* 如果市场未结算或查询失败,返回 null
|
||||
*
|
||||
* 使用缓存优化:已结算的市场结果会被缓存,避免重复 RPC 调用
|
||||
*
|
||||
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted)
|
||||
*/
|
||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
|
||||
// 1. 先检查缓存
|
||||
val cacheKey = "$marketId:$outcomeIndex"
|
||||
val cachedPrice = settledMarketCache.getIfPresent(cacheKey)
|
||||
if (cachedPrice != null) {
|
||||
logger.debug("从缓存获取已结算市场价格: marketId=$marketId, outcomeIndex=$outcomeIndex, price=$cachedPrice")
|
||||
return Pair(cachedPrice, false)
|
||||
}
|
||||
|
||||
// 2. 缓存未命中,发起 RPC 查询
|
||||
return try {
|
||||
val chainResult = blockchainService.getCondition(marketId)
|
||||
chainResult.fold(
|
||||
@@ -96,11 +124,17 @@ class MarketPriceService(
|
||||
when {
|
||||
payout > BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return Pair(BigDecimal.ONE, false)
|
||||
val price = BigDecimal.ONE
|
||||
// 缓存已结算的结果
|
||||
settledMarketCache.put(cacheKey, price)
|
||||
return Pair(price, false)
|
||||
}
|
||||
payout == BigInteger.ZERO -> {
|
||||
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
return Pair(BigDecimal.ZERO, false)
|
||||
val price = BigDecimal.ZERO
|
||||
// 缓存已结算的结果
|
||||
settledMarketCache.put(cacheKey, price)
|
||||
return Pair(price, false)
|
||||
}
|
||||
else -> {
|
||||
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||
@@ -109,7 +143,7 @@ class MarketPriceService(
|
||||
}
|
||||
} else {
|
||||
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||
Pair(null, false)
|
||||
Pair(null, false) // 未结算的市场不缓存
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
@@ -290,5 +324,29 @@ class MarketPriceService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
* 用于监控缓存命中率和性能
|
||||
*/
|
||||
fun getCacheStats(): String {
|
||||
val stats = settledMarketCache.stats()
|
||||
return """
|
||||
已结算市场缓存统计:
|
||||
- 缓存条目数: ${settledMarketCache.estimatedSize()}
|
||||
- 命中次数: ${stats.hitCount()}
|
||||
- 未命中次数: ${stats.missCount()}
|
||||
- 命中率: ${"%.2f".format(stats.hitRate() * 100)}%
|
||||
- 总请求次数: ${stats.requestCount()}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓存(测试或管理用)
|
||||
*/
|
||||
fun clearSettledMarketCache() {
|
||||
settledMarketCache.invalidateAll()
|
||||
logger.info("已清空已结算市场缓存")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.wrbug.polymarketbot.entity.Market
|
||||
import com.wrbug.polymarketbot.repository.MarketRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.getEventSlug
|
||||
import com.wrbug.polymarketbot.util.parseStringArray
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -207,6 +208,36 @@ class MarketService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 tokenId 从 Gamma 解析市场信息(conditionId、outcomeIndex)
|
||||
* 用于链上解析时 Gamma 失败、仅带 tokenId 的交易在 processBuyTrade 中补查市场
|
||||
*/
|
||||
suspend fun getMarketInfoByTokenId(tokenId: String): MarketInfoByTokenId? {
|
||||
if (tokenId.isBlank()) return null
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.listMarkets(
|
||||
conditionIds = null,
|
||||
clobTokenIds = listOf(tokenId),
|
||||
includeTag = null
|
||||
)
|
||||
if (!response.isSuccessful || response.body().isNullOrEmpty()) return null
|
||||
val market = response.body()!!.first()
|
||||
val conditionId = market.conditionId ?: return null
|
||||
val clobTokenIdsRaw = market.clobTokenIds ?: market.clob_token_ids
|
||||
val clobTokenIds = (clobTokenIdsRaw ?: "").parseStringArray()
|
||||
val outcomeIndex = clobTokenIds.indexOfFirst { it.equals(tokenId, ignoreCase = true) }.takeIf { it >= 0 }
|
||||
?: return null
|
||||
val outcomes = market.outcomes.parseStringArray()
|
||||
val outcome = if (outcomeIndex < outcomes.size) outcomes[outcomeIndex] else null
|
||||
saveMarketFromResponse(conditionId, market)
|
||||
MarketInfoByTokenId(conditionId = conditionId, outcomeIndex = outcomeIndex, outcome = outcome)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("按 tokenId 查询市场失败: tokenId=$tokenId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存(用于测试或手动刷新)
|
||||
*/
|
||||
@@ -230,5 +261,33 @@ class MarketService(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 conditionId 查询该市场是否为 Neg Risk(需使用 Neg Risk Exchange 签约)
|
||||
* 用于跟单下单时选择正确的 exchange 合约,避免 invalid signature
|
||||
*/
|
||||
suspend fun getNegRiskByConditionId(conditionId: String): Boolean? {
|
||||
if (conditionId.isBlank()) return null
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.listMarkets(conditionIds = listOf(conditionId))
|
||||
if (!response.isSuccessful || response.body().isNullOrEmpty()) return null
|
||||
val marketResponse = response.body()!!.first()
|
||||
val fromEvent = marketResponse.events?.firstOrNull()?.negRisk
|
||||
val fromMarket = marketResponse.negRisk ?: marketResponse.negRiskOther
|
||||
fromEvent ?: fromMarket
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询市场 negRisk 失败: conditionId=$conditionId, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 tokenId 查询 Gamma 得到的市场信息(用于补全 trade.market / outcomeIndex)
|
||||
*/
|
||||
data class MarketInfoByTokenId(
|
||||
val conditionId: String,
|
||||
val outcomeIndex: Int,
|
||||
val outcome: String? = null
|
||||
)
|
||||
|
||||
+20
-28
@@ -98,6 +98,24 @@ class PolymarketApiKeyService(
|
||||
creds.passphrase.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 CLOB /time 获取服务器时间戳,失败时返回 null(调用方使用本地时间)
|
||||
*/
|
||||
private suspend fun fetchServerTimeOrNull(): Long? {
|
||||
return try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful) {
|
||||
timeResponse.body()?.string()?.trim()?.toLongOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 API Key
|
||||
*/
|
||||
@@ -107,20 +125,7 @@ class PolymarketApiKeyService(
|
||||
chainId: Long
|
||||
): Result<ApiKeyCreds> {
|
||||
return try {
|
||||
// 获取服务器时间(可选,用于更准确的时间戳)
|
||||
val serverTime = try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful && timeResponse.body() != null) {
|
||||
timeResponse.body()!!.timestamp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
|
||||
val serverTime = fetchServerTimeOrNull()
|
||||
// 创建带 L1 认证的 API 客户端
|
||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||
|
||||
@@ -158,20 +163,7 @@ class PolymarketApiKeyService(
|
||||
chainId: Long
|
||||
): Result<ApiKeyCreds> {
|
||||
return try {
|
||||
// 获取服务器时间(可选)
|
||||
val serverTime = try {
|
||||
val timeApi = createUnauthenticatedApi()
|
||||
val timeResponse = timeApi.getServerTime()
|
||||
if (timeResponse.isSuccessful && timeResponse.body() != null) {
|
||||
timeResponse.body()!!.timestamp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取服务器时间失败,使用本地时间", e)
|
||||
null
|
||||
}
|
||||
|
||||
val serverTime = fetchServerTimeOrNull()
|
||||
// 创建带 L1 认证的 API 客户端
|
||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||
|
||||
|
||||
+9
-4
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.service.copytrading.leaders
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
@@ -20,9 +21,10 @@ class LeaderService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
|
||||
|
||||
/**
|
||||
@@ -157,7 +159,8 @@ class LeaderService(
|
||||
|
||||
val leaderDtos = leaders.map { leader ->
|
||||
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
|
||||
toDto(leader, copyTradingCount)
|
||||
val backtestCount = backtestTaskRepository.findByLeaderId(leader.id).size.toLong()
|
||||
toDto(leader, copyTradingCount, backtestCount)
|
||||
}
|
||||
|
||||
Result.success(
|
||||
@@ -181,7 +184,8 @@ class LeaderService(
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
|
||||
Result.success(toDto(leader, copyTradingCount))
|
||||
val backtestCount = backtestTaskRepository.findByLeaderId(leaderId).size.toLong()
|
||||
Result.success(toDto(leader, copyTradingCount, backtestCount))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 详情失败", e)
|
||||
Result.failure(e)
|
||||
@@ -225,7 +229,7 @@ class LeaderService(
|
||||
/**
|
||||
* 转换为 DTO
|
||||
*/
|
||||
private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto {
|
||||
private fun toDto(leader: Leader, copyTradingCount: Long = 0, backtestCount: Long = 0): LeaderDto {
|
||||
return LeaderDto(
|
||||
id = leader.id!!,
|
||||
leaderAddress = leader.leaderAddress,
|
||||
@@ -234,6 +238,7 @@ class LeaderService(
|
||||
remark = leader.remark,
|
||||
website = leader.website,
|
||||
copyTradingCount = copyTradingCount,
|
||||
backtestCount = backtestCount,
|
||||
createdAt = leader.createdAt,
|
||||
updatedAt = leader.updatedAt
|
||||
)
|
||||
|
||||
+4
-3
@@ -220,10 +220,10 @@ object OnChainWsUtils {
|
||||
return null
|
||||
}
|
||||
|
||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
|
||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId);失败时仍保留链上 tokenId 供后续按 tokenId 补查市场
|
||||
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
|
||||
|
||||
// 创建 TradeResponse
|
||||
// 创建 TradeResponse:tokenId 始终写入链上解析得到的 asset(与 CLOB 一致),便于 Gamma 失败时在 processBuyTrade 中按 tokenId 再查
|
||||
return TradeResponse(
|
||||
id = txHash,
|
||||
market = marketInfo?.conditionId ?: "",
|
||||
@@ -233,7 +233,8 @@ object OnChainWsUtils {
|
||||
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
||||
user = walletAddress,
|
||||
outcomeIndex = marketInfo?.outcomeIndex,
|
||||
outcome = marketInfo?.outcome
|
||||
outcome = marketInfo?.outcome,
|
||||
tokenId = asset.toString()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -463,6 +463,7 @@ class PolymarketActivityWsService(
|
||||
// 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID
|
||||
val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}"
|
||||
|
||||
// asset 即 CLOB 的 tokenId,必须写入 TradeResponse,跟单下单时用此 tokenId 请求订单簿/下单,否则会用 conditionId+outcomeIndex 链上重算,可能得到与 CLOB 不一致的 tokenId
|
||||
TradeResponse(
|
||||
id = tradeId,
|
||||
market = conditionId,
|
||||
@@ -472,7 +473,8 @@ class PolymarketActivityWsService(
|
||||
timestamp = timestamp,
|
||||
user = null, // Activity WS 中不需要
|
||||
outcomeIndex = outcomeIndex,
|
||||
outcome = outcome
|
||||
outcome = outcome,
|
||||
tokenId = asset
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("解析 Activity Trade 失败: ${e.message}", e)
|
||||
|
||||
+57
-32
@@ -8,6 +8,7 @@ import org.web3j.crypto.Credentials
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* 订单签名服务
|
||||
@@ -19,11 +20,31 @@ import java.math.RoundingMode
|
||||
*/
|
||||
@Service
|
||||
class OrderSigningService {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
|
||||
|
||||
// Polygon 主网合约地址
|
||||
|
||||
/**
|
||||
* 根据是否为 Neg Risk 市场返回签约用 exchange 合约地址
|
||||
* @param negRisk true 时使用 Neg Risk CTF Exchange,否则使用标准 CTF Exchange
|
||||
*/
|
||||
fun getExchangeContract(negRisk: Boolean): String {
|
||||
return if (negRisk) NEG_RISK_EXCHANGE_CONTRACT else EXCHANGE_CONTRACT
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据钱包类型返回 CLOB 订单签名类型
|
||||
* @param walletType Magic=邮箱/社交登录, Safe=Web3 钱包
|
||||
* @return 1=POLY_PROXY(Magic), 2=POLY_GNOSIS_SAFE(Safe), 默认 2
|
||||
*/
|
||||
fun getSignatureTypeForWalletType(walletType: String?): Int {
|
||||
val walletTypeEnum = com.wrbug.polymarketbot.enums.WalletType.fromStringOrDefault(walletType, com.wrbug.polymarketbot.enums.WalletType.SAFE)
|
||||
return if (walletTypeEnum == com.wrbug.polymarketbot.enums.WalletType.MAGIC) 1 else 2
|
||||
}
|
||||
|
||||
// Polygon 主网合约地址(标准 CTF Exchange)
|
||||
private val EXCHANGE_CONTRACT = "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
|
||||
// Neg Risk CTF Exchange(neg risk 市场需用此合约签约,否则服务端返回 invalid signature)
|
||||
private val NEG_RISK_EXCHANGE_CONTRACT = "0xC5d563A36AE78145C45a50134d48A1215220f80a"
|
||||
private val CHAIN_ID = 137L
|
||||
|
||||
// USDC 有 6 位小数
|
||||
@@ -147,6 +168,7 @@ class OrderSigningService {
|
||||
* @param nonce nonce(默认 "0")
|
||||
* @param feeRateBps 费率基点(默认 "0")
|
||||
* @param expiration 过期时间戳(秒,0 表示永不过期)
|
||||
* @param exchangeContract 签约用 exchange 合约地址;null 时用标准 CTF Exchange,neg risk 市场需传 Neg Risk Exchange
|
||||
* @return 签名的订单对象
|
||||
*/
|
||||
fun createAndSignOrder(
|
||||
@@ -159,7 +181,8 @@ class OrderSigningService {
|
||||
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
|
||||
nonce: String = "0",
|
||||
feeRateBps: String = "0",
|
||||
expiration: String = "0"
|
||||
expiration: String = "0",
|
||||
exchangeContract: String? = null
|
||||
): SignedOrderObject {
|
||||
try {
|
||||
// 1. 从私钥获取签名地址
|
||||
@@ -192,10 +215,11 @@ class OrderSigningService {
|
||||
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
|
||||
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
|
||||
|
||||
// 6. 构建订单数据并签名
|
||||
// 6. 构建订单数据并签名(neg risk 市场需用 NEG_RISK_EXCHANGE_CONTRACT)
|
||||
val contract = exchangeContract?.takeIf { it.isNotBlank() } ?: EXCHANGE_CONTRACT
|
||||
val signature = signOrder(
|
||||
privateKey = privateKey,
|
||||
exchangeContract = EXCHANGE_CONTRACT,
|
||||
exchangeContract = contract,
|
||||
chainId = CHAIN_ID,
|
||||
salt = salt,
|
||||
maker = makerAddressLower,
|
||||
@@ -257,20 +281,20 @@ class OrderSigningService {
|
||||
signatureType: Int
|
||||
): String {
|
||||
try {
|
||||
// 1. 从私钥创建 BigInteger
|
||||
// 1. 私钥与密钥对
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
|
||||
// 2. 编码域分隔符
|
||||
val credentials = Credentials.create(privateKeyBigInt.toString(16))
|
||||
val ecKeyPair = credentials.ecKeyPair
|
||||
|
||||
// 2. 编码域分隔符(verifyingContract 显式小写,与 EIP-712 约定一致)
|
||||
val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain(
|
||||
chainId = chainId,
|
||||
verifyingContract = exchangeContract
|
||||
verifyingContract = exchangeContract.lowercase()
|
||||
)
|
||||
|
||||
|
||||
// 3. 编码订单消息哈希
|
||||
// signatureType 参数:1 = POLY_PROXY (代理钱包), 2 = POLY_GNOSIS_SAFE, 0 = EOA
|
||||
// 使用传入的 signatureType 参数,而不是硬编码
|
||||
// signatureType:1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA
|
||||
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
|
||||
salt = salt,
|
||||
maker = maker,
|
||||
@@ -283,29 +307,25 @@ class OrderSigningService {
|
||||
nonce = nonce,
|
||||
feeRateBps = feeRateBps,
|
||||
side = side,
|
||||
signatureType = signatureType // 使用传入的参数
|
||||
signatureType = signatureType
|
||||
)
|
||||
|
||||
// 4. 计算完整的结构化数据哈希
|
||||
|
||||
// 4. 计算完整 EIP-712 结构化数据哈希
|
||||
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
|
||||
domainSeparator = domainSeparator,
|
||||
messageHash = orderHash
|
||||
)
|
||||
|
||||
// 5. 使用私钥签名
|
||||
|
||||
// 5. 使用私钥签名(needToHash=false,对 32 字节 hash 直接签名)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
|
||||
|
||||
// 6. 组合签名(r + s + v)
|
||||
|
||||
// 6. 组合 r + s + v
|
||||
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
|
||||
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
|
||||
val vBytes = signature.v as ByteArray
|
||||
val vInt = if (vBytes.isNotEmpty()) {
|
||||
vBytes[0].toInt() and 0xff
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val vHex = String.format("%02x", vInt)
|
||||
|
||||
val vBytes = signature.v
|
||||
val vInt = if (vBytes.isNotEmpty()) vBytes[0].toInt() and 0xff else 0
|
||||
val vHex = "%02x".format(vInt)
|
||||
|
||||
return "0x$rHex$sHex$vHex"
|
||||
} catch (e: Exception) {
|
||||
logger.error("订单签名失败", e)
|
||||
@@ -313,12 +333,17 @@ class OrderSigningService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 并发安全:确保同一毫秒内多次调用生成唯一 salt,避免 FIXED 模式预签双单等场景的 salt 碰撞 */
|
||||
private val saltSequence = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 生成 salt(使用时间戳,毫秒)
|
||||
* 与 TypeScript SDK 保持一致,使用时间戳作为 salt
|
||||
* 生成 salt(时间戳 + 自增序列,保证并发下唯一)
|
||||
* 兼容 Polymarket:salt 为 Long,时间戳主位 + 序列次位,与 TypeScript SDK 语义兼容
|
||||
*/
|
||||
private fun generateSalt(): Long {
|
||||
return System.currentTimeMillis()
|
||||
val now = System.currentTimeMillis()
|
||||
val seq = saltSequence.incrementAndGet() and 0x3FF
|
||||
return now * 1000 + seq
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+98
-42
@@ -185,6 +185,7 @@ open class CopyOrderTrackingService(
|
||||
processedAt = System.currentTimeMillis()
|
||||
)
|
||||
processedTradeRepository.save(processed)
|
||||
|
||||
} catch (e: Exception) {
|
||||
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||
if (isUniqueConstraintViolation(e)) {
|
||||
@@ -257,19 +258,36 @@ open class CopyOrderTrackingService(
|
||||
continue
|
||||
}
|
||||
|
||||
// 直接使用outcomeIndex获取tokenId(支持多元市场)
|
||||
if (trade.outcomeIndex == null) {
|
||||
logger.warn("交易缺少outcomeIndex,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}")
|
||||
continue
|
||||
// 获取 tokenId:优先使用链上解析得到的 tokenId(与 Gamma clobTokenIds 一致),否则用 conditionId+outcomeIndex 链上重算
|
||||
val tokenId = if (!trade.tokenId.isNullOrBlank()) {
|
||||
trade.tokenId
|
||||
} else {
|
||||
if (trade.outcomeIndex == null) {
|
||||
logger.warn("交易缺少outcomeIndex且无tokenId,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}")
|
||||
continue
|
||||
}
|
||||
val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
continue
|
||||
}
|
||||
tokenIdResult.getOrNull() ?: continue
|
||||
}
|
||||
|
||||
// 获取tokenId(直接使用outcomeIndex,不转换为YES/NO)
|
||||
val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
// 当链上解析时 Gamma 失败导致 market/outcomeIndex 为空时,按 tokenId 补查市场信息
|
||||
var effectiveMarketId = trade.market
|
||||
var effectiveOutcomeIndex = trade.outcomeIndex
|
||||
if (effectiveMarketId.isBlank() && !trade.tokenId.isNullOrBlank()) {
|
||||
val infoByToken = marketService.getMarketInfoByTokenId(trade.tokenId)
|
||||
if (infoByToken != null) {
|
||||
effectiveMarketId = infoByToken.conditionId
|
||||
effectiveOutcomeIndex = infoByToken.outcomeIndex
|
||||
}
|
||||
}
|
||||
if (effectiveMarketId.isBlank()) {
|
||||
logger.warn("无法确定市场(conditionId),跳过: tradeId=${trade.id}, tokenId=${trade.tokenId}")
|
||||
continue
|
||||
}
|
||||
val tokenId = tokenIdResult.getOrNull() ?: continue
|
||||
|
||||
// 先计算跟单金额(用于仓位检查)
|
||||
// 注意:这里先计算金额,即使后续被过滤也会记录
|
||||
@@ -292,7 +310,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
if (needMarketInfo) {
|
||||
try {
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val market = marketService.getMarket(effectiveMarketId)
|
||||
marketTitle = market?.title
|
||||
marketEndDate = market?.endDate
|
||||
} catch (e: Exception) {
|
||||
@@ -311,10 +329,10 @@ open class CopyOrderTrackingService(
|
||||
tokenId,
|
||||
tradePrice = tradePrice,
|
||||
copyOrderAmount = copyOrderAmount,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketTitle = marketTitle,
|
||||
marketEndDate = marketEndDate,
|
||||
outcomeIndex = trade.outcomeIndex
|
||||
outcomeIndex = effectiveOutcomeIndex
|
||||
)
|
||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||
if (!filterResult.isPassed) {
|
||||
@@ -324,8 +342,8 @@ open class CopyOrderTrackingService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val marketTitle = market?.title ?: trade.market
|
||||
val market = marketService.getMarket(effectiveMarketId)
|
||||
val marketTitle = market?.title ?: effectiveMarketId
|
||||
val marketSlug = market?.slug // 显示用的 slug
|
||||
|
||||
// 从过滤结果中提取 filterType
|
||||
@@ -345,11 +363,11 @@ open class CopyOrderTrackingService(
|
||||
accountId = copyTrading.accountId,
|
||||
leaderId = copyTrading.leaderId,
|
||||
leaderTradeId = trade.id,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketTitle = marketTitle,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcomeIndex = trade.outcomeIndex,
|
||||
outcomeIndex = effectiveOutcomeIndex,
|
||||
outcome = trade.outcome,
|
||||
price = trade.price.toSafeBigDecimal(),
|
||||
size = trade.size.toSafeBigDecimal(),
|
||||
@@ -375,7 +393,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
telegramNotificationService?.sendOrderFilteredNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = trade.outcome,
|
||||
@@ -555,6 +573,11 @@ open class CopyOrderTrackingService(
|
||||
|
||||
logger.info("准备创建买入订单: copyTradingId=${copyTrading.id}, tradeId=${trade.id}, leaderPrice=${trade.price}, tolerance=${copyTrading.priceTolerance}, calculatedPrice=$buyPrice, quantity=$finalBuyQuantity, baseFee=$feeRateBps")
|
||||
|
||||
// Neg Risk 市场需用 Neg Risk Exchange 签约,否则服务端返回 invalid signature
|
||||
val negRisk = marketService.getNegRiskByConditionId(effectiveMarketId) == true
|
||||
val exchangeContract = orderSigningService.getExchangeContract(negRisk)
|
||||
if (negRisk) logger.debug("市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=$effectiveMarketId")
|
||||
|
||||
// 调用API创建订单(带重试机制)
|
||||
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
|
||||
@@ -562,6 +585,8 @@ open class CopyOrderTrackingService(
|
||||
clobApi = clobApi,
|
||||
privateKey = decryptedPrivateKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
walletAddress = account.walletAddress,
|
||||
exchangeContract = exchangeContract,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = buyPrice.toString(),
|
||||
@@ -569,7 +594,8 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id!!,
|
||||
tradeId = trade.id,
|
||||
feeRateBps = feeRateBps
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
// 处理订单创建失败
|
||||
@@ -583,8 +609,8 @@ open class CopyOrderTrackingService(
|
||||
notificationScope.launch {
|
||||
try {
|
||||
// 获取市场信息(标题和slug)
|
||||
val market = marketService.getMarket(trade.market)
|
||||
val marketTitle = market?.title ?: trade.market
|
||||
val market = marketService.getMarket(effectiveMarketId)
|
||||
val marketTitle = market?.title ?: effectiveMarketId
|
||||
val marketSlug = market?.eventSlug // 跳转用的 slug
|
||||
|
||||
// 获取当前语言设置(从 LocaleContextHolder)
|
||||
@@ -596,7 +622,7 @@ open class CopyOrderTrackingService(
|
||||
|
||||
telegramNotificationService?.sendOrderFailureNotification(
|
||||
marketTitle = marketTitle,
|
||||
marketId = trade.market,
|
||||
marketId = effectiveMarketId,
|
||||
marketSlug = marketSlug,
|
||||
side = "BUY",
|
||||
outcome = null, // 失败时可能没有 outcome
|
||||
@@ -630,9 +656,9 @@ open class CopyOrderTrackingService(
|
||||
copyTradingId = copyTrading.id,
|
||||
accountId = copyTrading.accountId,
|
||||
leaderId = copyTrading.leaderId,
|
||||
marketId = trade.market,
|
||||
side = trade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据)
|
||||
outcomeIndex = trade.outcomeIndex, // 新增字段
|
||||
marketId = effectiveMarketId,
|
||||
side = effectiveOutcomeIndex?.toString() ?: "", // 使用outcomeIndex作为side(兼容旧数据)
|
||||
outcomeIndex = effectiveOutcomeIndex, // 新增字段
|
||||
buyOrderId = realOrderId, // 使用真实订单ID
|
||||
leaderBuyTradeId = trade.id,
|
||||
leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例)
|
||||
@@ -902,13 +928,21 @@ open class CopyOrderTrackingService(
|
||||
finalNeedMatch = BigDecimal.ONE
|
||||
}
|
||||
|
||||
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
return
|
||||
// 4. 获取 tokenId:优先使用链上解析得到的 tokenId,否则用 conditionId+outcomeIndex 链上重算
|
||||
val tokenId = if (!leaderSellTrade.tokenId.isNullOrBlank()) {
|
||||
leaderSellTrade.tokenId
|
||||
} else {
|
||||
if (leaderSellTrade.outcomeIndex == null) {
|
||||
logger.error("卖出交易缺少outcomeIndex且无tokenId: market=${leaderSellTrade.market}")
|
||||
return
|
||||
}
|
||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
||||
if (tokenIdResult.isFailure) {
|
||||
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
||||
return
|
||||
}
|
||||
tokenIdResult.getOrNull() ?: return
|
||||
}
|
||||
val tokenId = tokenIdResult.getOrNull() ?: return
|
||||
|
||||
// 5. 计算卖出价格(优先使用订单簿 bestBid,失败则使用 Leader 价格,固定按90%计算)
|
||||
// 注意:需要先计算卖出价格,因为后续创建 matchDetails 需要使用实际卖出价格
|
||||
@@ -993,7 +1027,12 @@ open class CopyOrderTrackingService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 9. 创建并签名卖出订单
|
||||
// 9. Neg Risk 市场需用 Neg Risk Exchange 签约
|
||||
val negRiskSell = marketService.getNegRiskByConditionId(leaderSellTrade.market) == true
|
||||
val exchangeContractSell = orderSigningService.getExchangeContract(negRiskSell)
|
||||
if (negRiskSell) logger.debug("卖出市场为 Neg Risk,使用 Neg Risk Exchange 签约: conditionId=${leaderSellTrade.market}")
|
||||
|
||||
// 10. 创建并签名卖出订单(按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -1002,17 +1041,18 @@ open class CopyOrderTrackingService(
|
||||
side = "SELL",
|
||||
price = sellPrice.toString(),
|
||||
size = totalMatched.toString(),
|
||||
signatureType = 2, // Browser Wallet
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
expiration = "0",
|
||||
exchangeContract = exchangeContractSell
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建并签名卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}", e)
|
||||
return
|
||||
}
|
||||
|
||||
// 10. 构建订单请求
|
||||
// 11. 构建订单请求
|
||||
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||
val orderRequest = NewOrderRequest(
|
||||
@@ -1022,7 +1062,7 @@ open class CopyOrderTrackingService(
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
// 11. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey,
|
||||
apiSecret,
|
||||
@@ -1030,12 +1070,13 @@ open class CopyOrderTrackingService(
|
||||
account.walletAddress
|
||||
)
|
||||
|
||||
// 12. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||
|
||||
// 13. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||
val createOrderResult = createOrderWithRetry(
|
||||
clobApi = clobApi,
|
||||
privateKey = decryptedPrivateKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
walletAddress = account.walletAddress,
|
||||
exchangeContract = exchangeContractSell,
|
||||
tokenId = tokenId,
|
||||
side = "SELL",
|
||||
price = sellPrice.toString(),
|
||||
@@ -1043,7 +1084,8 @@ open class CopyOrderTrackingService(
|
||||
owner = account.apiKey,
|
||||
copyTradingId = copyTrading.id,
|
||||
tradeId = leaderSellTrade.id,
|
||||
feeRateBps = feeRateBps
|
||||
feeRateBps = feeRateBps,
|
||||
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
)
|
||||
|
||||
if (createOrderResult.isFailure) {
|
||||
@@ -1127,7 +1169,9 @@ open class CopyOrderTrackingService(
|
||||
*
|
||||
* @param clobApi CLOB API 客户端
|
||||
* @param privateKey 私钥(用于签名)
|
||||
* @param makerAddress 代理钱包地址
|
||||
* @param makerAddress 代理钱包地址(funder)
|
||||
* @param walletAddress 账户 EOA 地址(须与私钥推导的 signer 一致,用于校验及 POLY_ADDRESS)
|
||||
* @param exchangeContract 签约用 exchange 合约(Neg Risk 市场需用 Neg Risk Exchange)
|
||||
* @param tokenId Token ID
|
||||
* @param side 订单方向(BUY/SELL)
|
||||
* @param price 价格
|
||||
@@ -1136,12 +1180,15 @@ open class CopyOrderTrackingService(
|
||||
* @param copyTradingId 跟单配置ID(用于日志)
|
||||
* @param tradeId Leader 交易ID(用于日志)
|
||||
* @param feeRateBps 费率基点(从API动态获取)
|
||||
* @param signatureType 签名类型(1=Magic, 2=Safe)
|
||||
* @return 成功返回订单ID,失败返回异常
|
||||
*/
|
||||
private suspend fun createOrderWithRetry(
|
||||
clobApi: PolymarketClobApi,
|
||||
privateKey: String,
|
||||
makerAddress: String,
|
||||
walletAddress: String,
|
||||
exchangeContract: String,
|
||||
tokenId: String,
|
||||
side: String,
|
||||
price: String,
|
||||
@@ -1149,7 +1196,8 @@ open class CopyOrderTrackingService(
|
||||
owner: String,
|
||||
copyTradingId: Long,
|
||||
tradeId: String,
|
||||
feeRateBps: String
|
||||
feeRateBps: String,
|
||||
signatureType: Int
|
||||
): Result<String> {
|
||||
var lastError: Exception? = null
|
||||
|
||||
@@ -1164,12 +1212,20 @@ open class CopyOrderTrackingService(
|
||||
side = side,
|
||||
price = price,
|
||||
size = size,
|
||||
signatureType = 2, // Browser Wallet
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||
expiration = "0"
|
||||
expiration = "0",
|
||||
exchangeContract = exchangeContract
|
||||
)
|
||||
|
||||
// 校验 signer 与账户 walletAddress 一致,否则服务端会返回 invalid signature(POLY_ADDRESS 与 order.signer 需一致)
|
||||
if (signedOrder.signer.lowercase() != walletAddress.lowercase()) {
|
||||
val msg = "订单 signer 与账户 walletAddress 不一致,会导致 invalid signature。请确认该账户的私钥与 walletAddress 对应同一 EOA,且 API 密钥由该 EOA 创建。signer=${signedOrder.signer.take(10)}..., walletAddress=${walletAddress.take(10)}..."
|
||||
logger.error(msg)
|
||||
return Result.failure(IllegalStateException(msg))
|
||||
}
|
||||
|
||||
// 构建订单请求
|
||||
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||
|
||||
-2
@@ -198,8 +198,6 @@ class OrderStatusUpdateService(
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("检查 ${ordersToCheck.size} 个30秒前创建的订单是否成交")
|
||||
|
||||
// 按账户分组,避免重复创建 API 客户端
|
||||
val ordersByAccount = ordersToCheck.groupBy { it.accountId }
|
||||
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.ApplicationContextAware
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 尾盘策略订单 TG 通知轮询服务(与跟单一致)
|
||||
* 定时查询「下单成功且未发 TG」的触发记录,通过 CLOB getOrder 获取订单详情后发送 TG 并标记已发。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailOrderNotificationPollingService(
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val marketService: MarketService,
|
||||
private val telegramNotificationService: TelegramNotificationService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderNotificationPollingService::class.java)
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
|
||||
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||
this.applicationContext = applicationContext
|
||||
}
|
||||
|
||||
private fun getSelf(): CryptoTailOrderNotificationPollingService {
|
||||
return applicationContext?.getBean(CryptoTailOrderNotificationPollingService::class.java)
|
||||
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var notificationJob: Job? = null
|
||||
|
||||
@Scheduled(fixedDelay = 5000)
|
||||
fun scheduledSendPendingNotifications() {
|
||||
if (notificationJob != null && notificationJob!!.isActive) {
|
||||
logger.debug("上一轮尾盘 TG 通知任务仍在执行,跳过本次")
|
||||
return
|
||||
}
|
||||
notificationJob = scope.launch {
|
||||
try {
|
||||
getSelf().sendPendingNotifications()
|
||||
} catch (e: Exception) {
|
||||
logger.error("尾盘 TG 通知轮询异常: ${e.message}", e)
|
||||
} finally {
|
||||
notificationJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
suspend fun sendPendingNotifications() {
|
||||
val pending = triggerRepository.findByStatusAndOrderIdIsNotNullAndNotificationSentFalseOrderByCreatedAtAsc("success")
|
||||
if (pending.isEmpty()) return
|
||||
for (trigger in pending) {
|
||||
try {
|
||||
if (trigger.resolved) {
|
||||
trigger.notificationSent = true
|
||||
triggerRepository.save(trigger)
|
||||
logger.debug("触发已结算,跳过请求并标记已通知: triggerId=${trigger.id}, orderId=${trigger.orderId}")
|
||||
continue
|
||||
}
|
||||
if (sendNotificationForTrigger(trigger)) {
|
||||
trigger.notificationSent = true
|
||||
triggerRepository.save(trigger)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘 TG 通知单条失败: triggerId=${trigger.id}, orderId=${trigger.orderId}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendNotificationForTrigger(trigger: CryptoTailStrategyTrigger): Boolean {
|
||||
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return false
|
||||
val orderId = trigger.orderId ?: return false
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.debug("账户未配置 API 凭证,跳过 TG: accountId=${account.id}")
|
||||
return false
|
||||
}
|
||||
val apiSecret = try {
|
||||
cryptoUtils.decrypt(account.apiSecret) ?: return false
|
||||
} catch (e: Exception) {
|
||||
logger.warn("解密 API Secret 失败: accountId=${account.id}", e)
|
||||
return false
|
||||
}
|
||||
val apiPassphrase = try {
|
||||
cryptoUtils.decrypt(account.apiPassphrase) ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
val clobApi = retrofitFactory.createClobApi(
|
||||
account.apiKey!!,
|
||||
apiSecret,
|
||||
apiPassphrase,
|
||||
account.walletAddress
|
||||
)
|
||||
val orderResponse = clobApi.getOrder(orderId)
|
||||
if (!orderResponse.isSuccessful) {
|
||||
logger.debug("查询订单详情失败,等待下次轮询: orderId=$orderId, code=${orderResponse.code()}")
|
||||
return false
|
||||
}
|
||||
val order = orderResponse.body() ?: run {
|
||||
logger.debug("订单详情为空,等待下次轮询: orderId=$orderId")
|
||||
return false
|
||||
}
|
||||
val market = marketService.getMarket(order.market)
|
||||
val marketTitle = trigger.marketTitle?.takeIf { it.isNotBlank() } ?: market?.title ?: order.market
|
||||
val orderTimeMs = if (order.createdAt < 1_000_000_000_000L) order.createdAt * 1000 else order.createdAt
|
||||
telegramNotificationService.sendCryptoTailOrderSuccessNotification(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = order.market,
|
||||
marketSlug = market?.eventSlug ?: market?.slug,
|
||||
side = order.side,
|
||||
outcome = order.outcome,
|
||||
price = order.price,
|
||||
size = order.originalSize,
|
||||
strategyName = strategy.name,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
orderTime = orderTimeMs
|
||||
)
|
||||
logger.info("尾盘订单 TG 通知已发送: orderId=$orderId, strategyId=${strategy.id}, triggerId=${trigger.id}")
|
||||
return true
|
||||
}
|
||||
}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.toJson
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.stereotype.Service
|
||||
import jakarta.annotation.PostConstruct
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* 尾盘策略订单簿 WebSocket 监听:订阅 CLOB Market 频道,收到订单簿/价格变更时若满足条件立即触发下单。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailOrderbookWsService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val executionService: CryptoTailStrategyExecutionService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailOrderbookWsService::class.java)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
/** tokenId -> list of (strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex) */
|
||||
private val tokenToEntries = AtomicReference<Map<String, List<WsBookEntry>>>(emptyMap())
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
|
||||
private val client = createClient().build()
|
||||
|
||||
/** 订阅成功后设置的倒计时 Job,在周期结束时自动刷新订阅 */
|
||||
private var periodEndCountdownJob: Job? = null
|
||||
|
||||
/** 重连延迟(毫秒) */
|
||||
private val reconnectDelayMs = 3_000L
|
||||
|
||||
/** 因无启用策略而主动关闭 WS 时置为 true,onClosing 中不触发重连 */
|
||||
private val closedForNoStrategies = AtomicBoolean(false)
|
||||
|
||||
/** 保护 connect() 的互斥锁,避免多线程并发创建连接 */
|
||||
private val connectLock = Any()
|
||||
|
||||
data class WsBookEntry(
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
val marketTitle: String?,
|
||||
val tokenIds: List<String>,
|
||||
val outcomeIndex: Int
|
||||
)
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
if (strategyRepository.findAllByEnabledTrue().isNotEmpty()) connect()
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
synchronized(connectLock) {
|
||||
if (webSocket != null) return
|
||||
try {
|
||||
val request = Request.Builder().url(wsUrl).build()
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
logger.info("尾盘策略订单簿 WebSocket 已连接")
|
||||
refreshAndSubscribe(fromConnect = true)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleMessage(text)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
this@CryptoTailOrderbookWsService.webSocket = null
|
||||
if (!closedForNoStrategies.getAndSet(false)) scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||
logger.warn("尾盘策略订单簿 WebSocket 异常: ${t.message}")
|
||||
this@CryptoTailOrderbookWsService.webSocket = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
logger.error("尾盘策略订单簿 WebSocket 连接失败: ${e.message}", e)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = scope.launch {
|
||||
delay(reconnectDelayMs)
|
||||
reconnectJob = null
|
||||
if (strategyRepository.findAllByEnabledTrue().isEmpty()) return@launch
|
||||
logger.info("尾盘策略订单簿 WebSocket 尝试重连")
|
||||
connect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(text: String) {
|
||||
if (text == "pong" || text.isEmpty()) return
|
||||
if (closedForNoStrategies.get()) return
|
||||
maybeRefreshSubscriptionIfPeriodChanged()
|
||||
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
|
||||
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||
|
||||
when (eventType) {
|
||||
"book" -> {
|
||||
val assetId = (json.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||
val bids = json.get("bids") as? com.google.gson.JsonArray
|
||||
if (bids == null || bids.isEmpty) return
|
||||
val firstBid = bids.get(0) as? com.google.gson.JsonObject
|
||||
val bestBid = (firstBid?.get("price") as? com.google.gson.JsonPrimitive)?.asString?.toSafeBigDecimal()
|
||||
if (bestBid != null) onBestBid(assetId, bestBid)
|
||||
}
|
||||
|
||||
"price_change" -> {
|
||||
val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return
|
||||
for (i in 0 until priceChanges.size()) {
|
||||
val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue
|
||||
val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue
|
||||
val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString
|
||||
val bestBid = bestBidStr?.toSafeBigDecimal()
|
||||
if (bestBid != null) onBestBid(assetId, bestBid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBestBid(tokenId: String, bestBid: BigDecimal) {
|
||||
if (closedForNoStrategies.get()) return
|
||||
val entries = tokenToEntries.get()[tokenId]
|
||||
if (entries == null) {
|
||||
logger.debug("tokenToEntries null: $tokenId")
|
||||
return
|
||||
}
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
for (e in entries) {
|
||||
val windowStart = e.periodStartUnix + e.strategy.windowStartSeconds
|
||||
val windowEnd = e.periodStartUnix + e.strategy.windowEndSeconds
|
||||
if (nowSeconds < windowStart || nowSeconds >= windowEnd) continue
|
||||
scope.launch {
|
||||
try {
|
||||
runBlocking {
|
||||
executionService.tryTriggerWithPriceFromWs(
|
||||
strategy = e.strategy,
|
||||
periodStartUnix = e.periodStartUnix,
|
||||
marketTitle = e.marketTitle,
|
||||
tokenIds = e.tokenIds,
|
||||
outcomeIndex = e.outcomeIndex,
|
||||
bestBid = bestBid
|
||||
)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
logger.error("WS 触发下单异常: strategyId=${e.strategy.id}, ${ex.message}", ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件驱动:仅在收到 WS 消息时检查当前周期是否变化,若变化则刷新订阅,无需定时轮询。
|
||||
*/
|
||||
private fun maybeRefreshSubscriptionIfPeriodChanged() {
|
||||
val subscribed = tokenToEntries.get().values.flatten().distinctBy { it.strategy.id }
|
||||
.associate { it.strategy.id!! to it.periodStartUnix }
|
||||
if (subscribed.isEmpty()) return
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val currentStrategyIds = strategies.map { it.id!! }.toSet()
|
||||
if (subscribed.keys != currentStrategyIds) {
|
||||
refreshAndSubscribe()
|
||||
return
|
||||
}
|
||||
for (s in strategies) {
|
||||
val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds
|
||||
val subPeriod = subscribed[s.id!!] ?: continue
|
||||
if (currentPeriod != subPeriod) {
|
||||
refreshAndSubscribe()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAndSubscribe(fromConnect: Boolean = false) {
|
||||
periodEndCountdownJob?.cancel()
|
||||
periodEndCountdownJob = null
|
||||
val oldTokenIds = tokenToEntries.get().keys.toSet()
|
||||
val (tokenIds, newMap) = buildSubscriptionMap()
|
||||
tokenToEntries.set(newMap)
|
||||
if (tokenIds.isEmpty()) {
|
||||
closeWebSocketForNoStrategies()
|
||||
return
|
||||
}
|
||||
if (!fromConnect) {
|
||||
if (webSocket == null) {
|
||||
connect()
|
||||
return
|
||||
}
|
||||
if (oldTokenIds == tokenIds.toSet()) {
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
||||
return
|
||||
}
|
||||
closeWebSocketAndReconnect()
|
||||
return
|
||||
}
|
||||
val marketSlugs = newMap.values.asSequence().flatten()
|
||||
.distinctBy { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.map { "${it.strategy.marketSlugPrefix}-${it.periodStartUnix}" }
|
||||
.toList()
|
||||
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||
try {
|
||||
webSocket?.send(msg)
|
||||
logger.info("尾盘策略订单簿订阅: ${tokenIds.size} 个 token, 市场: $marketSlugs")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订阅失败: ${e.message}")
|
||||
return
|
||||
}
|
||||
scheduleRefreshAtPeriodEnd(newMap)
|
||||
precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅更新时关闭当前 WebSocket,由 onClosing 触发重连,重连后 onOpen 会重新订阅。
|
||||
*/
|
||||
private fun closeWebSocketAndReconnect() {
|
||||
val ws = webSocket
|
||||
if (ws != null) {
|
||||
webSocket = null
|
||||
try {
|
||||
ws.close(1000, "subscription_change")
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}")
|
||||
}
|
||||
logger.info("尾盘策略订单簿 WebSocket 已关闭(订阅更新,将重连)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTO 模式:在周期开始(刷新订阅)时预拉历史 30 根 K 线并计算该周期最小价差,触发时直接用缓存。
|
||||
*/
|
||||
private fun precomputeAutoMinSpreadForCurrentPeriods(newMap: Map<String, List<WsBookEntry>>) {
|
||||
val autoPeriods = newMap.values.asSequence().flatten()
|
||||
.filter { it.strategy.minSpreadMode.uppercase() == "AUTO" }
|
||||
.distinctBy { "${it.strategy.intervalSeconds}-${it.periodStartUnix}" }
|
||||
.map { it.strategy.intervalSeconds to it.periodStartUnix }
|
||||
.toList()
|
||||
if (autoPeriods.isEmpty()) return
|
||||
scope.launch {
|
||||
for ((intervalSeconds, periodStartUnix) in autoPeriods) {
|
||||
try {
|
||||
val pair = binanceKlineAutoSpreadService.computeAndCache(intervalSeconds, periodStartUnix)
|
||||
if (pair != null) {
|
||||
logger.info(
|
||||
"周期开始初始价差: interval=${intervalSeconds}s periodStartUnix=$periodStartUnix " +
|
||||
"baseSpreadUp=${pair.first.toPlainString()} baseSpreadDown=${pair.second.toPlainString()}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("周期开始预计算 AUTO 价差失败: interval=$intervalSeconds periodStartUnix=$periodStartUnix ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无启用策略或无需订阅时关闭 WebSocket,并取消重连;停用策略后刷新订阅会走到此处。
|
||||
*/
|
||||
private fun closeWebSocketForNoStrategies() {
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
val ws = webSocket
|
||||
if (ws != null) {
|
||||
closedForNoStrategies.set(true)
|
||||
webSocket = null
|
||||
try {
|
||||
ws.close(1000, "no_enabled_strategies")
|
||||
} catch (e: Exception) {
|
||||
logger.debug("关闭尾盘策略 WebSocket 时异常: ${e.message}")
|
||||
}
|
||||
logger.info("尾盘策略订单簿 WebSocket 已关闭(无启用策略)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅成功后设置倒计时:在当前周期结束时自动刷新订阅,无需等消息触发。
|
||||
*/
|
||||
private fun scheduleRefreshAtPeriodEnd(newMap: Map<String, List<WsBookEntry>>) {
|
||||
val entries = newMap.values.flatten()
|
||||
if (entries.isEmpty()) return
|
||||
val nextPeriodEndSeconds = entries.minOf { it.periodStartUnix + it.strategy.intervalSeconds }
|
||||
val delayMs = (nextPeriodEndSeconds * 1000) - System.currentTimeMillis() + 2000
|
||||
if (delayMs <= 0) return
|
||||
periodEndCountdownJob = scope.launch {
|
||||
delay(delayMs)
|
||||
periodEndCountdownJob = null
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
logger.debug("尾盘策略订单簿订阅倒计时: ${delayMs / 1000}s 后刷新")
|
||||
}
|
||||
|
||||
private fun buildSubscriptionMap(): Pair<List<String>, Map<String, List<WsBookEntry>>> {
|
||||
val strategies = strategyRepository.findAllByEnabledTrue()
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val tokenIdSet = mutableSetOf<String>()
|
||||
val map = mutableMapOf<String, MutableList<WsBookEntry>>()
|
||||
|
||||
for (strategy in strategies) {
|
||||
val interval = strategy.intervalSeconds
|
||||
val periodStartUnix = (nowSeconds / interval) * interval
|
||||
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
||||
if (nowSeconds >= windowEnd) {
|
||||
logger.debug("尾盘策略跳过(已过时间窗口): strategyId=${strategy.id}, slug=${strategy.marketSlugPrefix}, windowEnd=$windowEnd")
|
||||
continue
|
||||
}
|
||||
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||
val event = fetchEventBySlugWithRetry(slug).getOrNull()
|
||||
if (event == null) {
|
||||
logger.warn("尾盘策略跳过(拉取事件失败): strategyId=${strategy.id}, slug=$slug,请确认 Gamma 是否存在该 slug 或稍后重试")
|
||||
continue
|
||||
}
|
||||
val market = event.markets?.firstOrNull()
|
||||
if (market == null) {
|
||||
logger.warn("尾盘策略跳过(事件无市场): strategyId=${strategy.id}, slug=$slug")
|
||||
continue
|
||||
}
|
||||
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||
if (tokenIds.size < 2) {
|
||||
logger.warn("尾盘策略跳过(token 数量不足): strategyId=${strategy.id}, slug=$slug, tokenCount=${tokenIds.size}")
|
||||
continue
|
||||
}
|
||||
tokenIdSet.addAll(tokenIds)
|
||||
for (i in tokenIds.indices) {
|
||||
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||
WsBookEntry(strategy, periodStartUnix, event.title, tokenIds, i)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Pair(tokenIdSet.toList(), map)
|
||||
}
|
||||
|
||||
/** 拉取事件,失败时重试最多 2 次(间隔 1s),避免瞬时失败导致多策略只订阅到其中一个 */
|
||||
private fun fetchEventBySlugWithRetry(slug: String, maxAttempts: Int = 3): Result<GammaEventBySlugResponse> {
|
||||
var lastFailure: Exception? = null
|
||||
repeat(maxAttempts) { attempt ->
|
||||
val result = fetchEventBySlug(slug)
|
||||
if (result.isSuccess) return result
|
||||
lastFailure = result.exceptionOrNull() as? Exception
|
||||
if (attempt < maxAttempts - 1) runBlocking { delay(1000L) }
|
||||
}
|
||||
return Result.failure(lastFailure ?: Exception("fetchEventBySlug failed"))
|
||||
}
|
||||
|
||||
private fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val api = retrofitFactory.createGammaApi()
|
||||
val response = runBlocking { api.getEventBySlug(slug) }
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||
val parsed = clobTokenIds.fromJson<List<String>>()
|
||||
return parsed ?: emptyList()
|
||||
}
|
||||
|
||||
@EventListener
|
||||
fun onStrategyChanged(event: CryptoTailStrategyChangedEvent) {
|
||||
refreshAndSubscribe()
|
||||
}
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* 尾盘策略结算轮询服务
|
||||
* 定时扫描「状态成功但未结算」的触发记录,通过 Gamma 获取 conditionId、链上查询结算结果,计算收益并回写。
|
||||
* 实际成交价与成交量使用 Data API 的 activity 接口获取(getUserActivity),比 CLOB getOrder 更准确;失败时回退为触发时的 amountUsdc + 固定价 0.99。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailSettlementService(
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailSettlementService::class.java)
|
||||
|
||||
private val triggerFixedPrice = BigDecimal("0.99")
|
||||
private val pnlScale = 8
|
||||
|
||||
private val settlementScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
/** 跟踪上一轮结算任务的 Job,防止并发执行(与 OrderStatusUpdateService 一致) */
|
||||
@Volatile
|
||||
private var settlementJob: Job? = null
|
||||
|
||||
/**
|
||||
* 定时轮询:每 10 秒执行一次。
|
||||
* 若上一轮任务仍在执行则跳过本次,避免并发重叠。
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10_000)
|
||||
fun scheduledPollAndSettle() {
|
||||
val previousJob = settlementJob
|
||||
if (previousJob != null && previousJob.isActive) {
|
||||
logger.debug("上一轮尾盘结算任务仍在执行,跳过本次调度")
|
||||
return
|
||||
}
|
||||
settlementJob = settlementScope.launch {
|
||||
try {
|
||||
doPollAndSettle()
|
||||
} catch (e: Exception) {
|
||||
logger.error("尾盘策略结算定时任务异常: ${e.message}", e)
|
||||
} finally {
|
||||
settlementJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询入口:拉取所有 status=success 且 resolved=false 的触发记录,逐条尝试结算并更新。
|
||||
* Controller/定时任务调用此方法(内部对 suspend 使用 runBlocking)。
|
||||
*/
|
||||
@Transactional
|
||||
fun pollAndSettle(): Int = runBlocking {
|
||||
doPollAndSettle()
|
||||
}
|
||||
|
||||
private suspend fun doPollAndSettle(): Int {
|
||||
val pending = triggerRepository.findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc("success", false)
|
||||
if (pending.isEmpty()) return 0
|
||||
var settledCount = 0
|
||||
for (trigger in pending) {
|
||||
try {
|
||||
if (settleOne(trigger)) settledCount++
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘结算单条失败: triggerId=${trigger.id}, ${e.message}", e)
|
||||
}
|
||||
}
|
||||
if (settledCount > 0) {
|
||||
logger.info("尾盘策略结算轮询完成: 处理=${pending.size}, 新结算=$settledCount")
|
||||
}
|
||||
return settledCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单条触发记录:解析 conditionId -> 查链上结算 -> 若已结算则计算 pnl 并更新。
|
||||
* 通过 copy() 生成新实体再 save,不直接修改原实体;实际成交价与投入金额从 Data API activity 获取并更新 triggerPrice、amountUsdc。
|
||||
* @return true 表示本条已结算并更新
|
||||
*/
|
||||
private suspend fun settleOne(trigger: CryptoTailStrategyTrigger): Boolean {
|
||||
if (trigger.resolved) return false
|
||||
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
|
||||
val conditionId = resolveConditionId(strategy, trigger) ?: return false
|
||||
val fill = fetchActivityFill(trigger, strategy, conditionId)
|
||||
val (newTriggerPrice, newAmountUsdc) = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) {
|
||||
val amountUsdc = fill.usdcSize?.takeIf { it.gt(BigDecimal.ZERO) }
|
||||
?: fill.price.multi(fill.size).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
Pair(fill.price, amountUsdc)
|
||||
} else {
|
||||
Pair(trigger.triggerPrice, trigger.amountUsdc)
|
||||
}
|
||||
|
||||
val (_, payouts) = blockchainService.getCondition(conditionId).getOrNull() ?: run {
|
||||
if (fill != null) {
|
||||
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
|
||||
triggerRepository.save(updated)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (payouts.isEmpty()) {
|
||||
if (fill != null) {
|
||||
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
|
||||
triggerRepository.save(updated)
|
||||
}
|
||||
return false
|
||||
}
|
||||
val winnerIndex = payouts.indexOfFirst { it == java.math.BigInteger.ONE }
|
||||
if (winnerIndex < 0) return false
|
||||
|
||||
val won = trigger.outcomeIndex == winnerIndex
|
||||
val pnl = if (fill != null && fill.price.gt(BigDecimal.ZERO) && fill.size.gt(BigDecimal.ZERO)) {
|
||||
if (won) newAmountUsdc.let { fill.size.subtract(it).setScale(pnlScale, RoundingMode.HALF_UP) }
|
||||
else newAmountUsdc.negate().setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
computePnlFallback(trigger.amountUsdc, won)
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
val updated = trigger.copy(
|
||||
triggerPrice = newTriggerPrice,
|
||||
amountUsdc = newAmountUsdc,
|
||||
conditionId = conditionId,
|
||||
resolved = true,
|
||||
winnerOutcomeIndex = winnerIndex,
|
||||
realizedPnl = pnl,
|
||||
settledAt = now
|
||||
)
|
||||
triggerRepository.save(updated)
|
||||
logger.debug("尾盘结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun resolveConditionId(strategy: CryptoTailStrategy, trigger: CryptoTailStrategyTrigger): String? {
|
||||
if (!trigger.conditionId.isNullOrBlank()) return trigger.conditionId
|
||||
val slug = "${strategy.marketSlugPrefix}-${trigger.periodStartUnix}"
|
||||
val event = fetchEventBySlug(slug).getOrNull() ?: return null
|
||||
val markets = event.markets ?: return null
|
||||
val first = markets.firstOrNull() ?: return null
|
||||
return first.conditionId?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.getEventBySlug(slug)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
val msg = if (response.code() == 404) "404" else "code=${response.code()}"
|
||||
Result.failure(Exception(msg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity 匹配到的一条 TRADE 的成交数据:价格、数量、实际投入 USDC(接口 usdcSize)。
|
||||
*/
|
||||
private data class ActivityFill(
|
||||
val price: BigDecimal,
|
||||
val size: BigDecimal,
|
||||
val usdcSize: BigDecimal?
|
||||
)
|
||||
|
||||
/**
|
||||
* 通过 Data API activity 接口获取该触发对应的实际成交价、成交量与投入金额(比 CLOB getOrder 更准确)。
|
||||
* 只有此接口返回匹配的 TRADE 且 price/size 有效时,结算才会更新 triggerPrice、amountUsdc(表现);投入金额优先用 activity 的 usdcSize。
|
||||
*/
|
||||
private suspend fun fetchActivityFill(
|
||||
trigger: CryptoTailStrategyTrigger,
|
||||
strategy: CryptoTailStrategy,
|
||||
conditionId: String
|
||||
): ActivityFill? {
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||
logger.warn("尾盘结算未拉取 activity: 账户不存在, triggerId=${trigger.id}, accountId=${strategy.accountId}")
|
||||
return null
|
||||
}
|
||||
val user = account.proxyAddress
|
||||
val triggerTimeSeconds = trigger.createdAt / 1000
|
||||
val start = triggerTimeSeconds - 120
|
||||
val end = triggerTimeSeconds + 600
|
||||
return try {
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
val response = dataApi.getUserActivity(
|
||||
user = user,
|
||||
type = listOf("TRADE"),
|
||||
start = start,
|
||||
end = end,
|
||||
limit = 50,
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "DESC"
|
||||
)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
logger.warn("尾盘结算拉取 activity 失败: triggerId=${trigger.id}, code=${response.code()}")
|
||||
return null
|
||||
}
|
||||
val activities = response.body()!!
|
||||
// 只匹配 TRADE:返回里可能混有 REDEEM(outcomeIndex=999、price=0)等,需排除
|
||||
val match = activities.firstOrNull { a ->
|
||||
a.type == "TRADE" &&
|
||||
a.conditionId == conditionId &&
|
||||
a.outcomeIndex != null && a.outcomeIndex!! in 0..1 &&
|
||||
a.outcomeIndex == trigger.outcomeIndex &&
|
||||
a.side?.uppercase() == "BUY" &&
|
||||
a.price != null && a.price!! > 0 &&
|
||||
a.size != null && a.size!! > 0
|
||||
} ?: run {
|
||||
logger.debug("尾盘结算 activity 无匹配成交: triggerId=${trigger.id}, conditionId=$conditionId, outcomeIndex=${trigger.outcomeIndex}, 条数=${activities.size}")
|
||||
return null
|
||||
}
|
||||
val price = match.price!!.toSafeBigDecimal()
|
||||
val size = match.size!!.toSafeBigDecimal()
|
||||
val usdcSize = match.usdcSize?.toSafeBigDecimal()?.takeIf { it.gt(BigDecimal.ZERO) }
|
||||
if (price.gt(BigDecimal.ZERO) && size.gt(BigDecimal.ZERO)) {
|
||||
ActivityFill(price = price, size = size, usdcSize = usdcSize)
|
||||
} else {
|
||||
logger.debug("尾盘结算 activity 成交数据无效: triggerId=${trigger.id}, price=$price, size=$size")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘结算拉取 activity 异常,触发价/投入金额不会更新: triggerId=${trigger.id}, error=${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按实际成交价与成交量计算收益:成本 = sizeMatched * price;赢则赎回 sizeMatched * 1,输则 0。
|
||||
*/
|
||||
private fun computePnlFromFill(price: BigDecimal, sizeMatched: BigDecimal, won: Boolean): BigDecimal {
|
||||
val cost = sizeMatched.multi(price).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
return if (won) {
|
||||
sizeMatched.subtract(cost).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
cost.negate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退收益计算:无 API 数据时用触发时的 amountUsdc 与固定价 0.99。
|
||||
* 赢: pnl = amountUsdc/0.99 - amountUsdc;输: pnl = -amountUsdc
|
||||
*/
|
||||
private fun computePnlFallback(amountUsdc: BigDecimal, won: Boolean): BigDecimal {
|
||||
return if (won) {
|
||||
amountUsdc.divide(triggerFixedPrice, pnlScale, RoundingMode.HALF_UP).subtract(amountUsdc)
|
||||
} else {
|
||||
amountUsdc.negate()
|
||||
}
|
||||
}
|
||||
}
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.api.NewOrderRequest
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderSigningService
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.github.benmanes.caffeine.cache.Cache
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/** 尾盘策略固定下单价格(最高价 0.99),不再在触发时拉取最优价 */
|
||||
private const val TRIGGER_FIXED_PRICE = "0.99"
|
||||
|
||||
/** 数量小数位数,与 OrderSigningService 的 roundConfig.size 一致 */
|
||||
private const val SIZE_DECIMAL_SCALE = 2
|
||||
|
||||
/**
|
||||
* 周期内预置上下文:账户、解密凭证、费率、签名类型、CLOB 客户端;不含预签订单。
|
||||
* 触发时 FIXED/RATIO 均按 outcomeIndex 计算 size 并签名提交。
|
||||
*/
|
||||
private data class PeriodContext(
|
||||
val strategy: CryptoTailStrategy,
|
||||
val periodStartUnix: Long,
|
||||
val account: Account,
|
||||
val decryptedPrivateKey: String,
|
||||
val apiSecretDecrypted: String,
|
||||
val apiPassphraseDecrypted: String,
|
||||
val clobApi: PolymarketClobApi,
|
||||
val feeRateByTokenId: Map<String, String>,
|
||||
val signatureType: Int,
|
||||
val tokenIds: List<String>,
|
||||
val marketTitle: String?
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略执行服务:按周期与时间窗口检查价格并下单,每周期最多触发一次。
|
||||
* 周期开始预置账户、解密、费率、签名类型、CLOB 客户端;触发时按 outcomeIndex 计算 size 并签名提交。
|
||||
*/
|
||||
@Service
|
||||
class CryptoTailStrategyExecutionService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val accountService: AccountService,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val clobService: PolymarketClobService,
|
||||
private val orderSigningService: OrderSigningService,
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val binanceKlineService: BinanceKlineService,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyExecutionService::class.java)
|
||||
|
||||
/** 按 (strategyId, periodStartUnix) 加锁,避免同一周期被调度器与 WebSocket 等多路并发重复下单 */
|
||||
private val triggerMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
private fun triggerLockKey(strategyId: Long, periodStartUnix: Long): String = "$strategyId-$periodStartUnix"
|
||||
|
||||
private fun getTriggerMutex(strategyId: Long, periodStartUnix: Long): Mutex =
|
||||
triggerMutexMap.getOrPut(triggerLockKey(strategyId, periodStartUnix)) { Mutex() }
|
||||
|
||||
/** 周期预置上下文缓存:(strategyId-periodStartUnix) -> PeriodContext,过期周期在读取时剔除 */
|
||||
private val periodContextCache = ConcurrentHashMap<String, PeriodContext>()
|
||||
|
||||
/** 已打印「首次满足条件」日志的周期:LRU 容量 100,每周期只打一次 */
|
||||
private val conditionLoggedCache: Cache<String, Long> = Caffeine.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* 在周期内首次需要时构建并缓存预置上下文;失败返回 null,触发流程将走完整路径。
|
||||
* 预置:账户、解密、费率、签名类型、CLOB 客户端;不预签订单,触发时再签名。
|
||||
*/
|
||||
private suspend fun ensurePeriodContext(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
tokenIds: List<String>,
|
||||
marketTitle: String?
|
||||
): PeriodContext? {
|
||||
val key = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
periodContextCache[key]?.let { return it }
|
||||
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: return null
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) return null
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: return null
|
||||
} catch (e: Exception) {
|
||||
logger.warn("尾盘策略周期上下文解密私钥失败: accountId=${account.id}", e)
|
||||
return null
|
||||
}
|
||||
val apiSecret = try {
|
||||
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
val apiPassphrase = try {
|
||||
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val feeRateByTokenId = tokenIds.associate { tokenId ->
|
||||
tokenId to (clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0")
|
||||
}
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
if (strategy.amountMode.uppercase() != "RATIO" && strategy.amountValue < BigDecimal("1")) return null
|
||||
|
||||
val ctx = PeriodContext(
|
||||
strategy = strategy,
|
||||
periodStartUnix = periodStartUnix,
|
||||
account = account,
|
||||
decryptedPrivateKey = decryptedKey,
|
||||
apiSecretDecrypted = apiSecret,
|
||||
apiPassphraseDecrypted = apiPassphrase,
|
||||
clobApi = clobApi,
|
||||
feeRateByTokenId = feeRateByTokenId,
|
||||
signatureType = signatureType,
|
||||
tokenIds = tokenIds,
|
||||
marketTitle = marketTitle
|
||||
)
|
||||
periodContextCache[key] = ctx
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 按投入金额和价格计算可买张数:size = ceil(amountUsdc/price),保留小数,至少 1。
|
||||
* 与 OrderSigningService 一致使用小数数量,向上取整保证不超过投入金额。
|
||||
*/
|
||||
private fun computeSize(amountUsdc: BigDecimal, price: BigDecimal): String {
|
||||
val size = amountUsdc.divide(price, SIZE_DECIMAL_SCALE, RoundingMode.UP).max(BigDecimal.ONE)
|
||||
return size.toPlainString()
|
||||
}
|
||||
|
||||
private fun getOrInvalidatePeriodContext(strategy: CryptoTailStrategy, periodStartUnix: Long): PeriodContext? {
|
||||
val key = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val ctx = periodContextCache[key] ?: return null
|
||||
if (periodStartUnix + strategy.intervalSeconds <= nowSeconds) {
|
||||
periodContextCache.remove(key)
|
||||
return null
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* 由订单簿 WebSocket 触发:当收到某 token 的 bestBid 且满足区间时调用,若本周期未触发则下单。
|
||||
*/
|
||||
suspend fun tryTriggerWithPriceFromWs(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
bestBid: BigDecimal
|
||||
) {
|
||||
if (outcomeIndex < 0 || outcomeIndex >= tokenIds.size) return
|
||||
if (bestBid < strategy.minPrice || bestBid > strategy.maxPrice) return
|
||||
|
||||
val mutex = getTriggerMutex(strategy.id!!, periodStartUnix)
|
||||
mutex.withLock {
|
||||
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(strategy.id!!, periodStartUnix) != null) return@withLock
|
||||
val logKey = triggerLockKey(strategy.id!!, periodStartUnix)
|
||||
if (conditionLoggedCache.getIfPresent(logKey) == null) {
|
||||
conditionLoggedCache.put(logKey, periodStartUnix + strategy.intervalSeconds)
|
||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
|
||||
val openPrice = oc?.first?.toPlainString() ?: "-"
|
||||
val closePrice = oc?.second?.toPlainString() ?: "-"
|
||||
val strategyName = strategy.name?.takeIf { it.isNotBlank() } ?: "尾盘策略-${strategy.marketSlugPrefix}"
|
||||
val direction = if (outcomeIndex == 0) "Up" else "Down"
|
||||
logger.info(
|
||||
"尾盘策略首次满足条件: strategyName=$strategyName, strategyId=${strategy.id}, " +
|
||||
"openPrice=$openPrice, closePrice=$closePrice, marketPrice=${bestBid.toPlainString()}, " +
|
||||
"direction=$direction, outcomeIndex=$outcomeIndex"
|
||||
)
|
||||
}
|
||||
if (!passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)) return@withLock
|
||||
ensurePeriodContext(strategy, periodStartUnix, tokenIds, marketTitle)
|
||||
placeOrderForTrigger(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, bestBid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun passMinSpreadCheck(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): Boolean {
|
||||
val mode = strategy.minSpreadMode.uppercase()
|
||||
if (mode == "NONE") return true
|
||||
val oc = binanceKlineService.getCurrentOpenClose(strategy.intervalSeconds, periodStartUnix)
|
||||
?: return false
|
||||
val (openP, closeP) = oc
|
||||
val spreadAbs = closeP.subtract(openP).abs()
|
||||
when (mode) {
|
||||
"FIXED" -> {
|
||||
val effectiveMinSpread = strategy.minSpreadValue?.takeIf { it > BigDecimal.ZERO }
|
||||
if (effectiveMinSpread == null || effectiveMinSpread <= BigDecimal.ZERO) return true
|
||||
return spreadAbs >= effectiveMinSpread
|
||||
}
|
||||
"AUTO" -> {
|
||||
val result = computeAutoEffectiveMinSpread(strategy, periodStartUnix, outcomeIndex) ?: return true
|
||||
val effectiveMinSpread = result.effectiveMinSpread
|
||||
if (effectiveMinSpread <= BigDecimal.ZERO) return true
|
||||
return spreadAbs >= effectiveMinSpread
|
||||
}
|
||||
else -> return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTO 模式:取 100% 基准价差,按窗口内毫秒进度计算动态系数(100%→50%)得到有效最小价差。
|
||||
*/
|
||||
private data class AutoSpreadResult(
|
||||
val baseSpread: BigDecimal,
|
||||
val coefficient: BigDecimal,
|
||||
val effectiveMinSpread: BigDecimal
|
||||
)
|
||||
|
||||
private fun computeAutoEffectiveMinSpread(strategy: CryptoTailStrategy, periodStartUnix: Long, outcomeIndex: Int): AutoSpreadResult? {
|
||||
val baseSpread = binanceKlineAutoSpreadService.getAutoMinSpreadBase(strategy.intervalSeconds, periodStartUnix, outcomeIndex)
|
||||
?: binanceKlineAutoSpreadService.computeAndCache(strategy.intervalSeconds, periodStartUnix)?.let { if (outcomeIndex == 0) it.first else it.second }
|
||||
?: return null
|
||||
if (baseSpread <= BigDecimal.ZERO) return null
|
||||
val windowStartMs = (periodStartUnix + strategy.windowStartSeconds) * 1000L
|
||||
val windowEndMs = (periodStartUnix + strategy.windowEndSeconds) * 1000L
|
||||
val windowLenMs = windowEndMs - windowStartMs
|
||||
val coefficient = if (windowLenMs <= 0) {
|
||||
BigDecimal.ONE
|
||||
} else {
|
||||
val nowMs = System.currentTimeMillis()
|
||||
val elapsedMs = (nowMs - windowStartMs).toBigDecimal()
|
||||
val progress = elapsedMs.div(windowLenMs.toBigDecimal(), 18, RoundingMode.HALF_UP)
|
||||
.let { p -> maxOf(BigDecimal.ZERO, minOf(BigDecimal.ONE, p)) }
|
||||
BigDecimal.ONE.subtract(progress.multi("0.5"))
|
||||
}
|
||||
val effectiveMinSpread = baseSpread.multi(coefficient).setScale(8, RoundingMode.HALF_UP)
|
||||
return AutoSpreadResult(baseSpread, coefficient, effectiveMinSpread)
|
||||
}
|
||||
|
||||
private suspend fun placeOrderForTrigger(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal
|
||||
) {
|
||||
val ctx = getOrInvalidatePeriodContext(strategy, periodStartUnix)
|
||||
|
||||
if (ctx != null) {
|
||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> {
|
||||
val balanceResult = accountService.getAccountBalance(ctx.account.id)
|
||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||
}
|
||||
else -> strategy.amountValue
|
||||
}
|
||||
if (amountUsdc < BigDecimal("1")) {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足")
|
||||
return
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
||||
return
|
||||
}
|
||||
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
val size = computeSize(amountUsdc, price)
|
||||
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = ctx.decryptedPrivateKey,
|
||||
makerAddress = ctx.account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
size = size,
|
||||
signatureType = ctx.signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = ctx.account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
submitOrderAndSaveRecord(ctx.clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
||||
return
|
||||
}
|
||||
|
||||
placeOrderForTriggerSlowPath(strategy, periodStartUnix, marketTitle, tokenIds, outcomeIndex, triggerPrice)
|
||||
}
|
||||
|
||||
private suspend fun submitOrderAndSaveRecord(
|
||||
clobApi: PolymarketClobApi,
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderRequest: NewOrderRequest
|
||||
) {
|
||||
var failReason: String? = null
|
||||
try {
|
||||
val response = clobApi.createOrder(orderRequest)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.success && body.orderId != null) {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, body.orderId, "success", null)
|
||||
logger.info("尾盘策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
||||
return
|
||||
}
|
||||
failReason = body.errorMsg ?: "unknown"
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
failReason = "HTTP ${response.code()} $errorBody"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failReason = e.message ?: e.toString()
|
||||
logger.error("尾盘策略下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
|
||||
}
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", failReason)
|
||||
logger.error("尾盘策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
|
||||
}
|
||||
|
||||
/** 无预置上下文时的完整流程:固定价格 0.99,账户/解密/费率/签名在触发时执行 */
|
||||
private suspend fun placeOrderForTriggerSlowPath(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
tokenIds: List<String>,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal
|
||||
) {
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null) ?: run {
|
||||
logger.warn("账户不存在: accountId=${strategy.accountId}")
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户不存在")
|
||||
return
|
||||
}
|
||||
if (account.apiKey == null || account.apiSecret == null || account.apiPassphrase == null) {
|
||||
logger.warn("账户未配置 API 凭证: accountId=${account.id}")
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, BigDecimal.ZERO, null, "fail", "账户未配置API凭证")
|
||||
return
|
||||
}
|
||||
|
||||
val balanceResult = accountService.getAccountBalance(account.id)
|
||||
val availableBalance = balanceResult.getOrNull()?.availableBalance?.toSafeBigDecimal() ?: BigDecimal.ZERO
|
||||
val amountUsdc = when (strategy.amountMode.uppercase()) {
|
||||
"RATIO" -> availableBalance.multiply(strategy.amountValue).divide(BigDecimal("100"), 18, RoundingMode.DOWN)
|
||||
else -> strategy.amountValue
|
||||
}
|
||||
if (amountUsdc < BigDecimal("1")) {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "投入金额不足")
|
||||
return
|
||||
}
|
||||
|
||||
val tokenId = tokenIds.getOrNull(outcomeIndex) ?: run {
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "tokenIds 越界")
|
||||
return
|
||||
}
|
||||
val price = BigDecimal(TRIGGER_FIXED_PRICE)
|
||||
val size = computeSize(amountUsdc, price)
|
||||
|
||||
val decryptedKey = try {
|
||||
cryptoUtils.decrypt(account.privateKey) ?: ""
|
||||
} catch (e: Exception) {
|
||||
logger.error("解密私钥失败: accountId=${account.id}", e)
|
||||
saveTriggerRecord(strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, null, "fail", "解密私钥失败")
|
||||
return
|
||||
}
|
||||
val apiSecret = try {
|
||||
account.apiSecret?.let { cryptoUtils.decrypt(it) } ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
val apiPassphrase = try {
|
||||
account.apiPassphrase?.let { cryptoUtils.decrypt(it) } ?: ""
|
||||
} catch (e: Exception) { "" }
|
||||
val clobApi = retrofitFactory.createClobApi(account.apiKey!!, apiSecret, apiPassphrase, account.walletAddress)
|
||||
val feeRateBps = clobService.getFeeRate(tokenId).getOrNull()?.toString() ?: "0"
|
||||
val signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedKey,
|
||||
makerAddress = account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = TRIGGER_FIXED_PRICE,
|
||||
size = size,
|
||||
signatureType = signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
submitOrderAndSaveRecord(clobApi, strategy, periodStartUnix, marketTitle, outcomeIndex, triggerPrice, amountUsdc, orderRequest)
|
||||
}
|
||||
|
||||
private suspend fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||
return try {
|
||||
val gammaApi = retrofitFactory.createGammaApi()
|
||||
val response = gammaApi.getEventBySlug(slug)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
val msg = if (response.code() == 404) "404" else "code=${response.code()}"
|
||||
Result.failure(Exception(msg))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||
val parsed = clobTokenIds.fromJson<List<String>>()
|
||||
return parsed ?: emptyList()
|
||||
}
|
||||
|
||||
private fun saveTriggerRecord(
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderId: String?,
|
||||
status: String,
|
||||
failReason: String?
|
||||
) {
|
||||
val record = CryptoTailStrategyTrigger(
|
||||
strategyId = strategy.id!!,
|
||||
periodStartUnix = periodStartUnix,
|
||||
marketTitle = marketTitle,
|
||||
outcomeIndex = outcomeIndex,
|
||||
triggerPrice = triggerPrice,
|
||||
amountUsdc = amountUsdc,
|
||||
orderId = orderId,
|
||||
status = status,
|
||||
failReason = failReason
|
||||
)
|
||||
triggerRepository.save(record)
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package com.wrbug.polymarketbot.service.cryptotail
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class CryptoTailStrategyService(
|
||||
private val strategyRepository: CryptoTailStrategyRepository,
|
||||
private val triggerRepository: CryptoTailStrategyTriggerRepository,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CryptoTailStrategyService::class.java)
|
||||
|
||||
private val maxWindowByInterval = mapOf(300 to 300, 900 to 900)
|
||||
|
||||
@Transactional
|
||||
fun create(request: CryptoTailStrategyCreateRequest): Result<CryptoTailStrategyDto> {
|
||||
return try {
|
||||
if (request.accountId <= 0) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ACCOUNT_ID_INVALID.messageKey))
|
||||
}
|
||||
if (request.marketSlugPrefix.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val interval = request.intervalSeconds
|
||||
if (interval != 300 && interval != 900) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_INTERVAL_INVALID.messageKey))
|
||||
}
|
||||
val maxWindow = maxWindowByInterval[interval] ?: 300
|
||||
if (request.windowStartSeconds > request.windowEndSeconds) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
|
||||
}
|
||||
if (request.windowEndSeconds > maxWindow) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
|
||||
}
|
||||
val amountMode = request.amountMode.uppercase()
|
||||
if (amountMode != "RATIO" && amountMode != "FIXED") {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey))
|
||||
}
|
||||
val minPrice = request.minPrice.toSafeBigDecimal()
|
||||
val maxPrice = (request.maxPrice ?: "1").toSafeBigDecimal()
|
||||
if (minPrice > maxPrice) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val amountValue = request.amountValue.toSafeBigDecimal()
|
||||
if (amountValue <= BigDecimal.ZERO) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val minSpreadMode = (request.minSpreadMode ?: "NONE").uppercase()
|
||||
if (minSpreadMode != "NONE" && minSpreadMode != "FIXED" && minSpreadMode != "AUTO") {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val minSpreadValue = request.minSpreadValue?.toSafeBigDecimal()
|
||||
if (minSpreadMode == "FIXED" && (minSpreadValue == null || minSpreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
|
||||
val nameToSave = request.name?.takeIf { it.isNotBlank() }
|
||||
?: generateStrategyName(request.marketSlugPrefix.trim())
|
||||
|
||||
val entity = CryptoTailStrategy(
|
||||
accountId = request.accountId,
|
||||
name = nameToSave,
|
||||
marketSlugPrefix = request.marketSlugPrefix.trim(),
|
||||
intervalSeconds = interval,
|
||||
windowStartSeconds = request.windowStartSeconds,
|
||||
windowEndSeconds = request.windowEndSeconds,
|
||||
minPrice = minPrice,
|
||||
maxPrice = maxPrice,
|
||||
amountMode = amountMode,
|
||||
amountValue = amountValue,
|
||||
minSpreadMode = minSpreadMode,
|
||||
minSpreadValue = minSpreadValue,
|
||||
enabled = request.enabled
|
||||
)
|
||||
val saved = strategyRepository.save(entity)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
Result.success(entityToDto(saved, null))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(request: CryptoTailStrategyUpdateRequest): Result<CryptoTailStrategyDto> {
|
||||
return try {
|
||||
val existing = strategyRepository.findById(request.strategyId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
val interval = existing.intervalSeconds
|
||||
val maxWindow = maxWindowByInterval[interval] ?: 300
|
||||
|
||||
request.windowStartSeconds?.let { ws ->
|
||||
request.windowEndSeconds?.let { we ->
|
||||
if (ws > we) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey))
|
||||
if (we > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey))
|
||||
}
|
||||
}
|
||||
request.windowStartSeconds?.let { if (it > (request.windowEndSeconds ?: existing.windowEndSeconds)) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_INVALID.messageKey)) }
|
||||
request.windowEndSeconds?.let { if (it > maxWindow) return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_WINDOW_EXCEED.messageKey)) }
|
||||
|
||||
val nameToSave = request.name?.takeIf { it.isNotBlank() }
|
||||
?: existing.name?.takeIf { it.isNotBlank() }
|
||||
?: generateStrategyName(existing.marketSlugPrefix)
|
||||
|
||||
val newMinSpreadMode = request.minSpreadMode?.uppercase() ?: existing.minSpreadMode
|
||||
if (newMinSpreadMode != "NONE" && newMinSpreadMode != "FIXED" && newMinSpreadMode != "AUTO") {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
val newMinSpreadValue = request.minSpreadValue?.toSafeBigDecimal() ?: existing.minSpreadValue
|
||||
if (newMinSpreadMode == "FIXED" && (newMinSpreadValue == null || newMinSpreadValue < BigDecimal.ZERO)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
|
||||
val updated = existing.copy(
|
||||
name = nameToSave,
|
||||
windowStartSeconds = request.windowStartSeconds ?: existing.windowStartSeconds,
|
||||
windowEndSeconds = request.windowEndSeconds ?: existing.windowEndSeconds,
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: existing.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: existing.maxPrice,
|
||||
amountMode = request.amountMode?.uppercase() ?: existing.amountMode,
|
||||
amountValue = request.amountValue?.toSafeBigDecimal() ?: existing.amountValue,
|
||||
minSpreadMode = newMinSpreadMode,
|
||||
minSpreadValue = newMinSpreadValue,
|
||||
enabled = request.enabled ?: existing.enabled,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
if (updated.minPrice > updated.maxPrice) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.PARAM_ERROR.messageKey))
|
||||
}
|
||||
request.amountMode?.uppercase()?.let { if (it != "RATIO" && it != "FIXED") return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_AMOUNT_MODE_INVALID.messageKey)) }
|
||||
val saved = strategyRepository.save(updated)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
val lastTrigger = triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(saved.id!!, PageRequest.of(0, 1))
|
||||
.content.firstOrNull()?.createdAt
|
||||
Result.success(entityToDto(saved, lastTrigger))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun delete(strategyId: Long): Result<Unit> {
|
||||
return try {
|
||||
if (!strategyRepository.existsById(strategyId)) {
|
||||
return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
}
|
||||
strategyRepository.deleteById(strategyId)
|
||||
eventPublisher.publishEvent(CryptoTailStrategyChangedEvent(this))
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("删除尾盘策略失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun list(request: CryptoTailStrategyListRequest): Result<CryptoTailStrategyListResponse> {
|
||||
return try {
|
||||
val list = when {
|
||||
request.accountId != null && request.enabled != null -> strategyRepository.findByAccountIdAndEnabled(request.accountId, request.enabled)
|
||||
request.accountId != null -> strategyRepository.findAllByAccountId(request.accountId)
|
||||
request.enabled == true -> strategyRepository.findAllByEnabledTrue()
|
||||
request.enabled == false -> strategyRepository.findAll().filter { !it.enabled }
|
||||
else -> strategyRepository.findAll()
|
||||
}
|
||||
val lastTriggerMap = list.map { it.id!! }.associateWith { id ->
|
||||
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(id, PageRequest.of(0, 1))
|
||||
.content.firstOrNull()?.createdAt
|
||||
}
|
||||
val dtos = list.map { entityToDto(it, lastTriggerMap[it.id]) }
|
||||
Result.success(CryptoTailStrategyListResponse(list = dtos))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询尾盘策略列表失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
|
||||
return try {
|
||||
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
|
||||
val startTs = request.startDate ?: 0L
|
||||
val endTs = request.endDate ?: Long.MAX_VALUE
|
||||
val useTimeRange = request.startDate != null || request.endDate != null
|
||||
val pageResult = when {
|
||||
useTimeRange && request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.findAllByStrategyIdAndStatusAndCreatedAtBetweenOrderByCreatedAtDesc(
|
||||
request.strategyId, request.status, startTs, endTs, page
|
||||
)
|
||||
useTimeRange ->
|
||||
triggerRepository.findAllByStrategyIdAndCreatedAtBetweenOrderByCreatedAtDesc(
|
||||
request.strategyId, startTs, endTs, page
|
||||
)
|
||||
request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.findAllByStrategyIdAndStatusOrderByCreatedAtDesc(request.strategyId, request.status, page)
|
||||
else ->
|
||||
triggerRepository.findAllByStrategyIdOrderByCreatedAtDesc(request.strategyId, page)
|
||||
}
|
||||
val list = pageResult.content.map { triggerToDto(it) }
|
||||
val total = when {
|
||||
useTimeRange && request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.countByStrategyIdAndStatusAndCreatedAtBetween(request.strategyId, request.status, startTs, endTs)
|
||||
useTimeRange ->
|
||||
triggerRepository.countByStrategyIdAndCreatedAtBetween(request.strategyId, startTs, endTs)
|
||||
request.status != null && request.status.isNotBlank() ->
|
||||
triggerRepository.countByStrategyIdAndStatus(request.strategyId, request.status)
|
||||
else ->
|
||||
pageResult.totalElements
|
||||
}
|
||||
Result.success(CryptoTailStrategyTriggerListResponse(list = list, total = total))
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询触发记录失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getStrategy(strategyId: Long): CryptoTailStrategy? = strategyRepository.findById(strategyId).orElse(null)
|
||||
|
||||
private fun generateStrategyName(marketSlugPrefix: String): String {
|
||||
val suffix = Instant.now().atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
|
||||
return "尾盘策略-${marketSlugPrefix}-$suffix"
|
||||
}
|
||||
|
||||
private fun entityToDto(e: CryptoTailStrategy, lastTriggerAt: Long?): CryptoTailStrategyDto {
|
||||
val strategyId = e.id ?: 0L
|
||||
val totalPnl = triggerRepository.sumRealizedPnlByStrategyId(strategyId)
|
||||
val settledCount = triggerRepository.countResolvedByStrategyId(strategyId)
|
||||
val winCount = triggerRepository.countWinsByStrategyId(strategyId)
|
||||
val winRateStr = if (settledCount > 0L) {
|
||||
BigDecimal(winCount).divide(BigDecimal(settledCount), 4, java.math.RoundingMode.HALF_UP).toPlainString()
|
||||
} else null
|
||||
return CryptoTailStrategyDto(
|
||||
id = strategyId,
|
||||
accountId = e.accountId,
|
||||
name = e.name,
|
||||
marketSlugPrefix = e.marketSlugPrefix,
|
||||
marketTitle = null,
|
||||
intervalSeconds = e.intervalSeconds,
|
||||
windowStartSeconds = e.windowStartSeconds,
|
||||
windowEndSeconds = e.windowEndSeconds,
|
||||
minPrice = e.minPrice.toPlainString(),
|
||||
maxPrice = e.maxPrice.toPlainString(),
|
||||
amountMode = e.amountMode,
|
||||
amountValue = e.amountValue.toPlainString(),
|
||||
minSpreadMode = e.minSpreadMode,
|
||||
minSpreadValue = e.minSpreadValue?.toPlainString(),
|
||||
enabled = e.enabled,
|
||||
lastTriggerAt = lastTriggerAt,
|
||||
totalRealizedPnl = totalPnl?.toPlainString(),
|
||||
settledCount = settledCount,
|
||||
winCount = winCount,
|
||||
winRate = winRateStr,
|
||||
createdAt = e.createdAt,
|
||||
updatedAt = e.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
private fun triggerToDto(t: CryptoTailStrategyTrigger): CryptoTailStrategyTriggerDto = CryptoTailStrategyTriggerDto(
|
||||
id = t.id ?: 0L,
|
||||
strategyId = t.strategyId,
|
||||
periodStartUnix = t.periodStartUnix,
|
||||
marketTitle = t.marketTitle,
|
||||
outcomeIndex = t.outcomeIndex,
|
||||
triggerPrice = t.triggerPrice.toPlainString(),
|
||||
amountUsdc = t.amountUsdc.toPlainString(),
|
||||
orderId = t.orderId,
|
||||
status = t.status,
|
||||
failReason = t.failReason,
|
||||
resolved = t.resolved,
|
||||
realizedPnl = t.realizedPnl?.toPlainString(),
|
||||
winnerOutcomeIndex = t.winnerOutcomeIndex,
|
||||
settledAt = t.settledAt,
|
||||
createdAt = t.createdAt
|
||||
)
|
||||
}
|
||||
+75
@@ -15,6 +15,7 @@ import org.springframework.context.ApplicationContextAware
|
||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.PolymarketActivityWsService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.UnifiedOnChainWsService
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -76,6 +77,17 @@ class ApiHealthCheckService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 BinanceKlineService(通过 ApplicationContext 避免循环依赖)
|
||||
*/
|
||||
private fun getBinanceKlineService(): BinanceKlineService? {
|
||||
return try {
|
||||
applicationContext?.getBean(BinanceKlineService::class.java)
|
||||
} catch (e: BeansException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private val logger = LoggerFactory.getLogger(ApiHealthCheckService::class.java)
|
||||
|
||||
/**
|
||||
@@ -91,6 +103,8 @@ class ApiHealthCheckService(
|
||||
async { checkDataApi() },
|
||||
async { checkGammaApi() },
|
||||
async { checkPolygonRpc() },
|
||||
async { checkBinanceApi() },
|
||||
async { checkBinanceWebSocket() },
|
||||
async { checkPolymarketRtdsWebSocket() },
|
||||
async { checkPolymarketActivityWebSocket() },
|
||||
async { checkUnifiedOnChainWebSocket() },
|
||||
@@ -197,6 +211,67 @@ class ApiHealthCheckService(
|
||||
checkJsonRpcApi("Polygon RPC", rpcUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查币安 API(用于 K 线等)
|
||||
* 使用 /api/v3/ping 端点
|
||||
*/
|
||||
private suspend fun checkBinanceApi(): ApiHealthCheckDto = withContext(Dispatchers.IO) {
|
||||
val url = "https://api.binance.com/api/v3/ping"
|
||||
checkApi("币安 API", url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查币安 K 线 WebSocket 连接状态(5m / 15m)
|
||||
*/
|
||||
private suspend fun checkBinanceWebSocket(): ApiHealthCheckDto = withContext(Dispatchers.Default) {
|
||||
val binanceWsUrl = "wss://stream.binance.com:9443"
|
||||
try {
|
||||
val binanceKlineService = getBinanceKlineService()
|
||||
if (binanceKlineService == null) {
|
||||
return@withContext ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "服务未初始化"
|
||||
)
|
||||
}
|
||||
val statuses = binanceKlineService.getConnectionStatuses()
|
||||
val total = statuses.size
|
||||
val connected = statuses.values.count { it }
|
||||
if (connected == total && total > 0) {
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "success",
|
||||
message = "连接正常 (5m、15m)"
|
||||
)
|
||||
} else if (connected > 0) {
|
||||
val which = statuses.filter { it.value }.keys.joinToString("、")
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "部分连接正常 ($which)"
|
||||
)
|
||||
} else {
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "连接断开"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("检查币安 WebSocket 状态失败", e)
|
||||
ApiHealthCheckDto(
|
||||
name = "币安 WebSocket",
|
||||
url = binanceWsUrl,
|
||||
status = "error",
|
||||
message = "检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Polymarket RTDS WebSocket 连接状态
|
||||
* 用于订单推送服务
|
||||
|
||||
+259
-12
@@ -4,6 +4,7 @@ import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.JsonRpcRequest
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.enums.WalletType
|
||||
import com.wrbug.polymarketbot.util.EthereumUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
@@ -40,6 +41,15 @@ class RelayClientService(
|
||||
// 空集合ID
|
||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
// Polygon PROXY(Magic)合约地址,参考 builder-relayer-client config
|
||||
private val proxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"
|
||||
private val relayHubAddress = "0xD216153c06E857cD7f72665E0aF1d7D82172F494"
|
||||
private val defaultProxyGasLimit = "10000000"
|
||||
|
||||
// Builder Relayer API 交易类型常量
|
||||
private val RELAYER_TYPE_PROXY = "PROXY"
|
||||
private val RELAYER_TYPE_SAFE = "SAFE"
|
||||
|
||||
private val polygonRpcApi: EthereumRpcApi by lazy {
|
||||
val rpcUrl = rpcNodeService.getHttpUrl()
|
||||
retrofitFactory.createEthereumRpcApi(rpcUrl)
|
||||
@@ -201,33 +211,45 @@ class RelayClientService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Safe 交易(通过 Proxy.execTransaction)
|
||||
* 执行代理交易(Safe 或 Magic PROXY)
|
||||
* 参考 TypeScript: RelayClient.execute()
|
||||
*
|
||||
* 优先使用 Builder Relayer(Gasless),如果未配置则回退到手动发送交易
|
||||
*
|
||||
* @param privateKey 私钥
|
||||
* @param proxyAddress 代理钱包地址
|
||||
* @param safeTx Safe 交易对象
|
||||
* @param safeTx 交易对象(to/data/value)
|
||||
* @param walletType 钱包类型:MAGIC 使用 PROXY Gasless,SAFE 使用 Safe 流程
|
||||
* @return 交易哈希
|
||||
*/
|
||||
suspend fun execute(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
safeTx: SafeTransaction
|
||||
safeTx: SafeTransaction,
|
||||
walletType: WalletType = WalletType.SAFE
|
||||
): Result<String> {
|
||||
return try {
|
||||
// 验证参数
|
||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||
}
|
||||
|
||||
// 检查 Builder API Key 是否已配置
|
||||
val builderApiKey = systemConfigService.getBuilderApiKey()
|
||||
val builderSecret = systemConfigService.getBuilderSecret()
|
||||
val builderPassphrase = systemConfigService.getBuilderPassphrase()
|
||||
|
||||
// 优先使用 Builder Relayer(Gasless)
|
||||
if (walletType == WalletType.MAGIC) {
|
||||
if (!isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
return Result.failure(IllegalStateException("Magic 账户赎回必须配置 Builder API Key(Gasless)"))
|
||||
}
|
||||
logger.info("使用 Builder Relayer PROXY 执行 Magic 赎回")
|
||||
return executeViaBuilderRelayerProxy(
|
||||
privateKey,
|
||||
proxyAddress,
|
||||
safeTx,
|
||||
builderApiKey!!,
|
||||
builderSecret!!,
|
||||
builderPassphrase!!
|
||||
)
|
||||
}
|
||||
|
||||
if (isBuilderRelayerEnabled(builderApiKey, builderSecret, builderPassphrase)) {
|
||||
logger.info("使用 Builder Relayer 执行 Gasless 交易")
|
||||
return executeViaBuilderRelayer(
|
||||
@@ -240,15 +262,240 @@ class RelayClientService(
|
||||
)
|
||||
}
|
||||
|
||||
// 回退到手动发送交易(需要用户支付 gas)
|
||||
logger.info("Builder Relayer 未配置,使用手动发送交易(需要用户支付 gas)")
|
||||
return executeManually(privateKey, proxyAddress, safeTx)
|
||||
} catch (e: Exception) {
|
||||
logger.error("执行 Safe 交易失败: ${e.message}", e)
|
||||
logger.error("执行交易失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 执行 PROXY(Magic)交易(Gasless)
|
||||
* 参考: builder-relayer-client client.ts executeProxyTransactions, builder/proxy.ts
|
||||
*/
|
||||
private suspend fun executeViaBuilderRelayerProxy(
|
||||
privateKey: String,
|
||||
proxyAddress: String,
|
||||
safeTx: SafeTransaction,
|
||||
builderApiKey: String,
|
||||
builderSecret: String,
|
||||
builderPassphrase: String
|
||||
): Result<String> {
|
||||
val relayerApi = retrofitFactory.createBuilderRelayerApi(
|
||||
relayerUrl = PolymarketConstants.BUILDER_RELAYER_URL,
|
||||
apiKey = builderApiKey,
|
||||
secret = builderSecret,
|
||||
passphrase = builderPassphrase
|
||||
)
|
||||
|
||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||
val credentials = org.web3j.crypto.Credentials.create(privateKeyBigInt.toString(16))
|
||||
val fromAddress = credentials.address
|
||||
|
||||
val relayPayloadResponse = relayerApi.getRelayPayload(fromAddress, RELAYER_TYPE_PROXY)
|
||||
if (!relayPayloadResponse.isSuccessful || relayPayloadResponse.body() == null) {
|
||||
val errorBody = relayPayloadResponse.errorBody()?.string() ?: "未知错误"
|
||||
logger.error("获取 Relay Payload 失败: code=${relayPayloadResponse.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("获取 Relay Payload 失败: ${relayPayloadResponse.code()} - $errorBody"))
|
||||
}
|
||||
val relayPayload = relayPayloadResponse.body()!!
|
||||
val relayAddress = relayPayload.address
|
||||
val nonce = relayPayload.nonce
|
||||
|
||||
val proxyCallData = encodeProxyTransactionData(safeTx)
|
||||
|
||||
// 估算 gas limit(参考 builder-relayer-client builder/proxy.ts getGasLimit)
|
||||
val gasLimit = try {
|
||||
estimateProxyGasLimit(fromAddress, proxyFactoryAddress, proxyCallData)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("估算 PROXY gas limit 失败,使用默认值: ${e.message}", e)
|
||||
defaultProxyGasLimit
|
||||
}
|
||||
|
||||
val structHash = createProxyStructHash(
|
||||
from = fromAddress,
|
||||
to = proxyFactoryAddress,
|
||||
data = proxyCallData,
|
||||
txFee = "0",
|
||||
gasPrice = "0",
|
||||
gasLimit = gasLimit,
|
||||
nonce = nonce,
|
||||
relayHubAddress = relayHubAddress,
|
||||
relayAddress = relayAddress
|
||||
)
|
||||
|
||||
val prefix = "\u0019Ethereum Signed Message:\n32".toByteArray(Charsets.UTF_8)
|
||||
val messageWithPrefix = ByteArray(prefix.size + structHash.size)
|
||||
System.arraycopy(prefix, 0, messageWithPrefix, 0, prefix.size)
|
||||
System.arraycopy(structHash, 0, messageWithPrefix, prefix.size, structHash.size)
|
||||
|
||||
val keccak256 = org.bouncycastle.crypto.digests.KeccakDigest(256)
|
||||
keccak256.update(messageWithPrefix, 0, messageWithPrefix.size)
|
||||
val hashWithPrefix = ByteArray(keccak256.digestSize)
|
||||
keccak256.doFinal(hashWithPrefix, 0)
|
||||
|
||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
||||
val signature = org.web3j.crypto.Sign.signMessage(hashWithPrefix, ecKeyPair, false)
|
||||
val sigHex = "0x" + org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0') +
|
||||
org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0') +
|
||||
String.format("%02x", (signature.v as ByteArray).getOrElse(0) { 0 }.toInt() and 0xff)
|
||||
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = RELAYER_TYPE_PROXY,
|
||||
from = fromAddress,
|
||||
to = proxyFactoryAddress,
|
||||
proxyWallet = proxyAddress,
|
||||
data = proxyCallData,
|
||||
nonce = nonce,
|
||||
signature = sigHex,
|
||||
signatureParams = BuilderRelayerApi.SignatureParams(
|
||||
gasPrice = "0",
|
||||
gasLimit = gasLimit,
|
||||
relayerFee = "0",
|
||||
relayHub = relayHubAddress,
|
||||
relay = relayAddress
|
||||
),
|
||||
metadata = "Redeem positions via Builder Relayer PROXY"
|
||||
)
|
||||
|
||||
val response = relayerApi.submitTransaction(request)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
val errorBody = response.errorBody()?.string() ?: "未知错误"
|
||||
logger.error("Builder Relayer PROXY API 调用失败: code=${response.code()}, body=$errorBody")
|
||||
return Result.failure(Exception("Builder Relayer PROXY 调用失败: ${response.code()} - $errorBody"))
|
||||
}
|
||||
|
||||
val relayerResponse = response.body()!!
|
||||
val txHash = relayerResponse.transactionHash ?: relayerResponse.hash
|
||||
?: return Result.failure(Exception("Builder Relayer 返回的交易哈希为空"))
|
||||
logger.info("Builder Relayer PROXY 执行成功: transactionID=${relayerResponse.transactionID}, txHash=$txHash")
|
||||
return Result.success(txHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 ProxyFactory.proxy(calls) 调用数据
|
||||
* 参考: builder-relayer-client encode/proxy.ts, abis proxyFactory proxy((uint8,address,uint256,bytes)[])
|
||||
*
|
||||
* ABI 编码规则:当 tuple 数组中的 tuple 包含动态类型(bytes)时,需要先存储 tuple offset
|
||||
* 结构:
|
||||
* - selector (4 bytes)
|
||||
* - array offset (32 bytes) = 32
|
||||
* - array length (32 bytes) = 1
|
||||
* - tuple[0] offset (32 bytes) = 32 (指向 tuple 数据开始,从 array length 之后计算)
|
||||
* - tuple[0] 数据:
|
||||
* - typeCode (32 bytes) = 1
|
||||
* - to (32 bytes)
|
||||
* - value (32 bytes) = 0
|
||||
* - data offset (32 bytes) = 128 (从 tuple 数据开始计算)
|
||||
* - data length (32 bytes)
|
||||
* - data (padded to 32-byte boundary)
|
||||
*/
|
||||
private fun encodeProxyTransactionData(safeTx: SafeTransaction): String {
|
||||
val selector = EthereumUtils.getFunctionSelector("proxy((uint8,address,uint256,bytes)[])")
|
||||
val callData = safeTx.data.removePrefix("0x")
|
||||
val dataLen = callData.length / 2
|
||||
val dataLenPadded = (dataLen + 31) / 32 * 32 * 2
|
||||
val dataPadded = callData.padEnd(dataLenPadded, '0')
|
||||
|
||||
// ABI 编码:tuple 数组,tuple 包含动态类型 bytes
|
||||
// 1. array offset: 32 (指向 array length)
|
||||
val arrayOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
// 2. array length: 1
|
||||
val arrayLength = EthereumUtils.encodeUint256(BigInteger.ONE)
|
||||
// 3. tuple[0] offset: 32 (指向 tuple 数据开始,从 array length 之后计算)
|
||||
val tupleOffset = EthereumUtils.encodeUint256(BigInteger.valueOf(32))
|
||||
// 4. tuple[0] 数据:
|
||||
// - typeCode: 1
|
||||
val typeCode = EthereumUtils.encodeUint256(BigInteger.ONE)
|
||||
// - to: address
|
||||
val toEncoded = EthereumUtils.encodeAddress(safeTx.to)
|
||||
// - value: 0
|
||||
val valueEncoded = EthereumUtils.encodeUint256(BigInteger.ZERO)
|
||||
// - data offset: 128 (从 tuple 数据开始计算,typeCode+to+value = 3*32 = 96,加上 offset 字段 = 128)
|
||||
val dataOffsetInTuple = BigInteger.valueOf(128)
|
||||
val dataOffsetEncoded = EthereumUtils.encodeUint256(dataOffsetInTuple)
|
||||
// - data length
|
||||
val dataLengthEncoded = EthereumUtils.encodeUint256(BigInteger.valueOf(dataLen.toLong()))
|
||||
// - data (padded)
|
||||
|
||||
return "0x" + selector.removePrefix("0x") + arrayOffset + arrayLength +
|
||||
tupleOffset + typeCode + toEncoded + valueEncoded + dataOffsetEncoded +
|
||||
dataLengthEncoded + dataPadded
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算 PROXY 交易的 gas limit
|
||||
* 参考: builder-relayer-client builder/proxy.ts getGasLimit
|
||||
*/
|
||||
private suspend fun estimateProxyGasLimit(
|
||||
from: String,
|
||||
to: String,
|
||||
data: String
|
||||
): String {
|
||||
val rpcApi = polygonRpcApi
|
||||
|
||||
val rpcRequest = JsonRpcRequest(
|
||||
method = "eth_estimateGas",
|
||||
params = listOf(
|
||||
mapOf(
|
||||
"from" to from,
|
||||
"to" to to,
|
||||
"data" to data
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val response = rpcApi.call(rpcRequest)
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw Exception("eth_estimateGas 调用失败: ${response.code()} ${response.message()}")
|
||||
}
|
||||
|
||||
val rpcResponse = response.body()!!
|
||||
if (rpcResponse.error != null) {
|
||||
throw Exception("eth_estimateGas 返回错误: ${rpcResponse.error.message}")
|
||||
}
|
||||
|
||||
val hexGasLimit = rpcResponse.result?.asString
|
||||
?: throw Exception("eth_estimateGas 结果为空")
|
||||
|
||||
// 将十六进制转换为十进制字符串
|
||||
val gasLimitBigInt = BigInteger(hexGasLimit.removePrefix("0x"), 16)
|
||||
return gasLimitBigInt.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 PROXY 结构哈希,参考 builder-relayer-client builder/proxy.ts createStructHash
|
||||
* concat: "rlx:" + from + to + data + txFee + gasPrice + gasLimit + nonce + relayHub + relay, then keccak256
|
||||
*/
|
||||
private fun createProxyStructHash(
|
||||
from: String,
|
||||
to: String,
|
||||
data: String,
|
||||
txFee: String,
|
||||
gasPrice: String,
|
||||
gasLimit: String,
|
||||
nonce: String,
|
||||
relayHubAddress: String,
|
||||
relayAddress: String
|
||||
): ByteArray {
|
||||
val rlxPrefix = "rlx:".toByteArray(Charsets.UTF_8)
|
||||
val fromBytes = EthereumUtils.hexToBytes(from.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val toBytes = EthereumUtils.hexToBytes(to.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val dataBytes = EthereumUtils.hexToBytes(data.removePrefix("0x"))
|
||||
val txFeeBytes = EthereumUtils.encodeUint256(BigInteger(txFee)).let { EthereumUtils.hexToBytes(it) }
|
||||
val gasPriceBytes = EthereumUtils.encodeUint256(BigInteger(gasPrice)).let { EthereumUtils.hexToBytes(it) }
|
||||
val gasLimitBytes = EthereumUtils.encodeUint256(BigInteger(gasLimit)).let { EthereumUtils.hexToBytes(it) }
|
||||
val nonceBytes = EthereumUtils.encodeUint256(BigInteger(nonce)).let { EthereumUtils.hexToBytes(it) }
|
||||
val relayHubBytes = EthereumUtils.hexToBytes(relayHubAddress.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
val relayBytes = EthereumUtils.hexToBytes(relayAddress.lowercase().removePrefix("0x").padStart(40, '0'))
|
||||
|
||||
val concat = rlxPrefix + fromBytes + toBytes + dataBytes + txFeeBytes + gasPriceBytes +
|
||||
gasLimitBytes + nonceBytes + relayHubBytes + relayBytes
|
||||
return EthereumUtils.keccak256(concat)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Builder Relayer 执行交易(Gasless)
|
||||
* 参考: builder-relayer-client/src/client.ts 的 execute 方法
|
||||
@@ -278,7 +525,7 @@ class RelayClientService(
|
||||
val redeemCallData = safeTx.data
|
||||
|
||||
// 获取 Proxy 的 nonce(通过 Builder Relayer API)
|
||||
val nonceResponse = relayerApi.getNonce(fromAddress, "SAFE")
|
||||
val nonceResponse = relayerApi.getNonce(fromAddress, RELAYER_TYPE_SAFE)
|
||||
if (!nonceResponse.isSuccessful || nonceResponse.body() == null) {
|
||||
val errorBody = nonceResponse.errorBody()?.string() ?: "未知错误"
|
||||
logger.error("获取 nonce 失败: code=${nonceResponse.code()}, body=$errorBody")
|
||||
@@ -345,7 +592,7 @@ class RelayClientService(
|
||||
// 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts)
|
||||
// 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀
|
||||
val request = BuilderRelayerApi.TransactionRequest(
|
||||
type = "SAFE",
|
||||
type = RELAYER_TYPE_SAFE,
|
||||
from = fromAddress,
|
||||
to = safeTx.to,
|
||||
proxyWallet = proxyAddress,
|
||||
|
||||
+142
@@ -303,6 +303,69 @@ class TelegramNotificationService(
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送尾盘策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配尾盘订单后调用)
|
||||
*/
|
||||
suspend fun sendCryptoTailOrderSuccessNotification(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String? = null,
|
||||
marketSlug: String? = null,
|
||||
side: String,
|
||||
outcome: String? = null,
|
||||
price: String,
|
||||
size: String,
|
||||
strategyName: String? = null,
|
||||
accountName: String? = null,
|
||||
walletAddress: String? = null,
|
||||
locale: java.util.Locale? = null,
|
||||
orderTime: Long? = null
|
||||
) {
|
||||
if (orderId != null) {
|
||||
val lastSentTime = sentOrderIds[orderId]
|
||||
if (lastSentTime != null && System.currentTimeMillis() - lastSentTime < 5 * 60 * 1000) {
|
||||
logger.info("尾盘订单通知已发送过(5分钟内),跳过: orderId=$orderId")
|
||||
return
|
||||
}
|
||||
sentOrderIds[orderId] = System.currentTimeMillis()
|
||||
if (sentOrderIds.size > 1000) {
|
||||
val expiryTime = System.currentTimeMillis() - 5 * 60 * 1000
|
||||
sentOrderIds.entries.removeIf { it.value < expiryTime }
|
||||
}
|
||||
}
|
||||
val currentLocale = locale ?: try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
|
||||
java.util.Locale("zh", "CN")
|
||||
}
|
||||
val amount = try {
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
val sizeDecimal = size.toSafeBigDecimal()
|
||||
priceDecimal.multiply(sizeDecimal).toString()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
val message = buildCryptoTailOrderSuccessMessage(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = side,
|
||||
outcome = outcome,
|
||||
price = price,
|
||||
size = size,
|
||||
amount = amount,
|
||||
strategyName = strategyName,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
locale = currentLocale,
|
||||
orderTime = orderTime
|
||||
)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单被过滤消息
|
||||
*/
|
||||
@@ -827,6 +890,85 @@ class TelegramNotificationService(
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建尾盘策略下单成功消息(与订单成功格式一致,增加「尾盘策略」标题与策略名)
|
||||
*/
|
||||
private fun buildCryptoTailOrderSuccessMessage(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
strategyName: String?,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
orderTime: Long?
|
||||
): String {
|
||||
val tailOrderSuccess = messageSource.getMessage("notification.tail.order.success", null, "尾盘策略下单成功", locale)
|
||||
val strategyLabel = messageSource.getMessage("notification.tail.strategy", null, "策略", locale)
|
||||
val orderInfo = messageSource.getMessage("notification.order.info", null, "订单信息", locale)
|
||||
val orderIdLabel = messageSource.getMessage("notification.order.id", null, "订单ID", locale)
|
||||
val marketLabel = messageSource.getMessage("notification.order.market", null, "市场", locale)
|
||||
val sideLabel = messageSource.getMessage("notification.order.side", null, "方向", locale)
|
||||
val outcomeLabel = messageSource.getMessage("notification.order.outcome", null, "市场方向", locale)
|
||||
val priceLabel = messageSource.getMessage("notification.order.price", null, "价格", locale)
|
||||
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
|
||||
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
||||
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val unknown: String = messageSource.getMessage("common.unknown", null, "未知", locale) ?: "未知"
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale)
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale)
|
||||
else -> side
|
||||
}
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">")
|
||||
val strategyDisplay = strategyName?.takeIf { it.isNotBlank() } ?: unknown
|
||||
val escapedStrategyName = strategyDisplay.replace("<", "<").replace(">", ">")
|
||||
val amountDisplay = if (amount != null) {
|
||||
try {
|
||||
val amountDecimal = amount.toSafeBigDecimal()
|
||||
val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) { amount }
|
||||
} else calculateFailed
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> null
|
||||
}
|
||||
val marketDisplay = if (marketLink != null) "<a href=\"$marketLink\">$escapedMarketTitle</a>" else escapedMarketTitle
|
||||
val outcomeDisplay = if (!outcome.isNullOrBlank()) {
|
||||
val escapedOutcome = outcome.replace("<", "<").replace(">", ">")
|
||||
"\n• $outcomeLabel: <b>$escapedOutcome</b>"
|
||||
} else ""
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
return """🚀 <b>$tailOrderSuccess</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
• $orderIdLabel: <code>${orderId ?: unknown}</code>
|
||||
• $strategyLabel: $escapedStrategyName
|
||||
• $marketLabel: $marketDisplay$outcomeDisplay
|
||||
• $sideLabel: <b>$sideDisplay</b>
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.util
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.BinanceApi
|
||||
import com.wrbug.polymarketbot.api.BuilderRelayerApi
|
||||
import com.wrbug.polymarketbot.api.EthereumRpcApi
|
||||
import com.wrbug.polymarketbot.api.GitHubApi
|
||||
@@ -300,7 +301,18 @@ class RetrofitFactory(
|
||||
fun createDataApi(): PolymarketDataApi {
|
||||
return dataApi
|
||||
}
|
||||
|
||||
|
||||
private val binanceApi: BinanceApi by lazy {
|
||||
Retrofit.Builder()
|
||||
.baseUrl("https://api.binance.com/")
|
||||
.client(sharedOkHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(BinanceApi::class.java)
|
||||
}
|
||||
|
||||
fun createBinanceApi(): BinanceApi = binanceApi
|
||||
|
||||
/**
|
||||
* 创建 Builder Relayer API 客户端
|
||||
* 按 relayerUrl 缓存,避免重复创建
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- ============================================
|
||||
-- 回测功能表创建
|
||||
-- ============================================
|
||||
|
||||
-- ============================================
|
||||
-- 2. 创建回测任务表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS backtest_task (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '回测任务ID',
|
||||
task_name VARCHAR(100) NOT NULL COMMENT '回测任务名称',
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
initial_balance DECIMAL(20, 8) NOT NULL COMMENT '初始资金',
|
||||
final_balance DECIMAL(20, 8) DEFAULT NULL COMMENT '最终资金',
|
||||
profit_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '收益金额',
|
||||
profit_rate DECIMAL(10, 4) DEFAULT NULL COMMENT '收益率(%)',
|
||||
backtest_days INT NOT NULL COMMENT '回测天数',
|
||||
start_time BIGINT NOT NULL COMMENT '回测开始时间(历史时间)',
|
||||
end_time BIGINT DEFAULT NULL COMMENT '回测结束时间(历史时间)',
|
||||
|
||||
-- 跟单配置 (复制CopyTrading表结构)
|
||||
copy_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '跟单模式: RATIO/FIXED',
|
||||
copy_ratio DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '跟单比例',
|
||||
fixed_amount DECIMAL(20, 8) DEFAULT NULL COMMENT '固定金额',
|
||||
max_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1000.0 COMMENT '最大单笔订单',
|
||||
min_order_size DECIMAL(20, 8) NOT NULL DEFAULT 1.0 COMMENT '最小单笔订单',
|
||||
max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 10000.0 COMMENT '最大每日亏损',
|
||||
max_daily_orders INT NOT NULL DEFAULT 100 COMMENT '最大每日订单数',
|
||||
price_tolerance DECIMAL(5, 2) NOT NULL DEFAULT 5.0 COMMENT '价格容忍度(%)',
|
||||
delay_seconds INT NOT NULL DEFAULT 0 COMMENT '延迟秒数',
|
||||
support_sell BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否支持卖出',
|
||||
min_order_depth DECIMAL(20, 8) DEFAULT NULL COMMENT '最小订单深度',
|
||||
max_spread DECIMAL(20, 8) DEFAULT NULL COMMENT '最大价差',
|
||||
min_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最低价格',
|
||||
max_price DECIMAL(20, 8) DEFAULT NULL COMMENT '最高价格',
|
||||
max_position_value DECIMAL(20, 8) DEFAULT NULL COMMENT '最大仓位金额',
|
||||
keyword_filter_mode VARCHAR(20) NOT NULL DEFAULT 'DISABLED' COMMENT '关键字过滤模式',
|
||||
keywords JSON DEFAULT NULL COMMENT '关键字列表',
|
||||
max_market_end_date BIGINT DEFAULT NULL COMMENT '市场截止时间限制',
|
||||
|
||||
-- 统计字段
|
||||
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
|
||||
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
|
||||
|
||||
-- 执行状态
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/RUNNING/COMPLETED/STOPPED/FAILED',
|
||||
progress INT DEFAULT 0 COMMENT '执行进度(0-100)',
|
||||
total_trades INT DEFAULT 0 COMMENT '总交易笔数',
|
||||
buy_trades INT DEFAULT 0 COMMENT '买入笔数',
|
||||
sell_trades INT DEFAULT 0 COMMENT '卖出笔数',
|
||||
win_trades INT DEFAULT 0 COMMENT '盈利交易笔数',
|
||||
loss_trades INT DEFAULT 0 COMMENT '亏损交易笔数',
|
||||
win_rate DECIMAL(5, 2) DEFAULT NULL COMMENT '胜率(%)',
|
||||
max_profit DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔盈利',
|
||||
max_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '最大单笔亏损',
|
||||
max_drawdown DECIMAL(20, 8) DEFAULT NULL COMMENT '最大回撤',
|
||||
error_message TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
execution_started_at BIGINT DEFAULT NULL COMMENT '执行开始时间(系统时间)',
|
||||
execution_finished_at BIGINT DEFAULT NULL COMMENT '执行完成时间(系统时间)',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间',
|
||||
|
||||
INDEX idx_leader_id (leader_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_leader_profit (leader_id, profit_rate DESC),
|
||||
INDEX idx_status_created (status, created_at DESC),
|
||||
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测任务表';
|
||||
|
||||
-- ============================================
|
||||
-- 3. 创建回测交易记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS backtest_trade (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '交易记录ID',
|
||||
backtest_task_id BIGINT NOT NULL COMMENT '回测任务ID',
|
||||
trade_time BIGINT NOT NULL COMMENT '交易时间',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
|
||||
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
|
||||
side VARCHAR(20) NOT NULL COMMENT '方向: BUY/SELL/SETTLEMENT',
|
||||
outcome VARCHAR(50) NOT NULL COMMENT '结果: YES/NO或outcomeIndex',
|
||||
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
|
||||
quantity DECIMAL(20, 8) NOT NULL COMMENT '数量',
|
||||
price DECIMAL(20, 8) NOT NULL COMMENT '价格',
|
||||
amount DECIMAL(20, 8) NOT NULL COMMENT '金额',
|
||||
fee DECIMAL(20, 8) NOT NULL DEFAULT 0.0 COMMENT '手续费',
|
||||
profit_loss DECIMAL(20, 8) DEFAULT NULL COMMENT '盈亏(仅卖出时)',
|
||||
balance_after DECIMAL(20, 8) NOT NULL COMMENT '交易后余额',
|
||||
leader_trade_id VARCHAR(100) DEFAULT NULL COMMENT 'Leader原始交易ID',
|
||||
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
|
||||
INDEX idx_backtest_task_id (backtest_task_id),
|
||||
INDEX idx_trade_time (trade_time),
|
||||
FOREIGN KEY (backtest_task_id) REFERENCES backtest_task(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='回测交易记录表';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- ============================================
|
||||
-- 回测功能恢复字段添加
|
||||
-- ============================================
|
||||
|
||||
-- 添加恢复相关字段到回测任务表
|
||||
ALTER TABLE backtest_task
|
||||
ADD COLUMN last_processed_trade_time BIGINT DEFAULT NULL COMMENT '最后处理的交易时间(用于中断恢复)',
|
||||
ADD COLUMN last_processed_trade_index INT DEFAULT 0 COMMENT '最后处理的交易索引(用于中断恢复)',
|
||||
ADD COLUMN processed_trade_count INT DEFAULT 0 COMMENT '已处理的交易数量(用于显示真实进度)';
|
||||
|
||||
-- 添加索引以优化查询性能
|
||||
ALTER TABLE backtest_task
|
||||
ADD INDEX idx_last_processed_trade_time (last_processed_trade_time);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Drop unused columns from backtest_task table
|
||||
-- These fields are not needed for backtest scenarios as they use historical data
|
||||
-- Note: Using standard SQL syntax compatible with MySQL 5.7+
|
||||
|
||||
-- Check if columns exist before dropping (using standard approach)
|
||||
ALTER TABLE backtest_task DROP COLUMN price_tolerance;
|
||||
ALTER TABLE backtest_task DROP COLUMN delay_seconds;
|
||||
ALTER TABLE backtest_task DROP COLUMN min_order_depth;
|
||||
ALTER TABLE backtest_task DROP COLUMN max_spread;
|
||||
ALTER TABLE backtest_task DROP COLUMN min_price;
|
||||
ALTER TABLE backtest_task DROP COLUMN max_price;
|
||||
ALTER TABLE backtest_task DROP COLUMN max_position_value;
|
||||
ALTER TABLE backtest_task DROP COLUMN max_market_end_date;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- ============================================
|
||||
-- 修复回测恢复逻辑:将 last_processed_trade_index 默认值改为 NULL
|
||||
-- ============================================
|
||||
-- 问题:新建任务的 last_processed_trade_index 默认值为 0,导致被误判为恢复任务
|
||||
-- 解决:将默认值改为 NULL,并将现有新任务的 0 值改为 NULL
|
||||
|
||||
-- 1. 将现有新任务(status='PENDING' 且 last_processed_trade_index=0)的索引值改为 NULL
|
||||
UPDATE backtest_task
|
||||
SET last_processed_trade_index = NULL
|
||||
WHERE status = 'PENDING' AND last_processed_trade_index = 0;
|
||||
|
||||
-- 2. 修改字段定义,允许 NULL 并设置默认值为 NULL
|
||||
ALTER TABLE backtest_task
|
||||
MODIFY COLUMN last_processed_trade_index INT DEFAULT NULL COMMENT '最后处理的交易索引(用于中断恢复)';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 添加最大仓位金额配置到回测任务表
|
||||
ALTER TABLE backtest_task
|
||||
ADD COLUMN max_position_value DECIMAL(20, 8) COMMENT '最大仓位金额(USDC),NULL表示不启用';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- ============================================
|
||||
-- V32: 添加回测价格区间过滤字段
|
||||
-- 用于配置价格区间,仅在指定价格区间内的订单才会跟单
|
||||
-- ============================================
|
||||
|
||||
-- 添加价格区间字段到回测任务表
|
||||
ALTER TABLE backtest_task
|
||||
ADD COLUMN min_price DECIMAL(20, 8) NULL COMMENT '最低价格(可选),NULL表示不限制最低价',
|
||||
ADD COLUMN max_price DECIMAL(20, 8) NULL COMMENT '最高价格(可选),NULL表示不限制最高价';
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
-- ============================================
|
||||
-- V33: 唯一约束从 wallet_address 改为 proxy_address
|
||||
-- 允许同一 EOA 以不同代理类型(Magic/Safe)各导入一个账户,按代理地址去重
|
||||
-- ============================================
|
||||
|
||||
-- 将已存在账户的 wallet_type 统一为 safe(历史数据兼容)
|
||||
UPDATE wallet_accounts SET wallet_type = 'safe';
|
||||
|
||||
-- 删除 wallet_address 上的唯一约束(通过 KEY_COLUMN_USAGE 定位到该列的约束名)
|
||||
SET @uk_name = (SELECT kcu.CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
||||
ON kcu.TABLE_SCHEMA = tc.TABLE_SCHEMA AND kcu.TABLE_NAME = tc.TABLE_NAME AND kcu.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
|
||||
WHERE kcu.TABLE_SCHEMA = DATABASE()
|
||||
AND kcu.TABLE_NAME = 'wallet_accounts'
|
||||
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND kcu.COLUMN_NAME = 'wallet_address'
|
||||
LIMIT 1);
|
||||
SET @sql = IF(@uk_name IS NOT NULL,
|
||||
CONCAT('ALTER TABLE wallet_accounts DROP INDEX ', @uk_name),
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 为 proxy_address 添加唯一约束(若已存在则跳过)
|
||||
SET @uk_exists = (SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'wallet_accounts'
|
||||
AND CONSTRAINT_TYPE = 'UNIQUE'
|
||||
AND CONSTRAINT_NAME = 'uk_wallet_accounts_proxy_address'
|
||||
LIMIT 1);
|
||||
SET @sql2 = IF(@uk_exists IS NULL,
|
||||
'ALTER TABLE wallet_accounts ADD UNIQUE KEY uk_wallet_accounts_proxy_address (proxy_address)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt2 FROM @sql2;
|
||||
EXECUTE stmt2;
|
||||
DEALLOCATE PREPARE stmt2;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- ============================================
|
||||
-- V34: 加密市场尾盘策略表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS crypto_tail_strategy (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '策略ID',
|
||||
account_id BIGINT NOT NULL COMMENT '钱包账户ID',
|
||||
name VARCHAR(255) DEFAULT NULL COMMENT '策略名称(可选,用于列表展示)',
|
||||
market_slug_prefix VARCHAR(64) NOT NULL COMMENT '市场 slug 前缀,如 btc-updown-5m、btc-updown-15m',
|
||||
interval_seconds INT NOT NULL COMMENT '周期长度秒数:300(5分钟) 或 900(15分钟)',
|
||||
window_start_seconds INT NOT NULL COMMENT '时间窗口开始秒数(相对周期起点)',
|
||||
window_end_seconds INT NOT NULL COMMENT '时间窗口结束秒数(相对周期起点)',
|
||||
min_price DECIMAL(20, 8) NOT NULL COMMENT '最低触发价格 0~1',
|
||||
max_price DECIMAL(20, 8) NOT NULL DEFAULT 1 COMMENT '最高触发价格 0~1,默认1',
|
||||
amount_mode VARCHAR(10) NOT NULL DEFAULT 'RATIO' COMMENT '投入方式: RATIO=按比例, FIXED=固定金额',
|
||||
amount_value DECIMAL(20, 8) NOT NULL COMMENT '比例(0~100)或固定USDC金额',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用: 0=停用, 1=启用',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间',
|
||||
INDEX idx_account_id (account_id),
|
||||
INDEX idx_enabled (enabled),
|
||||
FOREIGN KEY (account_id) REFERENCES wallet_accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='加密市场尾盘策略表';
|
||||
|
||||
-- ============================================
|
||||
-- 触发记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS crypto_tail_strategy_trigger (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
|
||||
strategy_id BIGINT NOT NULL COMMENT '策略ID',
|
||||
period_start_unix BIGINT NOT NULL COMMENT '周期起点 Unix 秒',
|
||||
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
|
||||
outcome_index INT NOT NULL COMMENT '方向: 0=Up, 1=Down',
|
||||
trigger_price DECIMAL(20, 8) NOT NULL COMMENT '触发时价格',
|
||||
amount_usdc DECIMAL(20, 8) NOT NULL COMMENT '投入金额 USDC',
|
||||
order_id VARCHAR(128) DEFAULT NULL COMMENT '订单ID(成功时有值)',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态: success, fail',
|
||||
fail_reason VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
INDEX idx_strategy_id (strategy_id),
|
||||
INDEX idx_period (strategy_id, period_start_unix),
|
||||
INDEX idx_created_at (created_at),
|
||||
FOREIGN KEY (strategy_id) REFERENCES crypto_tail_strategy(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='尾盘策略触发记录表';
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- ============================================
|
||||
-- V35: 尾盘策略触发记录 - 结算与收益字段
|
||||
-- 用于轮询服务:扫描 success 但未结算的订单,查链上结算结果并回写收益
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE crypto_tail_strategy_trigger
|
||||
ADD COLUMN condition_id VARCHAR(66) DEFAULT NULL COMMENT '市场 conditionId(用于查链上结算)' AFTER order_id,
|
||||
ADD COLUMN resolved TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已结算: 0=未结算, 1=已结算',
|
||||
ADD COLUMN winner_outcome_index INT DEFAULT NULL COMMENT '市场赢家 outcome 索引(结算后写入)',
|
||||
ADD COLUMN realized_pnl DECIMAL(20, 8) DEFAULT NULL COMMENT '已实现盈亏 USDC(赢为正,输为负)',
|
||||
ADD COLUMN settled_at BIGINT DEFAULT NULL COMMENT '结算时间(毫秒时间戳)';
|
||||
|
||||
CREATE INDEX idx_trigger_settlement ON crypto_tail_strategy_trigger (status, resolved);
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- ============================================
|
||||
-- V36: 尾盘策略触发记录 - TG 通知已发标记(与跟单轮询发 TG 一致)
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE crypto_tail_strategy_trigger
|
||||
ADD COLUMN notification_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已发送 TG 通知: 0=未发送, 1=已发送';
|
||||
|
||||
CREATE INDEX idx_trigger_notification ON crypto_tail_strategy_trigger (status, notification_sent);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 尾盘策略最小价差:NONE=不校验, FIXED=固定值, AUTO=历史计算
|
||||
ALTER TABLE crypto_tail_strategy
|
||||
ADD COLUMN min_spread_mode VARCHAR(16) NOT NULL DEFAULT 'NONE' COMMENT '最小价差模式: NONE, FIXED, AUTO',
|
||||
ADD COLUMN min_spread_value DECIMAL(20, 8) NULL COMMENT '最小价差数值(FIXED 时必填;AUTO 时可存计算值)';
|
||||
@@ -16,6 +16,8 @@ notification.order.time=Time
|
||||
notification.order.error_info=Error Information
|
||||
notification.order.unknown_account=Unknown Account
|
||||
notification.order.calculate_failed=Calculation Failed
|
||||
notification.tail.order.success=Tail Session Order Success
|
||||
notification.tail.strategy=Strategy
|
||||
notification.redeem.success=Position Redeemed Successfully
|
||||
notification.redeem.info=Redeem Information
|
||||
notification.redeem.transaction_hash=Transaction Hash
|
||||
@@ -255,3 +257,68 @@ error.server.order_tracking_process_failed=Failed to process order tracking
|
||||
error.server.order_tracking_buy_failed=Failed to process buy order
|
||||
error.server.order_tracking_sell_failed=Failed to process sell order
|
||||
error.server.order_tracking_match_failed=Order matching failed
|
||||
|
||||
# Backtest service errors
|
||||
error.backtest.task_not_found=Backtest task not found
|
||||
error.backtest.leader_not_found=Leader not found
|
||||
error.backtest.days_invalid=Backtest days must be between 1-15 days
|
||||
error.backtest.initial_balance_invalid=Invalid initial balance
|
||||
error.backtest.task_running=Backtest task is running, cannot delete
|
||||
error.backtest.task_not_completed=Re-run is only supported for completed backtest tasks
|
||||
error.server.backtest_create_failed=Failed to create backtest task
|
||||
error.server.backtest_update_failed=Failed to update backtest task
|
||||
error.server.backtest_delete_failed=Failed to delete backtest task
|
||||
error.server.backtest_list_fetch_failed=Failed to fetch backtest list
|
||||
error.server.backtest_detail_fetch_failed=Failed to fetch backtest detail
|
||||
error.server.backtest_trades_fetch_failed=Failed to fetch backtest trades
|
||||
error.server.backtest_execute_failed=Failed to execute backtest
|
||||
error.server.backtest_historical_data_fetch_failed=Failed to fetch historical data
|
||||
error.server.backtest_stop_failed=Failed to stop backtest task
|
||||
error.server.backtest_retry_failed=Failed to retry backtest task
|
||||
error.server.backtest_rerun_failed=Failed to re-run backtest with same config
|
||||
|
||||
# Crypto tail strategy
|
||||
error.crypto_tail_strategy_not_found=Crypto tail strategy not found
|
||||
error.crypto_tail_strategy_window_invalid=Window start must not be greater than window end
|
||||
error.crypto_tail_strategy_window_exceed=Time window must not exceed period length
|
||||
error.crypto_tail_strategy_interval_invalid=Interval must be 300 or 900 seconds
|
||||
error.crypto_tail_strategy_amount_mode_invalid=Amount mode must be RATIO or FIXED
|
||||
error.server.crypto_tail_strategy_create_failed=Failed to create crypto tail strategy
|
||||
error.server.crypto_tail_strategy_update_failed=Failed to update crypto tail strategy
|
||||
error.server.crypto_tail_strategy_delete_failed=Failed to delete crypto tail strategy
|
||||
error.server.crypto_tail_strategy_list_fetch_failed=Failed to fetch crypto tail strategy list
|
||||
error.server.crypto_tail_strategy_triggers_fetch_failed=Failed to fetch trigger records
|
||||
# Backtest Management
|
||||
backtest.title=Backtest Management
|
||||
backtest.create_task=Create Backtest
|
||||
backtest.task_name=Task Name
|
||||
backtest.leader=Leader
|
||||
backtest.initial_balance=Initial Balance
|
||||
backtest.backtest_days=Backtest Days
|
||||
backtest.profit_amount=Profit Amount
|
||||
backtest.profit_rate=Profit Rate
|
||||
backtest.backtest_days_range=Backtest Days Range (1-15 days)
|
||||
backtest.total_trades=Total Trades
|
||||
backtest.buy_trades=Buy Trades
|
||||
backtest.sell_trades=Sell Trades
|
||||
backtest.win_trades=Win Trades
|
||||
backtest.loss_trades=Loss Trades
|
||||
backtest.win_rate=Win Rate
|
||||
backtest.max_profit=Max Profit
|
||||
backtest.max_loss=Max Loss
|
||||
backtest.max_drawdown=Max Drawdown
|
||||
backtest.avg_holding_time=Avg Holding Time
|
||||
|
||||
# Backtest Status
|
||||
backtest.status.pending=Pending
|
||||
backtest.status.running=Running
|
||||
backtest.status.completed=Completed
|
||||
backtest.status.stopped=Stopped
|
||||
backtest.status.failed=Failed
|
||||
|
||||
# Backtest Config
|
||||
backtest.copy_mode.ratio=Ratio Mode
|
||||
backtest.copy_mode.fixed=Fixed Amount
|
||||
backtest.price_tolerance=Price Tolerance
|
||||
backtest.delay_seconds=Delay Seconds
|
||||
backtest.support_sell=Support Sell
|
||||
|
||||
@@ -16,6 +16,8 @@ notification.order.time=时间
|
||||
notification.order.error_info=错误信息
|
||||
notification.order.unknown_account=未知账户
|
||||
notification.order.calculate_failed=计算失败
|
||||
notification.tail.order.success=尾盘策略下单成功
|
||||
notification.tail.strategy=策略
|
||||
notification.redeem.success=仓位赎回成功
|
||||
notification.redeem.info=赎回信息
|
||||
notification.redeem.transaction_hash=交易哈希
|
||||
@@ -255,3 +257,74 @@ error.server.order_tracking_process_failed=处理订单跟踪失败
|
||||
error.server.order_tracking_buy_failed=处理买入订单失败
|
||||
error.server.order_tracking_sell_failed=处理卖出订单失败
|
||||
error.server.order_tracking_match_failed=订单匹配失败
|
||||
|
||||
# 回测服务错误
|
||||
error.backtest.task_not_found=回测任务不存在
|
||||
error.backtest.leader_not_found=Leader不存在
|
||||
error.backtest.days_invalid=回测天数必须在 1-15 天之间
|
||||
error.backtest.initial_balance_invalid=初始金额无效
|
||||
error.backtest.task_running=回测任务正在运行,无法删除
|
||||
error.backtest.task_not_completed=仅支持对已完成的回测任务重新测试
|
||||
error.server.backtest_create_failed=创建回测任务失败
|
||||
error.server.backtest_update_failed=更新回测任务失败
|
||||
error.server.backtest_delete_failed=删除回测任务失败
|
||||
error.server.backtest_list_fetch_failed=查询回测列表失败
|
||||
error.server.backtest_detail_fetch_failed=查询回测详情失败
|
||||
error.server.backtest_trades_fetch_failed=查询回测交易记录失败
|
||||
error.server.backtest_execute_failed=回测执行失败
|
||||
error.server.backtest_historical_data_fetch_failed=历史数据获取失败
|
||||
error.server.backtest_stop_failed=停止回测任务失败
|
||||
error.server.backtest_retry_failed=重试回测任务失败
|
||||
error.server.backtest_rerun_failed=按配置重新测试失败
|
||||
|
||||
# 尾盘策略
|
||||
error.crypto_tail_strategy_not_found=尾盘策略不存在
|
||||
error.crypto_tail_strategy_window_invalid=时间区间开始不能大于结束
|
||||
error.crypto_tail_strategy_window_exceed=时间区间不能超过周期长度
|
||||
error.crypto_tail_strategy_interval_invalid=周期仅支持 300 或 900 秒
|
||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式仅支持 RATIO 或 FIXED
|
||||
error.server.crypto_tail_strategy_create_failed=创建尾盘策略失败
|
||||
error.server.crypto_tail_strategy_update_failed=更新尾盘策略失败
|
||||
error.server.crypto_tail_strategy_delete_failed=删除尾盘策略失败
|
||||
error.server.crypto_tail_strategy_list_fetch_failed=查询尾盘策略列表失败
|
||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查询触发记录失败
|
||||
# 回测管理
|
||||
backtest.title=回测管理
|
||||
backtest.create_task=新增回测
|
||||
backtest.task_name=回测名称
|
||||
backtest.leader=Leader
|
||||
backtest.initial_balance=初始金额
|
||||
backtest.backtest_days=回测天数
|
||||
backtest.profit_amount=收益额
|
||||
backtest.profit_rate=收益率
|
||||
backtest.backtest_days_range=回测天数范围(1-15天)
|
||||
backtest.total_trades=总交易笔数
|
||||
backtest.buy_trades=买入笔数
|
||||
backtest.sell_trades=卖出笔数
|
||||
backtest.win_trades=盈利交易笔数
|
||||
backtest.loss_trades=亏损交易笔数
|
||||
backtest.win_rate=胜率
|
||||
backtest.max_profit=最大单笔盈利
|
||||
backtest.max_loss=最大单笔亏损
|
||||
backtest.max_drawdown=最大回撤
|
||||
backtest.avg_holding_time=平均持仓时间
|
||||
|
||||
# 回测状态
|
||||
backtest.status.pending=待执行
|
||||
backtest.status.running=运行中
|
||||
backtest.status.completed=已完成
|
||||
backtest.status.stopped=已停止
|
||||
backtest.status.failed=失败
|
||||
|
||||
# 回测配置
|
||||
backtest.copy_mode.ratio=比例模式
|
||||
backtest.copy_mode.fixed=固定金额
|
||||
backtest.price_tolerance=价格容忍度
|
||||
backtest.delay_seconds=延迟秒数
|
||||
backtest.support_sell=支持卖出
|
||||
|
||||
# 订单跟踪服务错误
|
||||
error.server.order_tracking_process_failed=处理订单跟踪失败
|
||||
error.server.order_tracking_buy_failed=处理买入订单失败
|
||||
error.server.order_tracking_sell_failed=处理卖出订单失败
|
||||
error.server.order_tracking_match_failed=订单匹配失败
|
||||
|
||||
@@ -16,6 +16,8 @@ notification.order.time=時間
|
||||
notification.order.error_info=錯誤信息
|
||||
notification.order.unknown_account=未知賬戶
|
||||
notification.order.calculate_failed=計算失敗
|
||||
notification.tail.order.success=尾盤策略下單成功
|
||||
notification.tail.strategy=策略
|
||||
notification.redeem.success=倉位贖回成功
|
||||
notification.redeem.info=贖回信息
|
||||
notification.redeem.transaction_hash=交易哈希
|
||||
@@ -255,3 +257,68 @@ error.server.order_tracking_process_failed=處理訂單跟蹤失敗
|
||||
error.server.order_tracking_buy_failed=處理買入訂單失敗
|
||||
error.server.order_tracking_sell_failed=處理賣出訂單失敗
|
||||
error.server.order_tracking_match_failed=訂單匹配失敗
|
||||
|
||||
# 回測服務錯誤
|
||||
error.backtest.task_not_found=回測任務不存在
|
||||
error.backtest.leader_not_found=Leader不存在
|
||||
error.backtest.days_invalid=回測天數必須在 1-15 天之間
|
||||
error.backtest.initial_balance_invalid=初始金額無效
|
||||
error.backtest.task_running=回測任務正在運行,無法刪除
|
||||
error.backtest.task_not_completed=僅支援對已完成的回測任務重新測試
|
||||
error.server.backtest_create_failed=創建回測任務失敗
|
||||
error.server.backtest_update_failed=更新回測任務失敗
|
||||
error.server.backtest_delete_failed=刪除回測任務失敗
|
||||
error.server.backtest_list_fetch_failed=查詢回測列表失敗
|
||||
error.server.backtest_detail_fetch_failed=查詢回測詳情失敗
|
||||
error.server.backtest_trades_fetch_failed=查詢回測交易記錄失敗
|
||||
error.server.backtest_execute_failed=回測執行失敗
|
||||
error.server.backtest_historical_data_fetch_failed=歷史數據獲取失敗
|
||||
error.server.backtest_stop_failed=停止回測任務失敗
|
||||
error.server.backtest_retry_failed=重試回測任務失敗
|
||||
error.server.backtest_rerun_failed=依配置重新測試失敗
|
||||
|
||||
# 尾盤策略
|
||||
error.crypto_tail_strategy_not_found=尾盤策略不存在
|
||||
error.crypto_tail_strategy_window_invalid=時間區間開始不能大於結束
|
||||
error.crypto_tail_strategy_window_exceed=時間區間不能超過週期長度
|
||||
error.crypto_tail_strategy_interval_invalid=週期僅支援 300 或 900 秒
|
||||
error.crypto_tail_strategy_amount_mode_invalid=投入方式僅支援 RATIO 或 FIXED
|
||||
error.server.crypto_tail_strategy_create_failed=創建尾盤策略失敗
|
||||
error.server.crypto_tail_strategy_update_failed=更新尾盤策略失敗
|
||||
error.server.crypto_tail_strategy_delete_failed=刪除尾盤策略失敗
|
||||
error.server.crypto_tail_strategy_list_fetch_failed=查詢尾盤策略列表失敗
|
||||
error.server.crypto_tail_strategy_triggers_fetch_failed=查詢觸發記錄失敗
|
||||
# 回測管理
|
||||
backtest.title=回測管理
|
||||
backtest.create_task=新增回測
|
||||
backtest.task_name=回測名稱
|
||||
backtest.leader=Leader
|
||||
backtest.initial_balance=初始金額
|
||||
backtest.backtest_days=回測天數
|
||||
backtest.profit_amount=收益額
|
||||
backtest.profit_rate=收益率
|
||||
backtest.backtest_days_range=回測天數範圍(1-15天)
|
||||
backtest.total_trades=總交易筆數
|
||||
backtest.buy_trades=買入筆數
|
||||
backtest.sell_trades=賣出筆數
|
||||
backtest.win_trades=盈利交易筆數
|
||||
backtest.loss_trades=虧損交易筆數
|
||||
backtest.win_rate=勝率
|
||||
backtest.max_profit=最大單筆盈利
|
||||
backtest.max_loss=最大單筆虧損
|
||||
backtest.max_drawdown=最大回撤
|
||||
backtest.avg_holding_time=平均持倉時間
|
||||
|
||||
# 回測狀態
|
||||
backtest.status.pending=待執行
|
||||
backtest.status.running=運行中
|
||||
backtest.status.completed=已完成
|
||||
backtest.status.stopped=已停止
|
||||
backtest.status.failed=失敗
|
||||
|
||||
# 回測配置
|
||||
backtest.copy_mode.ratio=比例模式
|
||||
backtest.copy_mode.fixed=固定金額
|
||||
backtest.price_tolerance=價格容忍度
|
||||
backtest.delay_seconds=延遲秒數
|
||||
backtest.support_sell=支持賣出
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# PolyHermes 一键部署脚本使用说明
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- **可在任意目录运行** - 无需下载项目源码
|
||||
- **仅使用线上镜像** - 从 Docker Hub 拉取官方镜像
|
||||
- **自动下载配置** - 从 GitHub 下载最新的 `docker-compose.prod.yml`
|
||||
- **交互式配置** - 友好的问答式配置向导
|
||||
- **自动生成密钥** - 所有敏感配置回车自动生成安全随机值
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 一键安装(推荐)
|
||||
|
||||
**使用 curl(推荐):**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh -o deploy.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
**使用 wget:**
|
||||
```bash
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -O deploy.sh https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh && chmod +x deploy.sh && ./deploy.sh
|
||||
```
|
||||
|
||||
这个命令会自动:
|
||||
- 📁 创建专用工作目录 `~/polyhermes`
|
||||
- 📥 下载部署脚本
|
||||
- ✅ 自动检查 Docker 环境
|
||||
- ⚙️ 交互式配置所有参数(支持回车使用默认值)
|
||||
- 🔐 自动生成安全的随机密钥
|
||||
- 🚀 自动下载最新镜像并部署
|
||||
|
||||
**或者直接通过管道运行(不保存文件):**
|
||||
```bash
|
||||
# curl 方式
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && curl -fsSL https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
|
||||
|
||||
# wget 方式
|
||||
mkdir -p ~/polyhermes && cd ~/polyhermes && wget -qO- https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh | bash
|
||||
```
|
||||
|
||||
### 方式一:直接下载脚本运行
|
||||
|
||||
```bash
|
||||
# 下载脚本
|
||||
curl -O https://raw.githubusercontent.com/WrBug/PolyHermes/main/deploy-interactive.sh
|
||||
|
||||
# 添加执行权限
|
||||
chmod +x deploy-interactive.sh
|
||||
|
||||
# 运行
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
### 方式二:在项目目录中运行
|
||||
|
||||
```bash
|
||||
git clone https://github.com/WrBug/PolyHermes.git
|
||||
cd PolyHermes
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
## 📝 使用流程
|
||||
|
||||
运行脚本后,会引导你完成以下步骤:
|
||||
|
||||
```
|
||||
步骤 1: 环境检查 → 检查 Docker/Docker Compose
|
||||
步骤 2: 配置收集 → 交互式输入配置(可全部回车使用默认)
|
||||
步骤 3: 获取部署配置 → 从 GitHub 下载 docker-compose.prod.yml
|
||||
步骤 4: 生成环境变量文件 → 自动生成 .env
|
||||
步骤 5: 拉取 Docker 镜像 → 从 Docker Hub 拉取最新镜像
|
||||
步骤 6: 部署服务 → 启动容器
|
||||
步骤 7: 健康检查 → 验证服务是否正常运行
|
||||
```
|
||||
|
||||
## ⚡ 最简单的使用方式
|
||||
|
||||
**所有配置项直接回车使用默认值**,脚本会自动:
|
||||
- 使用端口 80(应用)和 3307(MySQL)
|
||||
- 生成 32 字符的数据库密码
|
||||
- 生成 128 字符的 JWT 密钥
|
||||
- 生成 64 字符的管理员重置密钥
|
||||
- 生成 64 字符的加密密钥
|
||||
- 配置合理的日志级别
|
||||
|
||||
### 交互示例
|
||||
|
||||
脚本会逐项提示你输入配置,**直接按回车即可跳过使用默认值**:
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
步骤 2: 配置收集
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成
|
||||
|
||||
⚠ 密钥配置:回车将自动生成安全的随机密钥
|
||||
⚠ 其他配置:回车将使用括号中的默认值
|
||||
|
||||
【基础配置】
|
||||
将配置:服务器端口、MySQL端口、时区
|
||||
➤ 服务器端口 [默认: 80]: ⏎
|
||||
➤ MySQL 端口(外部访问) [默认: 3307]: ⏎
|
||||
➤ 时区 [默认: Asia/Shanghai]: ⏎
|
||||
|
||||
【数据库配置】
|
||||
将配置:数据库用户名、数据库密码
|
||||
➤ 数据库用户名 [默认: root]: ⏎
|
||||
➤ 数据库密码 [回车自动生成]: ⏎
|
||||
[✓] 已自动生成数据库密码(32字符)
|
||||
|
||||
【安全配置】
|
||||
将配置:JWT密钥、管理员密码重置密钥、数据加密密钥
|
||||
➤ JWT 密钥 [回车自动生成]: ⏎
|
||||
[✓] 已自动生成 JWT 密钥(128字符)
|
||||
➤ 管理员密码重置密钥 [回车自动生成]: ⏎
|
||||
[✓] 已自动生成管理员重置密钥(64字符)
|
||||
➤ 加密密钥(用于加密 API Key) [回车自动生成]: ⏎
|
||||
[✓] 已自动生成加密密钥(64字符)
|
||||
|
||||
【日志配置】
|
||||
将配置:Root日志级别、应用日志级别
|
||||
可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF
|
||||
➤ Root 日志级别(第三方库) [默认: WARN]: ⏎
|
||||
➤ 应用日志级别 [默认: INFO]: ⏎
|
||||
|
||||
【其他配置】
|
||||
将配置:运行环境、自动更新策略、GitHub仓库
|
||||
➤ Spring Profile [默认: prod]: ⏎
|
||||
➤ 允许预发布版本更新 (true/false) [默认: false]: ⏎
|
||||
➤ GitHub 仓库 [默认: WrBug/PolyHermes]: ⏎
|
||||
```
|
||||
|
||||
## 🔧 脚本生成的文件
|
||||
|
||||
脚本运行后会在当前目录生成:
|
||||
|
||||
1. **docker-compose.prod.yml** - 从 GitHub 下载的 Docker Compose 配置文件(始终保持最新)
|
||||
2. **.env** - 根据你的配置自动生成的环境变量文件
|
||||
|
||||
这两个文件包含了运行 PolyHermes 所需的全部配置。
|
||||
|
||||
## 🌐 部署后管理
|
||||
|
||||
### 快速更新(推荐)
|
||||
|
||||
如果已有配置文件,再次运行脚本时会自动检测并询问:
|
||||
|
||||
```bash
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
```
|
||||
【检测到现有配置】
|
||||
发现已存在的 .env 配置文件
|
||||
|
||||
是否使用现有配置直接更新镜像?[Y/n]: ⏎
|
||||
```
|
||||
|
||||
- **回车或输入 Y**:使用现有配置,直接拉取最新镜像并更新
|
||||
- **输入 N**:重新配置(会备份现有配置)
|
||||
|
||||
### 手动管理命令
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# 查看日志
|
||||
docker compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# 重启服务
|
||||
docker compose -f docker-compose.prod.yml restart
|
||||
|
||||
# 停止服务
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
|
||||
# 更新到最新版本
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## 🔐 安全建议
|
||||
|
||||
- **保护 .env 文件**:其中包含敏感信息,切勿提交到版本控制
|
||||
- **定期备份数据库**:数据存储在 Docker volume `mysql-data` 中
|
||||
- **生产环境配置 HTTPS**:建议使用 Nginx 或 Caddy 作为反向代理
|
||||
|
||||
## 📞 获取支持
|
||||
|
||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||
- [问题反馈](https://github.com/WrBug/PolyHermes/issues)
|
||||
- [完整部署文档](docs/zh/DEPLOYMENT_GUIDE.md)
|
||||
Executable
+510
@@ -0,0 +1,510 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ========================================
|
||||
# PolyHermes 交互式一键部署脚本
|
||||
# ========================================
|
||||
# 功能:
|
||||
# - 交互式配置环境变量
|
||||
# - 自动生成安全密钥
|
||||
# - 使用 Docker Hub 线上镜像部署
|
||||
# - 支持配置预检和回滚
|
||||
# ========================================
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 打印函数
|
||||
info() {
|
||||
echo -e "${GREEN}[✓]${NC} $1"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}[⚠]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[✗]${NC} $1"
|
||||
}
|
||||
|
||||
title() {
|
||||
echo -e "${CYAN}${1}${NC}"
|
||||
}
|
||||
|
||||
# 生成随机密钥
|
||||
generate_secret() {
|
||||
local length=${1:-32}
|
||||
if command -v openssl &> /dev/null; then
|
||||
openssl rand -hex $length
|
||||
else
|
||||
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w $((length * 2)) | head -n 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 生成随机端口号(10000-60000之间)
|
||||
generate_random_port() {
|
||||
echo $((10000 + RANDOM % 50001))
|
||||
}
|
||||
|
||||
# 读取用户输入(支持默认值)
|
||||
read_input() {
|
||||
local prompt="$1"
|
||||
local default="$2"
|
||||
local is_secret="$3"
|
||||
local value=""
|
||||
|
||||
# 构建提示信息(不使用颜色,因为 read -p 可能不支持)
|
||||
local prompt_text=""
|
||||
if [ -n "$default" ]; then
|
||||
if [ "$is_secret" = "secret" ]; then
|
||||
prompt_text="${prompt} [回车自动生成]: "
|
||||
else
|
||||
prompt_text="${prompt} [默认: ${default}]: "
|
||||
fi
|
||||
else
|
||||
prompt_text="${prompt}: "
|
||||
fi
|
||||
|
||||
# 使用 read -p 确保提示正确显示
|
||||
read -r -p "$prompt_text" value
|
||||
|
||||
# 如果用户没有输入,使用默认值
|
||||
if [ -z "$value" ]; then
|
||||
if [ "$is_secret" = "secret" ] && [ -z "$default" ]; then
|
||||
# 自动生成密钥
|
||||
case "$prompt" in
|
||||
*JWT*)
|
||||
value=$(generate_secret 64)
|
||||
# 输出到 stderr,避免被捕获到返回值中
|
||||
info "已自动生成 JWT 密钥(128字符)" >&2
|
||||
;;
|
||||
*管理员*|*ADMIN*)
|
||||
value=$(generate_secret 32)
|
||||
info "已自动生成管理员重置密钥(64字符)" >&2
|
||||
;;
|
||||
*加密*|*CRYPTO*)
|
||||
value=$(generate_secret 32)
|
||||
info "已自动生成加密密钥(64字符)" >&2
|
||||
;;
|
||||
*数据库密码*|*DB_PASSWORD*)
|
||||
value=$(generate_secret 16)
|
||||
info "已自动生成数据库密码(32字符)" >&2
|
||||
;;
|
||||
*)
|
||||
value="$default"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
value="$default"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
# 检查 Docker 环境
|
||||
check_docker() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 1: 环境检查"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# 检查 Docker
|
||||
if ! command -v docker &> /dev/null; then
|
||||
error "Docker 未安装"
|
||||
echo ""
|
||||
info "请先安装 Docker:"
|
||||
info " macOS: brew install docker"
|
||||
info " Ubuntu/Debian: apt-get install docker.io"
|
||||
info " CentOS/RHEL: yum install docker"
|
||||
exit 1
|
||||
fi
|
||||
info "Docker 已安装: $(docker --version | head -1)"
|
||||
|
||||
# 检查 Docker Compose
|
||||
if docker compose version &> /dev/null 2>&1; then
|
||||
info "Docker Compose 已安装: $(docker compose version)"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
info "Docker Compose 已安装: $(docker-compose --version)"
|
||||
else
|
||||
error "Docker Compose 未安装"
|
||||
echo ""
|
||||
info "请先安装 Docker Compose:"
|
||||
info " https://docs.docker.com/compose/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Docker 守护进程
|
||||
if ! docker info &> /dev/null; then
|
||||
error "Docker 守护进程未运行"
|
||||
info "请启动 Docker 服务:"
|
||||
info " macOS: 打开 Docker Desktop"
|
||||
info " Linux: systemctl start docker"
|
||||
exit 1
|
||||
fi
|
||||
info "Docker 守护进程运行正常"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 交互式配置收集
|
||||
collect_configuration() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 2: 配置收集"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
info "💡 所有配置项均为可选,直接按回车即可使用默认值或自动生成"
|
||||
echo ""
|
||||
warn "密钥配置:回车将自动生成安全的随机密钥"
|
||||
warn "其他配置:回车将使用括号中的默认值"
|
||||
echo ""
|
||||
|
||||
# 基础配置
|
||||
title "【基础配置】"
|
||||
echo -e "${CYAN}将配置:服务器端口、MySQL端口、时区${NC}"
|
||||
# 生成随机端口作为默认值
|
||||
DEFAULT_PORT=$(generate_random_port)
|
||||
SERVER_PORT=$(read_input "➤ 服务器端口" "$DEFAULT_PORT")
|
||||
MYSQL_PORT=$(read_input "➤ MySQL 端口(外部访问)" "3307")
|
||||
TZ=$(read_input "➤ 时区" "Asia/Shanghai")
|
||||
echo ""
|
||||
|
||||
# 数据库配置
|
||||
title "【数据库配置】"
|
||||
echo -e "${CYAN}将配置:数据库用户名、数据库密码${NC}"
|
||||
echo -e "${YELLOW}💡 提示:密码留空将自动生成 32 字符的安全随机密码${NC}"
|
||||
DB_USERNAME=$(read_input "➤ 数据库用户名" "root")
|
||||
DB_PASSWORD=$(read_input "➤ 数据库密码" "" "secret")
|
||||
echo ""
|
||||
|
||||
# 安全配置
|
||||
title "【安全配置】"
|
||||
echo -e "${CYAN}将配置:JWT密钥、管理员密码重置密钥、数据加密密钥${NC}"
|
||||
echo -e "${YELLOW}💡 提示:留空将自动生成高强度随机密钥(推荐)${NC}"
|
||||
JWT_SECRET=$(read_input "➤ JWT 密钥" "" "secret")
|
||||
ADMIN_RESET_PASSWORD_KEY=$(read_input "➤ 管理员密码重置密钥" "" "secret")
|
||||
CRYPTO_SECRET_KEY=$(read_input "➤ 加密密钥(用于加密 API Key)" "" "secret")
|
||||
echo ""
|
||||
|
||||
# 日志配置
|
||||
title "【日志配置】"
|
||||
echo -e "${CYAN}将配置:Root日志级别、应用日志级别${NC}"
|
||||
echo -e "${YELLOW}可选级别: TRACE, DEBUG, INFO, WARN, ERROR, OFF${NC}"
|
||||
LOG_LEVEL_ROOT=$(read_input "➤ Root 日志级别(第三方库)" "WARN")
|
||||
LOG_LEVEL_APP=$(read_input "➤ 应用日志级别" "INFO")
|
||||
echo ""
|
||||
|
||||
# 自动设置不需要用户输入的配置
|
||||
SPRING_PROFILES_ACTIVE="prod"
|
||||
ALLOW_PRERELEASE="false"
|
||||
GITHUB_REPO="WrBug/PolyHermes"
|
||||
}
|
||||
|
||||
# 下载 docker-compose.prod.yml(如果不存在)
|
||||
download_docker_compose_file() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 3: 获取部署配置"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
if [ -f "docker-compose.prod.yml" ]; then
|
||||
info "检测到现有 docker-compose.prod.yml,跳过下载"
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "正在从 GitHub 下载 docker-compose.prod.yml..."
|
||||
|
||||
# GitHub raw 文件链接
|
||||
local compose_url="https://raw.githubusercontent.com/WrBug/PolyHermes/main/docker-compose.prod.yml"
|
||||
|
||||
# 尝试下载
|
||||
if curl -fsSL "$compose_url" -o docker-compose.prod.yml; then
|
||||
info "docker-compose.prod.yml 下载成功"
|
||||
else
|
||||
error "docker-compose.prod.yml 下载失败"
|
||||
warn "请检查网络连接或手动下载:"
|
||||
warn " $compose_url"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 生成 .env 文件
|
||||
generate_env_file() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 4: 生成环境变量文件"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# 备份现有 .env 文件
|
||||
if [ -f ".env" ]; then
|
||||
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp .env "$BACKUP_FILE"
|
||||
warn "已备份现有配置文件到: $BACKUP_FILE"
|
||||
fi
|
||||
|
||||
# 生成新的 .env 文件
|
||||
cat > .env <<EOF
|
||||
# ========================================
|
||||
# PolyHermes 生产环境配置
|
||||
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
# ========================================
|
||||
|
||||
# ============================================
|
||||
# 基础配置
|
||||
# ============================================
|
||||
TZ=${TZ}
|
||||
SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE}
|
||||
SERVER_PORT=${SERVER_PORT}
|
||||
MYSQL_PORT=${MYSQL_PORT}
|
||||
|
||||
# ============================================
|
||||
# 数据库配置
|
||||
# ============================================
|
||||
DB_URL=jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true
|
||||
DB_USERNAME=${DB_USERNAME}
|
||||
DB_PASSWORD=${DB_PASSWORD}
|
||||
|
||||
# ============================================
|
||||
# 安全配置(请妥善保管)
|
||||
# ============================================
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY}
|
||||
CRYPTO_SECRET_KEY=${CRYPTO_SECRET_KEY}
|
||||
|
||||
# ============================================
|
||||
# 日志配置
|
||||
# ============================================
|
||||
LOG_LEVEL_ROOT=${LOG_LEVEL_ROOT}
|
||||
LOG_LEVEL_APP=${LOG_LEVEL_APP}
|
||||
|
||||
# ============================================
|
||||
# 其他配置
|
||||
# ============================================
|
||||
ALLOW_PRERELEASE=${ALLOW_PRERELEASE}
|
||||
GITHUB_REPO=${GITHUB_REPO}
|
||||
EOF
|
||||
|
||||
info "配置文件已生成: .env"
|
||||
echo ""
|
||||
|
||||
# 显示配置摘要
|
||||
title "【配置摘要】"
|
||||
echo " 服务器端口: ${SERVER_PORT}"
|
||||
echo " MySQL 端口: ${MYSQL_PORT}"
|
||||
echo " 时区: ${TZ}"
|
||||
echo " 数据库用户: ${DB_USERNAME}"
|
||||
echo " 数据库密码: ${DB_PASSWORD:0:8}... (已隐藏)"
|
||||
echo " JWT 密钥: ${JWT_SECRET:0:16}... (已隐藏)"
|
||||
echo " 管理员重置密钥: ${ADMIN_RESET_PASSWORD_KEY:0:16}... (已隐藏)"
|
||||
echo " 加密密钥: ${CRYPTO_SECRET_KEY:0:16}... (已隐藏)"
|
||||
echo " 日志级别: Root=${LOG_LEVEL_ROOT}, App=${LOG_LEVEL_APP}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 拉取镜像
|
||||
pull_images() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 5: 拉取 Docker 镜像"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
info "正在从 Docker Hub 拉取最新镜像..."
|
||||
|
||||
# 拉取应用镜像
|
||||
if docker pull wrbug/polyhermes:latest; then
|
||||
info "应用镜像拉取成功: wrbug/polyhermes:latest"
|
||||
else
|
||||
error "应用镜像拉取失败"
|
||||
warn "可能的原因:"
|
||||
warn " 1. 网络连接问题"
|
||||
warn " 2. Docker Hub 服务异常"
|
||||
warn " 3. 镜像不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 拉取 MySQL 镜像
|
||||
if docker pull mysql:8.2; then
|
||||
info "MySQL 镜像拉取成功: mysql:8.2"
|
||||
else
|
||||
warn "MySQL 镜像拉取失败,将在启动时自动下载"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 部署服务
|
||||
deploy_services() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 6: 部署服务"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# 停止现有服务
|
||||
if docker compose -f docker-compose.prod.yml ps -q 2>/dev/null | grep -q .; then
|
||||
warn "检测到正在运行的服务,正在停止..."
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
info "已停止现有服务"
|
||||
fi
|
||||
|
||||
# 启动服务
|
||||
info "正在启动服务..."
|
||||
if docker compose -f docker-compose.prod.yml up -d; then
|
||||
info "服务启动成功"
|
||||
else
|
||||
error "服务启动失败"
|
||||
error "请检查日志: docker compose -f docker-compose.prod.yml logs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
health_check() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 步骤 7: 健康检查"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
info "等待服务启动(最多等待 60 秒)..."
|
||||
|
||||
local max_attempts=12
|
||||
local attempt=0
|
||||
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
# 检查容器状态
|
||||
if docker compose -f docker-compose.prod.yml ps | grep -q "Up"; then
|
||||
info "容器运行正常"
|
||||
|
||||
# 检查应用是否响应
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://localhost:${SERVER_PORT} | grep -q "200\|302\|401"; then
|
||||
info "应用响应正常"
|
||||
echo ""
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -n "."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo ""
|
||||
warn "健康检查超时,请手动检查服务状态"
|
||||
warn "查看日志: docker compose -f docker-compose.prod.yml logs -f"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 显示部署信息
|
||||
show_deployment_info() {
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
title " 部署完成!"
|
||||
title "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
info "访问地址: ${GREEN}http://localhost:${SERVER_PORT}${NC}"
|
||||
echo ""
|
||||
|
||||
title "【常用命令】"
|
||||
echo -e " 查看服务状态: ${CYAN}docker compose -f docker-compose.prod.yml ps${NC}"
|
||||
echo -e " 查看日志: ${CYAN}docker compose -f docker-compose.prod.yml logs -f${NC}"
|
||||
echo -e " 停止服务: ${CYAN}docker compose -f docker-compose.prod.yml down${NC}"
|
||||
echo -e " 重启服务: ${CYAN}docker compose -f docker-compose.prod.yml restart${NC}"
|
||||
echo -e " 更新镜像: ${CYAN}docker pull wrbug/polyhermes:latest && docker compose -f docker-compose.prod.yml up -d${NC}"
|
||||
echo ""
|
||||
|
||||
title "【数据库连接信息】"
|
||||
echo -e " 主机: ${CYAN}localhost${NC}"
|
||||
echo -e " 端口: ${CYAN}${MYSQL_PORT}${NC}"
|
||||
echo -e " 数据库: ${CYAN}polyhermes${NC}"
|
||||
echo -e " 用户名: ${CYAN}${DB_USERNAME}${NC}"
|
||||
echo -e " 密码: ${CYAN}${DB_PASSWORD}${NC}"
|
||||
echo ""
|
||||
|
||||
title "【管理员重置密钥】"
|
||||
echo -e " 重置密钥: ${CYAN}${ADMIN_RESET_PASSWORD_KEY}${NC}"
|
||||
echo -e " ${YELLOW}💡 此密钥用于重置管理员密码,请妥善保管${NC}"
|
||||
echo ""
|
||||
|
||||
warn "重要提示:"
|
||||
warn " 1. 请妥善保管 .env 文件,勿提交到版本控制系统"
|
||||
warn " 2. 定期备份数据库数据(位于 Docker volume: polyhermes_mysql-data)"
|
||||
warn " 3. 生产环境建议配置反向代理(如 Nginx)并启用 HTTPS"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
clear
|
||||
|
||||
echo ""
|
||||
title "========================================="
|
||||
title " PolyHermes 交互式一键部署脚本 "
|
||||
title "========================================="
|
||||
echo ""
|
||||
|
||||
# 执行部署流程
|
||||
check_docker
|
||||
|
||||
# 检查是否已存在 .env 文件
|
||||
if [ -f ".env" ]; then
|
||||
echo ""
|
||||
title "【检测到现有配置】"
|
||||
info "发现已存在的 .env 配置文件"
|
||||
echo ""
|
||||
echo -ne "${YELLOW}是否使用现有配置直接更新镜像?[Y/n]: ${NC}"
|
||||
read -r use_existing
|
||||
use_existing=${use_existing:-Y}
|
||||
|
||||
if [[ "$use_existing" =~ ^[Yy]$ ]]; then
|
||||
info "将使用现有配置,跳过配置步骤"
|
||||
echo ""
|
||||
# 从现有 .env 文件读取必要的变量
|
||||
source .env 2>/dev/null || true
|
||||
else
|
||||
warn "将重新配置,现有配置将被备份"
|
||||
echo ""
|
||||
collect_configuration
|
||||
fi
|
||||
else
|
||||
collect_configuration
|
||||
fi
|
||||
|
||||
download_docker_compose_file
|
||||
|
||||
# 只有在重新配置时才生成新的 .env 文件
|
||||
if [[ ! "$use_existing" =~ ^[Yy]$ ]] || [ ! -f ".env" ]; then
|
||||
generate_env_file
|
||||
fi
|
||||
|
||||
# 确认部署
|
||||
echo ""
|
||||
title "【确认部署】"
|
||||
echo -ne "${YELLOW}是否开始部署?[Y/n](回车默认为是): ${NC}"
|
||||
read -r confirm
|
||||
|
||||
# 默认为 Y,只有明确输入 n/N 才取消
|
||||
confirm=${confirm:-Y}
|
||||
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
||||
warn "部署已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
pull_images
|
||||
deploy_services
|
||||
health_check
|
||||
show_deployment_info
|
||||
|
||||
info "部署流程已完成!"
|
||||
}
|
||||
|
||||
# 捕获 Ctrl+C
|
||||
trap 'echo ""; warn "部署已中断"; exit 1' INT
|
||||
|
||||
# 运行主函数
|
||||
main "$@"
|
||||
@@ -161,10 +161,15 @@ deploy() {
|
||||
# 注意:这里需要手动修改 docker-compose.yml,或者使用环境变量
|
||||
warn "请确保 docker-compose.yml 中已配置使用 image: wrbug/polyhermes:latest"
|
||||
else
|
||||
# 获取当前分支名作为版本号
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
# 如果分支名包含 /,替换为 -(Docker tag 不支持 /)
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
# 版本号:优先环境变量 DOCKER_VERSION,其次 .env 中的 DOCKER_VERSION,否则用当前分支名
|
||||
if [ -z "${DOCKER_VERSION}" ] && [ -f ".env" ]; then
|
||||
DOCKER_VERSION=$(grep "^DOCKER_VERSION=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r')
|
||||
fi
|
||||
if [ -z "${DOCKER_VERSION}" ]; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
fi
|
||||
export DOCKER_VERSION
|
||||
|
||||
info "构建 Docker 镜像(本地构建,版本号: ${DOCKER_VERSION})..."
|
||||
|
||||
@@ -216,9 +221,14 @@ main() {
|
||||
info "访问地址: http://localhost:${SERVER_PORT:-80}"
|
||||
echo ""
|
||||
if [ "$USE_DOCKER_HUB" != "true" ]; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
info "提示:本地构建的版本号为当前分支名: ${DOCKER_VERSION}"
|
||||
if [ -z "${DOCKER_VERSION}" ] && [ -f ".env" ]; then
|
||||
DOCKER_VERSION=$(grep "^DOCKER_VERSION=" .env 2>/dev/null | cut -d'=' -f2- | sed 's/^["'\'']//;s/["'\'']$//' | tr -d '\r')
|
||||
fi
|
||||
if [ -z "${DOCKER_VERSION}" ]; then
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "dev")
|
||||
DOCKER_VERSION=$(echo "$CURRENT_BRANCH" | tr '/' '-')
|
||||
fi
|
||||
info "提示:本地构建的版本号: ${DOCKER_VERSION}(可在 .env 或环境变量中设置 DOCKER_VERSION)"
|
||||
info "生产环境推荐使用 Docker Hub 镜像:"
|
||||
info " ./deploy.sh --use-docker-hub"
|
||||
info " 或修改 docker-compose.yml 使用 image: wrbug/polyhermes:latest"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# AUTO 最小价差:100%→50% 动态系数方案
|
||||
|
||||
## 现状
|
||||
|
||||
- **BinanceKlineAutoSpreadService**:拉取历史 K 线 → IQR 剔除异常值 → 求平均得到「基础价差」→ **固定 ×0.7** 后缓存。
|
||||
- 预加载(周期开始时):`computeAndCache()` 计算并缓存的是 **已乘 0.7** 的值。
|
||||
- 触发时:`getAutoMinSpread()` 直接返回缓存值,等价于始终用 **70%** 的系数。
|
||||
|
||||
问题:70% 固定,无法随周期内时间变化放宽或收紧。
|
||||
|
||||
---
|
||||
|
||||
## 目标
|
||||
|
||||
1. **预加载提供 100% 数值**:缓存里存「基础价差」(IQR 平均),不再乘 0.7,即预加载 = 100% 基准。
|
||||
2. **系数随区间时间点动态递减**:从 **100%** 线性递减到 **50%**,根据「当前时间在区间内的进度」计算。
|
||||
|
||||
---
|
||||
|
||||
## 方案一:按「触发窗口」进度(推荐)
|
||||
|
||||
**区间**:策略的触发窗口 `[periodStartUnix + windowStartSeconds, periodStartUnix + windowEndSeconds]`。
|
||||
|
||||
- 窗口起始:系数 = **100%**(最严,价差要求最高)。
|
||||
- 窗口内时间越靠后,系数越小;窗口结束:系数 = **50%**(最松,更容易触发)。
|
||||
|
||||
公式(**progress 按毫秒计算**,保证精度):
|
||||
|
||||
```
|
||||
windowStartMs = (periodStartUnix + windowStartSeconds) * 1000
|
||||
windowEndMs = (periodStartUnix + windowEndSeconds) * 1000
|
||||
windowLenMs = windowEndMs - windowStartMs
|
||||
nowMs = System.currentTimeMillis()
|
||||
|
||||
progress = (nowMs - windowStartMs) / windowLenMs
|
||||
progress = clamp(progress, 0, 1)
|
||||
|
||||
// 比例系数 = progress × (100% - 50%),即已「消耗」的系数降幅
|
||||
// 真正系数 = 100% - 比例系数
|
||||
coefficient = 1.0 - progress × (1.0 - 0.5) = 1.0 - 0.5 × progress
|
||||
|
||||
effectiveMinSpread = baseSpread × coefficient
|
||||
```
|
||||
|
||||
**计算示例**(时间区间 14分0秒~15分0秒,窗口 60 秒 = 60000 ms):
|
||||
|
||||
| 时刻 | 进入窗口的毫秒数 | progress(按毫秒) | 比例系数 | 真正系数 |
|
||||
|------------|------------------|--------------------|--------------------|------------|
|
||||
| 14:00 | 0 | 0/60000 = 0% | 0% × 50% = 0% | 100% |
|
||||
| 14:15 | 15000 | 15000/60000 = 25% | 25% × 50% = 12.5% | **87.5%** |
|
||||
| 14:30 | 30000 | 30000/60000 = 50% | 50% × 50% = 25% | 75% |
|
||||
| 15:00 | 60000 | 60000/60000 = 100% | 100% × 50% = 50% | 50% |
|
||||
|
||||
即:在 14分15秒 时,progress = 15000ms / 60000ms = 25%,比例系数 = 12.5%,真正系数 = **87.5%**。实现时统一用毫秒计算 progress,避免秒级舍入误差。
|
||||
|
||||
- 需要策略的 `windowStartSeconds`、`windowEndSeconds` 传入计算处;若窗口长度为 0,可退化为系数 = 1.0 或 0.5(需约定)。
|
||||
|
||||
**优点**:与「尾盘只在窗口内触发」一致,时间语义清晰;毫秒级 progress 更精确。
|
||||
**缺点**:`getAutoMinSpread` 需要增加当前时间(毫秒)和窗口参数(或传整个 strategy)。
|
||||
|
||||
---
|
||||
|
||||
## 方案二:按「整周期」进度
|
||||
|
||||
**区间**:整个周期 `[periodStartUnix, periodStartUnix + intervalSeconds]`。**progress 按毫秒计算**。
|
||||
|
||||
```
|
||||
periodStartMs = periodStartUnix * 1000
|
||||
periodEndMs = (periodStartUnix + intervalSeconds) * 1000
|
||||
periodLenMs = intervalSeconds * 1000L
|
||||
nowMs = System.currentTimeMillis()
|
||||
|
||||
progress = (nowMs - periodStartMs) / periodLenMs
|
||||
progress = clamp(progress, 0, 1)
|
||||
|
||||
coefficient = 1.0 - 0.5 * progress
|
||||
effectiveMinSpread = baseSpread × coefficient
|
||||
```
|
||||
|
||||
**优点**:只依赖 `intervalSeconds`、`periodStartUnix`、`nowSeconds`,不依赖窗口配置。
|
||||
**缺点**:若窗口只占周期后半段,周期前半段也会在算系数,语义上不如按窗口精确。
|
||||
|
||||
---
|
||||
|
||||
## 实现要点
|
||||
|
||||
### 1. 缓存 100% 基准值
|
||||
|
||||
- **BinanceKlineAutoSpreadService**:
|
||||
- `computeAndCache()`:缓存 **不乘 0.7** 的 (avgUp, avgDown),即 IQR 平均后的原始值(100% 基准)。
|
||||
- 可保留方法名与入参不变,仅去掉 `autoSpreadCoefficient` 的乘法;或新增 `getBaseSpread()` 语义,内部仍用同一缓存。
|
||||
|
||||
### 2. 动态系数计算位置
|
||||
|
||||
- 系数依赖「当前时间」和「区间定义」,适合在 **触发校验处** 算,而不是在 AutoSpread 服务里写死。
|
||||
- **CryptoTailStrategyExecutionService.passMinSpreadCheck()**:
|
||||
- 当前:`getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)` 得到已乘系数的值。
|
||||
- 改为:
|
||||
- 取「基础价差」:`getAutoMinSpreadBase(intervalSeconds, periodStartUnix, outcomeIndex)` 或由现有缓存返回 100% 值。
|
||||
- 在 `passMinSpreadCheck` 内根据 `strategy.windowStartSeconds/windowEndSeconds` 和 `System.currentTimeMillis()`(毫秒)算 `progress`(按毫秒)→ `coefficient` → `effectiveMinSpread = baseSpread × coefficient`。
|
||||
|
||||
### 3. 接口形态建议
|
||||
|
||||
- **BinanceKlineAutoSpreadService**:
|
||||
- `computeAndCache(interval, periodStartUnix)`:只缓存 100% 基准 (baseUp, baseDown),不再乘 0.7。
|
||||
- `getAutoMinSpreadBase(interval, periodStartUnix, outcomeIndex): BigDecimal?`:仅返回缓存的基础价差;若需兼容旧名,可保留 `getAutoMinSpread` 但增加可选参数 `coefficient`,默认 1.0。
|
||||
- **CryptoTailStrategyExecutionService**:
|
||||
- 在 `passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)` 内:
|
||||
- 取 `baseSpread = getAutoMinSpreadBase(...)`。
|
||||
- 计算 `progress`(按方案一用 windowStart/End,或方案二用 interval)。
|
||||
- `coefficient = 1.0 - 0.5 * progress`,再 `effectiveMinSpread = baseSpread * coefficient` 做比较。
|
||||
|
||||
### 4. 边界与兼容
|
||||
|
||||
- 窗口长度为 0:可约定 `coefficient = 0.5` 或 1.0,避免除零。
|
||||
- 已有策略未配置窗口(全 0):若用方案一,可退化为「整周期」或固定 0.5/1.0」。
|
||||
- 预加载逻辑(如 CryptoTailOrderbookWsService 的 `precomputeAutoMinSpreadForCurrentPeriods`)无需改,仍调用 `computeAndCache`,只是缓存内容变为 100% 基准。
|
||||
|
||||
---
|
||||
|
||||
## 小结
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------------|------|
|
||||
| 预加载 | 缓存 100% 基础价差(去掉固定 0.7) |
|
||||
| 系数范围 | 100% → 50% 线性递减 |
|
||||
| 推荐区间 | 按触发窗口 `windowStartSeconds`~`windowEndSeconds` 计算进度(方案一) |
|
||||
| progress | **按毫秒计算**:`(nowMs - windowStartMs) / windowLenMs`,避免秒级舍入误差 |
|
||||
| 计算位置 | 触发时在 `passMinSpreadCheck` 中算 progress → coefficient → effectiveMinSpread |
|
||||
|
||||
按上述实现后,AUTO 模式即为「预加载提供 100% 数值 + 随区间时间点从 100% 递减到 50%」的动态方案。
|
||||
@@ -0,0 +1,335 @@
|
||||
# PolyHermes 一键部署指南
|
||||
|
||||
本文档介绍如何使用交互式一键部署脚本快速部署 PolyHermes 应用。
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
### 必需软件
|
||||
|
||||
- **Docker**: 版本 20.10 或更高
|
||||
- **Docker Compose**: 版本 2.0 或更高(或 `docker-compose` v1.29+)
|
||||
|
||||
### 环境准备
|
||||
|
||||
```bash
|
||||
# macOS 安装 Docker
|
||||
brew install docker
|
||||
|
||||
# Ubuntu/Debian 安装 Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# CentOS/RHEL 安装 Docker
|
||||
yum install docker-ce docker-ce-cli containerd.io
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 克隆项目(如果尚未克隆)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/WrBug/PolyHermes.git
|
||||
cd PolyHermes
|
||||
```
|
||||
|
||||
### 2. 运行部署脚本
|
||||
|
||||
```bash
|
||||
./deploy-interactive.sh
|
||||
```
|
||||
|
||||
### 3. 按提示配置
|
||||
|
||||
脚本会引导你完成以下配置:
|
||||
|
||||
#### 基础配置
|
||||
- **服务器端口**:应用对外暴露的端口(默认:80)
|
||||
- **MySQL 端口**:数据库外部访问端口(默认:3307,避免与本地 MySQL 冲突)
|
||||
- **时区**:服务器时区(默认:Asia/Shanghai)
|
||||
|
||||
#### 数据库配置
|
||||
- **数据库用户名**:默认为 `root`
|
||||
- **数据库密码**:回车自动生成 32 字符随机密码
|
||||
|
||||
#### 安全配置(关键)
|
||||
- **JWT 密钥**:回车自动生成 128 字符随机密钥
|
||||
- **管理员密码重置密钥**:回车自动生成 64 字符随机密钥
|
||||
- **加密密钥**:用于加密 API Key,回车自动生成 64 字符随机密钥
|
||||
|
||||
#### 日志配置
|
||||
- **Root 日志级别**:第三方库日志级别(默认:WARN)
|
||||
- 可选:`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF`
|
||||
- **应用日志级别**:应用代码日志级别(默认:INFO)
|
||||
|
||||
#### 其他配置
|
||||
- **Spring Profile**:环境配置(默认:prod)
|
||||
- **允许预发布版本**:是否允许自动更新到预发布版本(默认:false)
|
||||
- **GitHub 仓库**:项目仓库地址(默认:WrBug/PolyHermes)
|
||||
|
||||
### 4. 确认并部署
|
||||
|
||||
配置完成后,脚本会:
|
||||
1. 生成 `.env` 配置文件
|
||||
2. 显示配置摘要
|
||||
3. 请求确认部署
|
||||
4. 拉取 Docker Hub 镜像
|
||||
5. 启动服务
|
||||
6. 执行健康检查
|
||||
|
||||
## 📝 配置示例
|
||||
|
||||
### 使用默认值部署(推荐)
|
||||
|
||||
所有配置项直接回车,脚本会自动生成安全的随机密钥:
|
||||
|
||||
```
|
||||
服务器端口 [默认: 80]: ⏎
|
||||
MySQL 端口(外部访问) [默认: 3307]: ⏎
|
||||
时区 [默认: Asia/Shanghai]: ⏎
|
||||
数据库用户名 [默认: root]: ⏎
|
||||
数据库密码 [回车自动生成]: ⏎
|
||||
JWT 密钥 [回车自动生成]: ⏎
|
||||
管理员密码重置密钥 [回车自动生成]: ⏎
|
||||
加密密钥(用于加密 API Key) [回车自动生成]: ⏎
|
||||
Root 日志级别(第三方库) [默认: WARN]: ⏎
|
||||
应用日志级别 [默认: INFO]: ⏎
|
||||
Spring Profile [默认: prod]: ⏎
|
||||
允许预发布版本更新 [默认: false]: ⏎
|
||||
GitHub 仓库 [默认: WrBug/PolyHermes]: ⏎
|
||||
```
|
||||
|
||||
### 自定义端口部署
|
||||
|
||||
如果需要使用不同的端口:
|
||||
|
||||
```
|
||||
服务器端口 [默认: 80]: 8080⏎
|
||||
MySQL 端口(外部访问) [默认: 3307]: 33306⏎
|
||||
```
|
||||
|
||||
### 开发环境部署
|
||||
|
||||
启用 DEBUG 日志:
|
||||
|
||||
```
|
||||
Root 日志级别(第三方库) [默认: WARN]: DEBUG⏎
|
||||
应用日志级别 [默认: INFO]: DEBUG⏎
|
||||
```
|
||||
|
||||
## 🔧 部署后管理
|
||||
|
||||
### 访问应用
|
||||
|
||||
部署完成后,访问:
|
||||
|
||||
```
|
||||
http://localhost:80
|
||||
```
|
||||
|
||||
(或你配置的自定义端口)
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# 查看实时日志
|
||||
docker compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# 仅查看应用日志
|
||||
docker compose -f docker-compose.prod.yml logs -f app
|
||||
|
||||
# 停止服务
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
|
||||
# 重启服务
|
||||
docker compose -f docker-compose.prod.yml restart
|
||||
|
||||
# 更新到最新版本
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 数据库连接
|
||||
|
||||
使用配置的凭据连接到 MySQL:
|
||||
|
||||
```bash
|
||||
mysql -h 127.0.0.1 -P 3307 -u root -p
|
||||
# 输入你在部署时设置的数据库密码
|
||||
```
|
||||
|
||||
或使用图形化工具(如 DBeaver、Navicat):
|
||||
- **主机**: `localhost`
|
||||
- **端口**: `3307`(或你配置的端口)
|
||||
- **数据库**: `polyhermes`
|
||||
- **用户名**: `root`(或你配置的用户名)
|
||||
- **密码**: 部署时设置的密码(可在 `.env` 文件中查看)
|
||||
|
||||
## 🔐 安全最佳实践
|
||||
|
||||
### 保护配置文件
|
||||
|
||||
```bash
|
||||
# 设置 .env 文件权限
|
||||
chmod 600 .env
|
||||
|
||||
# 确保 .env 已添加到 .gitignore
|
||||
echo ".env" >> .gitignore
|
||||
```
|
||||
|
||||
### 定期更换密钥
|
||||
|
||||
生产环境建议定期更换安全密钥:
|
||||
|
||||
```bash
|
||||
# 生成新的 JWT 密钥(128字符)
|
||||
openssl rand -hex 64
|
||||
|
||||
# 生成新的管理员重置密钥(64字符)
|
||||
openssl rand -hex 32
|
||||
|
||||
# 更新 .env 文件后重启服务
|
||||
docker compose -f docker-compose.prod.yml restart
|
||||
```
|
||||
|
||||
### 备份数据库
|
||||
|
||||
```bash
|
||||
# 备份数据库
|
||||
docker exec polyhermes-mysql mysqldump -u root -p polyhermes > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# 恢复数据库
|
||||
docker exec -i polyhermes-mysql mysql -u root -p polyhermes < backup_20260201.sql
|
||||
```
|
||||
|
||||
## 🌐 生产环境部署建议
|
||||
|
||||
### 1. 使用反向代理
|
||||
|
||||
建议使用 Nginx 或 Caddy 作为反向代理:
|
||||
|
||||
```nginx
|
||||
# Nginx 配置示例
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name polyhermes.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 配置防火墙
|
||||
|
||||
```bash
|
||||
# UFW (Ubuntu)
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw enable
|
||||
|
||||
# Firewalld (CentOS)
|
||||
firewall-cmd --add-service=http --permanent
|
||||
firewall-cmd --add-service=https --permanent
|
||||
firewall-cmd --reload
|
||||
```
|
||||
|
||||
### 3. 启用自动更新
|
||||
|
||||
配置定时任务自动检查并更新镜像:
|
||||
|
||||
```bash
|
||||
# 创建更新脚本
|
||||
cat > /opt/polyhermes-update.sh <<'EOF'
|
||||
#!/bin/bash
|
||||
cd /path/to/PolyHermes
|
||||
docker pull wrbug/polyhermes:latest
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
EOF
|
||||
|
||||
chmod +x /opt/polyhermes-update.sh
|
||||
|
||||
# 添加到 crontab(每天凌晨 3 点检查更新)
|
||||
echo "0 3 * * * /opt/polyhermes-update.sh >> /var/log/polyhermes-update.log 2>&1" | crontab -
|
||||
```
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 服务无法启动
|
||||
|
||||
```bash
|
||||
# 查看详细错误日志
|
||||
docker compose -f docker-compose.prod.yml logs
|
||||
|
||||
# 检查容器状态
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
### 数据库连接失败
|
||||
|
||||
```bash
|
||||
# 检查 MySQL 容器状态
|
||||
docker logs polyhermes-mysql
|
||||
|
||||
# 测试数据库连接
|
||||
docker exec polyhermes-mysql mysql -u root -p -e "SELECT 1"
|
||||
```
|
||||
|
||||
### 端口被占用
|
||||
|
||||
```bash
|
||||
# 查找占用端口的进程
|
||||
lsof -i :80
|
||||
# 或
|
||||
netstat -tulpn | grep :80
|
||||
|
||||
# 修改 SERVER_PORT 环境变量
|
||||
vim .env # 修改 SERVER_PORT=8080
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 镜像拉取失败
|
||||
|
||||
如果 Docker Hub 访问受限,可以配置镜像加速器:
|
||||
|
||||
```bash
|
||||
# 配置 Docker 镜像加速器
|
||||
sudo mkdir -p /etc/docker
|
||||
sudo tee /etc/docker/daemon.json <<-'EOF'
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"https://docker.mirrors.ustc.edu.cn",
|
||||
"https://hub-mirror.c.163.com"
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
## 📚 更多资源
|
||||
|
||||
- [项目 README](../../README.md)
|
||||
- [发布日志](../../RELEASE.md)
|
||||
- [GitHub 仓库](https://github.com/WrBug/PolyHermes)
|
||||
- [问题反馈](https://github.com/WrBug/PolyHermes/issues)
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
如遇到问题,请:
|
||||
|
||||
1. 查看上方的**故障排查**章节
|
||||
2. 检查 [GitHub Issues](https://github.com/WrBug/PolyHermes/issues)
|
||||
3. 提交新的 Issue 并附上日志输出
|
||||
|
||||
---
|
||||
|
||||
**祝部署顺利!** 🎉
|
||||
@@ -0,0 +1,323 @@
|
||||
# 跟单回测功能产品需求文档 (PRD)
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
跟单回测功能允许用户对历史数据进行模拟跟单交易,评估不同跟单策略的收益表现,帮助用户在实际投入资金前验证策略的有效性。
|
||||
|
||||
## 二、用户故事
|
||||
|
||||
### 主要用户场景
|
||||
|
||||
1. **作为用户**,我希望能够创建回测任务,选择特定的 Leader 和跟单配置,以便评估在过去一段时间内使用该策略的收益情况
|
||||
2. **作为用户**,我希望能够查看所有历史回测结果,并按收益额或收益率排序,以便找到最优策略
|
||||
3. **作为用户**,我希望能够按 Leader 筛选回测记录,以便比较不同 Leader 的表现
|
||||
4. **作为用户**,我希望回测结果能够展示详细的交易记录和收益变化,以便深入分析策略表现
|
||||
5. **作为用户**,我希望回测在资金不足时能够自动停止,模拟真实交易场景
|
||||
|
||||
## 三、功能需求
|
||||
|
||||
### 3.1 回测管理页面
|
||||
|
||||
#### 3.1.1 页面布局
|
||||
|
||||
- **页面位置**: 在跟单管理模块下新增"回测管理"菜单项
|
||||
- **页面标题**: "回测管理" / "Backtest Management"
|
||||
|
||||
#### 3.1.2 列表功能
|
||||
|
||||
**筛选功能**:
|
||||
- Leader 筛选下拉框,支持按 Leader 过滤回测记录
|
||||
- 状态筛选: 全部 / 运行中 / 已完成 / 已停止
|
||||
|
||||
**排序功能**:
|
||||
- 按收益额排序 (升序/降序)
|
||||
- 按收益率排序 (升序/降序)
|
||||
- 按创建时间排序 (默认降序)
|
||||
|
||||
**列表字段**:
|
||||
| 字段名 | 说明 | 示例 |
|
||||
|-------|------|------|
|
||||
| 回测ID | 唯一标识 | #12345 |
|
||||
| 配置名称 | 回测任务名称 | "激进策略-Leader A" |
|
||||
| Leader名称 | 跟单的Leader | "Smart Trader" |
|
||||
| 初始金额 | 回测起始资金 | $1000 |
|
||||
| 最终金额 | 回测结束时资金 | $1250 |
|
||||
| 收益额 | 最终金额 - 初始金额 | +$250 |
|
||||
| 收益率 | (收益额/初始金额) × 100% | +25% |
|
||||
| 回测天数 | 回测的时间跨度 | 30天 |
|
||||
| 交易笔数 | 回测期间执行的交易数量 | 45笔 |
|
||||
| 状态 | 运行中/已完成/已停止 | 已完成 |
|
||||
| 开始时间 | 回测开始时间 | 2026-01-01 10:00 |
|
||||
| 结束时间 | 回测结束时间 | 2026-01-31 15:30 |
|
||||
| 操作 | 查看详情/删除 | - |
|
||||
|
||||
#### 3.1.3 列表操作
|
||||
|
||||
- **查看详情**: 点击后展开详细信息,包括:
|
||||
- 回测配置参数
|
||||
- 详细交易记录
|
||||
- 资金变化曲线图
|
||||
- 收益统计
|
||||
- **删除**: 删除回测记录(确认后不可恢复)
|
||||
|
||||
### 3.2 新增回测任务
|
||||
|
||||
#### 3.2.1 创建入口
|
||||
|
||||
- 列表页面右上角"新增回测"按钮
|
||||
- 点击后弹出创建对话框或跳转到创建页面
|
||||
|
||||
#### 3.2.2 配置表单
|
||||
|
||||
**基本配置**:
|
||||
- **回测名称** (必填): 用户自定义名称,方便识别
|
||||
- **选择Leader** (必填): 下拉框选择已添加的Leader
|
||||
- **初始投入金额** (必填): 模拟起始资金,单位USDC,范围: 1 - 1,000,000
|
||||
- **回测天数** (必填): 选择回测的历史天数,范围: 1 - 30天
|
||||
|
||||
**跟单配置** (参照现有跟单配置参数):
|
||||
|
||||
| 配置项 | 字段名 | 说明 | 默认值 |
|
||||
|-------|-------|------|-------|
|
||||
| 跟单模式 | copyMode | RATIO(比例模式) / FIXED(固定金额) | RATIO |
|
||||
| 跟单比例 | copyRatio | 比例模式下生效,相对Leader订单金额的比例 | 1.0 |
|
||||
| 固定金额 | fixedAmount | 固定金额模式下生效,每笔固定投入金额 | null |
|
||||
| 最大单笔订单 | maxOrderSize | 单笔订单最大金额限制 | 1000 |
|
||||
| 最小单笔订单 | minOrderSize | 单笔订单最小金额限制 | 1 |
|
||||
| 最大每日亏损 | maxDailyLoss | 每日最大亏损限制 | 10000 |
|
||||
| 最大每日订单数 | maxDailyOrders | 每日最大订单数量限制 | 100 |
|
||||
| 价格容忍度 | priceTolerance | 价格偏差容忍百分比 | 5% |
|
||||
| 延迟秒数 | delaySeconds | 跟单延迟时间 | 0 |
|
||||
| 支持卖出 | supportSell | 是否跟随卖出 | true |
|
||||
| 最小订单深度 | minOrderDepth | 订单簿深度要求 | null |
|
||||
| 最大价差 | maxSpread | 买卖价差限制 | null |
|
||||
| 最低价格 | minPrice | 最低价格限制 | null |
|
||||
| 最高价格 | maxPrice | 最高价格限制 | null |
|
||||
| 最大仓位金额 | maxPositionValue | 最大持仓总金额 | null |
|
||||
| 关键字过滤模式 | keywordFilterMode | DISABLED/WHITELIST/BLACKLIST | DISABLED |
|
||||
| 关键字列表 | keywords | 关键字数组 | [] |
|
||||
| 市场截止时间限制 | maxMarketEndDate | 市场结束时间限制 | null |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 跟单配置表单应完全复用现有的跟单配置组件,保持参数一致性
|
||||
|
||||
#### 3.2.3 表单验证
|
||||
|
||||
- 回测名称: 不能为空,长度1-100字符
|
||||
- Leader: 必须选择有效的Leader
|
||||
- 初始金额: 必须大于0
|
||||
- 回测天数: 必须在1-30之间
|
||||
- 其他配置参数: 遵循现有跟单配置的验证规则
|
||||
|
||||
#### 3.2.4 提交逻辑
|
||||
|
||||
1. 表单验证通过后,提交到后端API
|
||||
2. 后端保存回测任务到数据库,状态设置为"待执行"
|
||||
3. 前端显示创建成功提示,自动跳转到列表页面
|
||||
4. 回测任务由后端轮询服务自动获取并执行
|
||||
|
||||
### 3.3 回测详情页面
|
||||
|
||||
#### 3.3.1 页面布局
|
||||
|
||||
**顶部概览卡片**:
|
||||
- 回测名称
|
||||
- Leader信息
|
||||
- 初始金额 / 最终金额
|
||||
- 收益额 / 收益率
|
||||
- 回测时间范围
|
||||
- 总交易笔数
|
||||
- 状态
|
||||
|
||||
**资金变化图表**:
|
||||
- X轴: 时间
|
||||
- Y轴: 账户余额
|
||||
- 折线图展示资金随时间的变化
|
||||
|
||||
**交易记录表格**:
|
||||
| 时间 | 市场 | 方向 | 数量 | 价格 | 金额 | 盈亏 | 余额 |
|
||||
|-----|------|-----|------|------|------|------|------|
|
||||
| 2026-01-01 10:05 | BTC > $100k | 买入 YES | 100 | 0.65 | 65 | - | 935.00 |
|
||||
| 2026-01-01 14:20 | BTC > $100k | 卖出 YES | 100 | 0.72 | 72 | +7.00 | 942.00 |
|
||||
| 2026-01-05 16:00 | ETH > $5k | 买入 YES | 50 | 0.80 | 40 | - | 902.00 |
|
||||
| 2026-01-10 00:00 | ETH > $5k | 市场结算 YES | 50 | 1.00 | 50 | +10.00 | 952.00 |
|
||||
|
||||
> [!NOTE]
|
||||
> **交易类型说明**:
|
||||
> - **买入**: 跟随Leader买入
|
||||
> - **卖出**: 跟随Leader卖出
|
||||
> - **市场结算**: 市场到期自动结算(赎回)
|
||||
>
|
||||
> **手续费**: 回测不计算手续费,简化计算逻辑
|
||||
|
||||
**统计数据**:
|
||||
- 总交易笔数
|
||||
- 买入笔数 / 卖出笔数
|
||||
- 胜率 (盈利交易 / 总交易)
|
||||
- 平均收益
|
||||
- 最大单笔盈利
|
||||
- 最大单笔亏损
|
||||
- 最大回撤
|
||||
|
||||
## 四、业务规则
|
||||
|
||||
### 4.1 回测执行规则
|
||||
|
||||
#### 4.1.1 资金检查
|
||||
- **停止条件**: 当账户余额 < $1 **且无任何持仓**时,回测自动停止
|
||||
- **继续条件**: 如果余额 < $1 但仍有持仓,继续处理后续交易
|
||||
- 原因: Leader 可能卖出,或市场到期结算,释放资金
|
||||
- 处理: 跳过无法执行的买入订单,继续处理卖出和结算
|
||||
- **订单检查**: 每次下单前检查余额是否充足
|
||||
- **不足处理**: 余额不足时跳过该买入订单,记录日志,继续监听后续交易
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 只要有持仓存在,就不应停止回测,因为后续可能通过卖出或市场结算回收资金
|
||||
|
||||
#### 4.1.2 历史数据获取
|
||||
- 从 Polymarket API 获取 Leader 的历史交易记录
|
||||
- 根据回测天数计算起始时间: `startTime = now - (backtestDays × 24 × 3600 × 1000)`
|
||||
- 按时间顺序回放交易
|
||||
|
||||
#### 4.1.3 交易执行模拟
|
||||
- 按照配置的跟单规则计算跟单金额
|
||||
- 应用所有过滤条件 (价格、深度、关键字等)
|
||||
- 模拟订单成交(不计算手续费)
|
||||
- 更新账户余额
|
||||
|
||||
> [!NOTE]
|
||||
> 回测不计算手续费,简化计算逻辑,避免过于复杂的精度问题
|
||||
|
||||
#### 4.1.3.1 余额不足的处理 ⚠️
|
||||
|
||||
**场景说明**:
|
||||
- 当前余额不足以执行买入订单
|
||||
- 但有持仓未卖出(相当于"待赎回资产")
|
||||
|
||||
**处理策略**:
|
||||
|
||||
**方案A: 严格模式**(推荐)
|
||||
- ✅ 仅使用当前可用余额(`currentBalance`)
|
||||
- ✅ 余额不足时跳过该订单,不考虑持仓价值
|
||||
- ✅ 理由: 更保守,模拟真实场景(持仓未卖出前资金不可用)
|
||||
|
||||
**方案B: 宽松模式**(可选)
|
||||
- 计算"潜在可用资金" = `currentBalance + 持仓市值`
|
||||
- 允许"透支"买入,后续通过卖出或结算平衡
|
||||
- 风险: 可能产生不切实际的回测结果
|
||||
|
||||
**推荐实现**:
|
||||
```kotlin
|
||||
// 严格检查余额
|
||||
if (totalCost > currentBalance) {
|
||||
logger.info("余额不足以执行买入订单: 需要 $totalCost, 可用 $currentBalance")
|
||||
logger.debug("当前持仓价值: ${calculatePositionValue(positions)}, 但不计入可用余额")
|
||||
continue // 跳过该订单
|
||||
}
|
||||
```
|
||||
|
||||
**特殊情况: 市场即将结算**
|
||||
- 如果持仓市场在接下来很短时间内会结算,可以提前释放资金
|
||||
- 实现: 在每次交易前先执行市场结算检查(已在4.1.5实现)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 采用**严格模式**更符合真实跟单场景,避免回测结果过于乐观
|
||||
|
||||
#### 4.1.4 卖出跟随
|
||||
- 如果 `supportSell = true`,跟随 Leader 的卖出操作
|
||||
- 按照买入时的比例卖出持仓
|
||||
- 计算盈亏并更新余额
|
||||
|
||||
#### 4.1.5 市场结算处理 ⭐
|
||||
- **触发时机**:
|
||||
- **实时检查**: 在处理每笔Leader交易前,检查所有持仓市场的`endDate`
|
||||
- **到期即结算**: 如果市场结束时间 ≤ 当前交易时间,立即结算该持仓
|
||||
- **兜底处理**: 回测结束时,结算所有剩余持仓
|
||||
- **结算规则**:
|
||||
- 获取市场最终结果 (通过Polymarket API)
|
||||
- 持仓方向为胜出方: 按 **1.0** 价格结算
|
||||
- 持仓方向为失败方: 按 **0.0** 价格结算
|
||||
- 市场未结算或无法获取结果: 按**成本价**结算 (保守估计)
|
||||
- **资金流转**: 结算后的资金立即计入余额,可用于后续交易
|
||||
- **交易记录**: 生成"市场结算"类型的交易记录,用于详情展示
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **实时结算的优势**:
|
||||
> - ✅ 模拟真实场景: 市场结束时资金会自动返还
|
||||
> - ✅ 提高资金利用率: 结算后的资金可以参与后续交易
|
||||
> - ✅ 更准确的收益计算: 反映实际的资金周转情况
|
||||
|
||||
### 4.2 数据持久化
|
||||
|
||||
#### 4.2.1 回测任务表
|
||||
- 保存回测基本信息和配置
|
||||
- 记录执行状态和结果
|
||||
|
||||
#### 4.2.2 回测交易记录表
|
||||
- 保存每笔模拟交易的详细信息
|
||||
- 用于详情页面展示和分析
|
||||
|
||||
### 4.3 并发控制
|
||||
|
||||
- 同一时间最多支持 5 个回测任务并发执行
|
||||
- 新任务排队等待,FIFO策略
|
||||
- 前端显示任务队列位置
|
||||
|
||||
## 五、UI/UX 要求
|
||||
|
||||
### 5.1 响应式设计
|
||||
- 支持桌面和移动端浏览
|
||||
- 表格在小屏幕上支持横向滚动
|
||||
|
||||
### 5.2 国际化
|
||||
- 支持中文和英文
|
||||
- 所有文案提供双语版本
|
||||
|
||||
### 5.3 交互体验
|
||||
- 创建回测: 表单提交时显示Loading状态
|
||||
- 回测执行中: 显示进度条或百分比
|
||||
- 数据加载: Skeleton占位符
|
||||
- 操作反馈: Toast提示 (成功/失败/警告)
|
||||
|
||||
### 5.4 数据可视化
|
||||
- 资金变化图表使用 ECharts 或 Recharts
|
||||
- 支持图表缩放和数据点Tooltip
|
||||
- 图表颜色: 盈利绿色,亏损红色
|
||||
|
||||
## 六、非功能需求
|
||||
|
||||
### 6.1 性能要求
|
||||
- 回测列表页面加载时间 < 2秒
|
||||
- 单个回测任务执行时间 < 5分钟 (30天数据)
|
||||
- 详情页图表渲染时间 < 1秒
|
||||
|
||||
### 6.2 数据准确性
|
||||
- 回测结果误差 < 0.1%
|
||||
- 余额计算使用 BigDecimal 避免精度丢失
|
||||
- 价格和数量精确到小数点后8位
|
||||
|
||||
### 6.3 安全性
|
||||
- 回测数据仅用户本人可见
|
||||
- API接口需要身份认证
|
||||
- 防止SQL注入和XSS攻击
|
||||
|
||||
## 七、后续迭代规划
|
||||
|
||||
### Phase 2 (可选)
|
||||
- 支持批量创建回测任务
|
||||
- 回测结果对比功能
|
||||
- 导出回测报告 (PDF/Excel)
|
||||
- AI策略推荐
|
||||
|
||||
### Phase 3 (可选)
|
||||
- 实时回测 (边交易边回测)
|
||||
- 社区策略分享
|
||||
- 策略市场
|
||||
|
||||
---
|
||||
|
||||
## 附录: 页面路由规划
|
||||
|
||||
- 回测列表: `/copy-trading/backtest`
|
||||
- 新增回测: `/copy-trading/backtest/create`
|
||||
- 回测详情: `/copy-trading/backtest/:id`
|
||||
@@ -0,0 +1,669 @@
|
||||
# 回测功能设计审查清单
|
||||
|
||||
## 一、设计审查要点
|
||||
|
||||
### 1.1 产品需求完整性 ✅
|
||||
|
||||
**已覆盖的核心功能**:
|
||||
- ✅ 回测任务的创建、查询、删除
|
||||
- ✅ 按Leader筛选和排序功能
|
||||
- ✅ 回测配置参数复用现有跟单配置
|
||||
- ✅ 回测详情展示 (交易记录、资金曲线图、统计数据)
|
||||
- ✅ 资金不足时自动停止机制
|
||||
- ✅ 回测天数限制 (1-30天)
|
||||
|
||||
**潜在遗漏点**:
|
||||
> [!WARNING]
|
||||
> **需要确认的问题**:
|
||||
> 1. **回测结果的可见性**: 是否需要支持多用户? 当前设计未涉及权限控制
|
||||
> 2. **回测任务的生命周期管理**: 是否需要自动清理过期的回测记录?
|
||||
> 3. **回测进度的实时展示**: 前端如何获取运行中任务的进度? (考虑WebSocket或轮询)
|
||||
|
||||
### 1.2 技术设计合理性 ✅
|
||||
|
||||
**优点**:
|
||||
- ✅ 数据库设计规范,索引合理
|
||||
- ✅ API设计符合RESTful规范
|
||||
- ✅ 复用现有的 `CopyTradingFilterService`,减少代码冗余
|
||||
- ✅ 使用 BigDecimal 保证计算精度
|
||||
- ✅ 异步执行回测任务,不阻塞主线程
|
||||
|
||||
**可能的改进点**:
|
||||
> [!NOTE]
|
||||
> **建议优化的地方**:
|
||||
> 1. **历史数据获取**: 当前设计依赖Polymarket API,需要考虑API限流和数据缺失的情况
|
||||
> 2. **缓存策略**: 建议对Leader历史交易数据使用分层缓存 (内存 + Redis)
|
||||
> 3. **回测结果的序列化**: 考虑将详细交易记录存储为JSON,减少表的大小
|
||||
|
||||
### 1.3 业务逻辑准确性 ✅
|
||||
|
||||
**已完善的关键逻辑**:
|
||||
|
||||
#### 1.3.1 历史数据获取 ⭐ (已修正)
|
||||
> [!NOTE]
|
||||
> **问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。
|
||||
>
|
||||
> **解决方案**: 创建独立的 `backtest_historical_trades` 表
|
||||
> - ✅ 存储完整的交易信息(marketId, price, quantity, outcomeIndex 等)
|
||||
> - ✅ 支持实时数据同步(跟单时同时写入)
|
||||
> - ✅ 支持通过 API 补充历史数据
|
||||
> - ✅ 不影响现有跟单功能
|
||||
|
||||
#### 1.3.2 卖出匹配逻辑 ⭐ (已修正)
|
||||
> [!NOTE]
|
||||
> **改进**: 使用 `outcomeIndex` 支持多元市场
|
||||
>
|
||||
> **实现方案**:
|
||||
> - 持仓键: `marketId + outcomeIndex`(支持多元市场)
|
||||
> - 比例模式: 按 Leader 卖出比例计算
|
||||
> - 固定金额模式: 全部卖出
|
||||
> - 参考 `CopyOrderTracking` 的逻辑
|
||||
|
||||
**伪代码**:
|
||||
```kotlin
|
||||
val positionKey = "${leaderTrade.marketId}:${leaderTrade.outcomeIndex ?: 0}"
|
||||
val position = positions[positionKey] ?: continue
|
||||
|
||||
val sellQuantity = if (task.copyMode == "RATIO") {
|
||||
if (position.leaderBuyQuantity != null && position.leaderBuyQuantity > BigDecimal.ZERO) {
|
||||
position.quantity * (leaderTrade.quantity / position.leaderBuyQuantity)
|
||||
} else {
|
||||
position.quantity // 全部卖出
|
||||
}
|
||||
} else {
|
||||
position.quantity // 固定金额模式全部卖出
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3.3 价格滑点模拟 ✅ (已决策)
|
||||
> [!NOTE]
|
||||
> **用户决策**: 暂不模拟价格滑点
|
||||
>
|
||||
> **理由**:
|
||||
> - 简化回测逻辑
|
||||
> - 减少复杂度
|
||||
> - 后续可以作为可选项添加
|
||||
>
|
||||
> **实现**: 使用 Leader 的成交价,不进行滑点调整
|
||||
|
||||
#### 1.3.2 价格滑点模拟
|
||||
> [!NOTE]
|
||||
> **关键问题**: 是否需要模拟价格滑点?
|
||||
>
|
||||
> **当前设计**: 不模拟价格滑点,直接使用Leader的成交价
|
||||
> - 优点: 简化逻辑,回测速度快
|
||||
> - 缺点: 可能高估收益(实际跟单可能有滑点)
|
||||
>
|
||||
> **可选方案**: 增加可配置的滑点参数
|
||||
> - 买入时: 价格 × (1 + 滑点%)
|
||||
> - 卖出时: 价格 × (1 - 滑点%)
|
||||
> - 增加可选的滑点模拟参数 (例如: ±0.5%)
|
||||
> - 在PRD中补充此配置项
|
||||
|
||||
#### 1.3.3 手续费计算 ✅ (已移除)
|
||||
> [!NOTE]
|
||||
> **用户决策**: 回测不计算手续费
|
||||
>
|
||||
> **理由**:
|
||||
> - 简化计算逻辑
|
||||
> - 避免精度问题
|
||||
> - 降低复杂度
|
||||
>
|
||||
> **实现**:
|
||||
> - 所有交易的 `fee` 字段均为 `0`
|
||||
> - 买入成本 = 数量 × 价格
|
||||
> - 卖出收入 = 数量 × 价格
|
||||
> - 结算收入 = 数量 × 结算价
|
||||
|
||||
#### 1.3.4 市场结算处理 ⭐ (已优化)
|
||||
> [!NOTE]
|
||||
> **问题**: 市场结束时,未平仓位如何自动结算?
|
||||
>
|
||||
> **优化方案** (采纳用户建议):
|
||||
> - ✅ **实时检查**: 在回测循环中,每处理一笔Leader交易前,检查所有持仓的市场`endDate`
|
||||
> - ✅ **到期即结算**: 如果 `marketEndDate <= currentTradeTime`,立即结算该持仓
|
||||
> - ✅ **资金可用**: 结算后的资金立即计入余额,可以用于后续交易
|
||||
> - ✅ **兜底处理**: 回测结束时,结算所有剩余未到期持仓
|
||||
>
|
||||
> **结算价格判断** (通过市场价格):
|
||||
> - 价格 >= 0.95: 胜出 (按 1.0 结算)
|
||||
> - 价格 <= 0.05: 失败 (按 0.0 结算)
|
||||
> - 其他情况: 按成本价保守估计
|
||||
>
|
||||
> **实现要点**:
|
||||
> ```kotlin
|
||||
> // 在交易循环中实时检查市场到期
|
||||
> for (leaderTrade in leaderTrades.sortedBy { it.timestamp }) {
|
||||
>
|
||||
> // 1. 检查并结算已到期的市场
|
||||
> val expiredPositions = positions.filter { (_, position) ->
|
||||
> val marketInfo = getMarketInfo(position.marketId)
|
||||
> marketInfo.endDate <= leaderTrade.timestamp
|
||||
> }
|
||||
>
|
||||
> for ((positionKey, position) in expiredPositions) {
|
||||
> val marketPrice = marketPriceService.getCurrentMarketPrice(
|
||||
> marketId = position.marketId,
|
||||
> outcomeIndex = position.outcomeIndex ?: 0
|
||||
> )
|
||||
>
|
||||
> val settlementPrice = when {
|
||||
> marketPrice >= BigDecimal("0.95") -> BigDecimal.ONE // 胜出
|
||||
> marketPrice <= BigDecimal("0.05") -> BigDecimal.ZERO // 失败
|
||||
> else -> position.avgPrice // 未结算,按成本价
|
||||
> }
|
||||
>
|
||||
> val settlementValue = position.quantity * settlementPrice
|
||||
> currentBalance += settlementValue
|
||||
> positions.remove(positionKey)
|
||||
> }
|
||||
>
|
||||
> // 2. 处理当前Leader交易
|
||||
> // ...
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **优势**:
|
||||
> - 更符合真实场景 (市场结束时自动返还资金)
|
||||
> - 提高资金利用率 (结算资金可参与后续交易)
|
||||
> - 更准确的收益计算
|
||||
> - 通过市场价格判断,无需依赖可能不存在的 `winner` 字段
|
||||
|
||||
#### 1.3.5 余额不足与持仓处理 ⚠️ (边缘场景) - 已修正
|
||||
> [!WARNING]
|
||||
> **关键问题**: 当余额不足但有未卖出持仓时,如何处理?
|
||||
>
|
||||
> **场景示例**:
|
||||
> - 初始余额: $1000
|
||||
> - 已买入持仓市值: $800(未卖出)
|
||||
> - 当前余额: $200
|
||||
> - 新买入订单需要: $300
|
||||
> - **问题**: 是否允许买入?虽然持仓市值足够,但资金被占用
|
||||
>
|
||||
> **推荐方案: 严格模式**
|
||||
> - ❌ 不允许买入(余额不足)
|
||||
> - ✅ 仅使用 `currentBalance` 判断
|
||||
> - ✅ 不计入持仓市值(因为持仓未实现)
|
||||
> - ✅ 理由: 更真实,避免过于乐观的回测结果
|
||||
>
|
||||
> **替代方案: 宽松模式**
|
||||
> - ✅ 计算"虚拟可用资金" = `currentBalance + 持仓估值`
|
||||
> - ⚠️ 允许"透支"买入
|
||||
> - ❌ 风险: 可能产生不切实际的收益
|
||||
>
|
||||
> **实际影响**:
|
||||
> - 严格模式下,资金周转率是限制因素
|
||||
> - 鼓励快进快出的策略
|
||||
> - 长期持仓策略会因资金占用而错过后续机会
|
||||
>
|
||||
> **已在文档中采用**: 严格模式
|
||||
|
||||
#### 1.3.6 每日订单数限制 ✅ (已补充)
|
||||
> [!NOTE]
|
||||
> **问题**: 文档提到了 `maxDailyOrders` 参数,但未在算法中实现
|
||||
>
|
||||
> **解决方案**: 在回测循环中添加每日订单数统计
|
||||
>
|
||||
> **实现**:
|
||||
> ```kotlin
|
||||
> // 统计当前交易时间当天已有的订单数
|
||||
> val dailyOrderCount = trades.count { isSameDay(it.tradeTime, leaderTrade.timestamp) }
|
||||
>
|
||||
> if (dailyOrderCount >= task.maxDailyOrders) {
|
||||
> logger.info("已达到每日最大订单数限制: $dailyOrderCount / ${task.maxDailyOrders}")
|
||||
> continue
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **优势**:
|
||||
> - 符合实际跟单的风险控制逻辑
|
||||
> - 避免回测结果过于激进
|
||||
|
||||
#### 1.3.7 价格容忍度检查 ✅ (已补充)
|
||||
> [!NOTE]
|
||||
> **问题**: 文档提到了 `priceTolerance` 参数,但未在算法中实现
|
||||
>
|
||||
> **解决方案**: 在执行交易前检查当前市场价格是否在容忍范围内
|
||||
>
|
||||
> **实现**:
|
||||
> ```kotlin
|
||||
> if (task.priceTolerance > BigDecimal.ZERO) {
|
||||
> val tolerance = task.priceTolerance.divide(BigDecimal("100"))
|
||||
> val minPrice = leaderTrade.price.multiply(BigDecimal.ONE.subtract(tolerance))
|
||||
> val maxPrice = leaderTrade.price.multiply(BigDecimal.ONE.add(tolerance))
|
||||
>
|
||||
> val currentPrice = marketPriceService.getCurrentMarketPrice(
|
||||
> marketId = leaderTrade.marketId,
|
||||
> outcomeIndex = leaderTrade.outcomeIndex ?: 0
|
||||
> )
|
||||
>
|
||||
> if (currentPrice < minPrice || currentPrice > maxPrice) {
|
||||
> logger.info("价格超出容忍度范围: 当前=$currentPrice, 可用范围=[$minPrice, $maxPrice]")
|
||||
> continue
|
||||
> }
|
||||
> }
|
||||
> ```
|
||||
|
||||
#### 1.3.8 回测停止条件 ✅ (已修正)
|
||||
> [!NOTE]
|
||||
> **修正**: 基于用户反馈,修正了停止逻辑
|
||||
>
|
||||
> **错误设计**:
|
||||
> ```kotlin
|
||||
> if (currentBalance < $1) {
|
||||
> break // ❌ 直接停止,忽略持仓
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **正确设计**:
|
||||
> ```kotlin
|
||||
> // 只有"余额不足 且 无持仓"时才停止
|
||||
> if (currentBalance < $1 && positions.isEmpty()) {
|
||||
> break // ✅ 确保无持仓时才停止
|
||||
> }
|
||||
>
|
||||
> // 有持仓时继续处理(等待卖出或结算)
|
||||
> if (currentBalance < $1 && positions.isNotEmpty()) {
|
||||
> // 继续处理,跳过买入,但执行卖出和结算
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **理由**:
|
||||
> - 持仓存在意味着可能有后续卖出或市场结算
|
||||
> - 这些操作会释放资金
|
||||
> - 过早停止会导致资金无法回收,回测不准确
|
||||
|
||||
### 1.4 性能和可扩展性 ✅
|
||||
|
||||
**已考虑的优化**:
|
||||
- ✅ 异步执行,线程池限制并发
|
||||
- ✅ 分页查询
|
||||
- ✅ 数据库索引优化
|
||||
- ✅ 前端虚拟滚动
|
||||
|
||||
**需要进一步考虑**:
|
||||
> [!TIP]
|
||||
> **性能优化建议**:
|
||||
> 1. **批量插入交易记录**: 使用 `saveAll()` 而非逐条 `save()`
|
||||
> 2. **进度更新频率**: 避免每笔交易都更新数据库,改为每100笔或每10秒更新一次
|
||||
> 3. **历史数据预加载**: 在任务开始前一次性加载所有历史交易,避免多次API调用
|
||||
|
||||
## 二、数据库设计补充
|
||||
|
||||
### 2.1 新增回测历史交易表
|
||||
|
||||
**问题**: 现有 `ProcessedTrade` 表字段有限,无法满足回测需求。
|
||||
|
||||
**解决方案**: 创建独立的 `backtest_historical_trades` 表,存储完整的 Leader 历史交易数据。
|
||||
|
||||
```sql
|
||||
CREATE TABLE backtest_historical_trades (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
trade_id VARCHAR(100) NOT NULL COMMENT 'Leader 交易ID(唯一标识)',
|
||||
market_id VARCHAR(100) NOT NULL COMMENT '市场ID',
|
||||
market_title VARCHAR(500) DEFAULT NULL COMMENT '市场标题',
|
||||
market_slug VARCHAR(200) DEFAULT NULL COMMENT '市场 slug(用于生成链接)',
|
||||
side VARCHAR(10) NOT NULL COMMENT '交易方向: BUY/SELL',
|
||||
outcome VARCHAR(50) DEFAULT NULL COMMENT '市场方向(如 YES, NO 等)',
|
||||
outcome_index INT DEFAULT NULL COMMENT '结果索引(0, 1, 2, ...),支持多元市场',
|
||||
price DECIMAL(20, 8) NOT NULL COMMENT '交易价格',
|
||||
size DECIMAL(20, 8) NOT NULL COMMENT '交易数量',
|
||||
amount DECIMAL(20, 8) NOT NULL COMMENT '交易金额(price × size)',
|
||||
trade_timestamp BIGINT NOT NULL COMMENT '交易时间戳(毫秒)',
|
||||
|
||||
-- 元数据
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'POLLING' COMMENT '数据来源: WEBSOCKET/POLLING/API',
|
||||
fetched_at BIGINT NOT NULL COMMENT '数据获取时间(毫秒)',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒)',
|
||||
|
||||
UNIQUE INDEX uk_leader_trade (leader_id, trade_id),
|
||||
INDEX idx_leader_id (leader_id),
|
||||
INDEX idx_trade_timestamp (trade_timestamp),
|
||||
INDEX idx_market_id (market_id)
|
||||
) COMMENT='回测历史交易表';
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- ✅ 不影响现有跟单系统的 `ProcessedTrade` 表
|
||||
- ✅ 存储完整的交易信息,满足回测需求
|
||||
- ✅ 支持实时数据同步(跟单时同时写入)
|
||||
- ✅ 支持通过 API 补充历史数据
|
||||
- ✅ 唯一索引自动去重
|
||||
|
||||
### 2.2 移除 max_position_count 字段
|
||||
|
||||
**问题**: 文档中包含 `max_position_count` 字段,但 V26 迁移已删除该字段。
|
||||
|
||||
**解决方案**: 从 `backtest_task` 表和相关 API 中移除该字段。
|
||||
|
||||
### 2.3 其他字段建议
|
||||
|
||||
#### `backtest_task` 表
|
||||
建议新增以下字段:
|
||||
|
||||
```sql
|
||||
-- 用于计算平均持仓时间
|
||||
avg_holding_time BIGINT DEFAULT NULL COMMENT '平均持仓时间(毫秒)',
|
||||
|
||||
-- 用于记录回测使用的数据源
|
||||
data_source VARCHAR(50) DEFAULT 'MIXED' COMMENT '数据源: INTERNAL/API/MIXED',
|
||||
|
||||
-- 用于记录回测执行的详细日志
|
||||
execution_log TEXT DEFAULT NULL COMMENT '执行日志(JSON格式)'
|
||||
```
|
||||
|
||||
### 2.4 索引优化
|
||||
|
||||
建议添加复合索引:
|
||||
```sql
|
||||
-- 用于按Leader和收益率查询
|
||||
CREATE INDEX idx_leader_profit ON backtest_task(leader_id, profit_rate DESC);
|
||||
|
||||
-- 用于按状态和创建时间查询
|
||||
CREATE INDEX idx_status_created ON backtest_task(status, created_at DESC);
|
||||
```
|
||||
|
||||
## 三、API设计补充
|
||||
|
||||
### 3.1 API 规范修正
|
||||
|
||||
**问题**: 文档中使用 GET/DELETE 方法,违反项目统一使用 POST 的规范。
|
||||
|
||||
**修正方案**:
|
||||
|
||||
```bash
|
||||
# ❌ 错误(使用 GET/DELETE)
|
||||
GET /api/backtest/tasks
|
||||
GET /api/backtest/tasks/{id}
|
||||
DELETE /api/backtest/tasks/{id}
|
||||
|
||||
# ✅ 正确(统一使用 POST)
|
||||
POST /api/backtest/tasks/list
|
||||
POST /api/backtest/tasks/detail
|
||||
POST /api/backtest/tasks/delete
|
||||
```
|
||||
|
||||
**完整的 API 列表**:
|
||||
|
||||
| 功能 | 方法 | 路径 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| 创建回测 | POST | /api/backtest/tasks | 创建新的回测任务 |
|
||||
| 查询列表 | POST | /api/backtest/tasks/list | 分页查询回测任务列表 |
|
||||
| 查询详情 | POST | /api/backtest/tasks/detail | 查询单个回测任务详情 |
|
||||
| 查询交易 | POST | /api/backtest/tasks/trades | 查询回测的交易记录 |
|
||||
| 删除任务 | POST | /api/backtest/tasks/delete | 删除回测任务 |
|
||||
| 停止任务 | POST | /api/backtest/tasks/stop | 停止运行中的回测 |
|
||||
| 查询进度 | POST | /api/backtest/tasks/progress | 查询回测执行进度 |
|
||||
|
||||
### 3.2 缺失的API
|
||||
|
||||
建议新增以下API:
|
||||
|
||||
#### 3.2.1 查询回测进度 (实时更新)
|
||||
```
|
||||
POST /api/backtest/tasks/progress
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"id": 12345
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"progress": 65,
|
||||
"currentBalance": "1150.00",
|
||||
"totalTrades": 30,
|
||||
"status": "RUNNING"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 批量删除回测任务
|
||||
```
|
||||
POST /api/backtest/tasks/batch-delete
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"taskIds": [12345, 12346, 12347]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.3 导出回测报告
|
||||
```
|
||||
POST /api/backtest/tasks/export
|
||||
```
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"id": 12345,
|
||||
"format": "csv" // 或 "pdf"
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 API错误码规范
|
||||
|
||||
建议统一错误码:
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 40001 | 回测任务不存在 |
|
||||
| 40002 | Leader不存在 |
|
||||
| 40003 | 回测天数超出限制 |
|
||||
| 40004 | 初始金额无效 |
|
||||
| 40005 | 回测任务正在运行,无法删除 |
|
||||
| 50001 | 历史数据获取失败 |
|
||||
| 50002 | 回测执行失败 |
|
||||
|
||||
## 四、前端实现补充
|
||||
|
||||
### 4.1 状态轮询
|
||||
|
||||
对于运行中的回测任务,前端需要定时轮询进度:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (task.status === 'RUNNING') {
|
||||
const interval = setInterval(async () => {
|
||||
const progress = await backtestService.getProgress(task.id);
|
||||
setTask({ ...task, ...progress });
|
||||
}, 3000); // 每3秒轮询一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [task.status]);
|
||||
```
|
||||
|
||||
### 4.2 图表数据压缩
|
||||
|
||||
当交易记录过多时,图表数据需要压缩:
|
||||
|
||||
```typescript
|
||||
// 将数据按时间聚合为最多200个点
|
||||
const compressChartData = (trades: BacktestTrade[], maxPoints: number = 200) => {
|
||||
if (trades.length <= maxPoints) return trades;
|
||||
|
||||
const interval = Math.floor(trades.length / maxPoints);
|
||||
return trades.filter((_, index) => index % interval === 0);
|
||||
};
|
||||
```
|
||||
|
||||
### 4.3 国际化文案
|
||||
|
||||
需要在 `locales/` 目录下补充以下文案:
|
||||
|
||||
**zh-CN.json**:
|
||||
```json
|
||||
{
|
||||
"backtest": {
|
||||
"title": "回测管理",
|
||||
"createTask": "新增回测",
|
||||
"taskName": "回测名称",
|
||||
"leader": "Leader",
|
||||
"initialBalance": "初始金额",
|
||||
"backtestDays": "回测天数",
|
||||
"profitAmount": "收益额",
|
||||
"profitRate": "收益率",
|
||||
"status": {
|
||||
"pending": "待执行",
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"stopped": "已停止",
|
||||
"failed": "失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、测试计划补充
|
||||
|
||||
### 5.1 单元测试
|
||||
|
||||
**需要测试的核心方法**:
|
||||
- `BacktestExecutionService.executeBacktest()` - 回测算法准确性
|
||||
- `BacktestExecutionService.calculateStatistics()` - 统计数据计算
|
||||
- `BacktestDataService.getLeaderHistoricalTrades()` - 历史数据获取
|
||||
|
||||
**测试用例示例**:
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test backtest with simple buy-sell scenario`() {
|
||||
// Given: 初始余额1000, Leader买入100@0.5, 卖出100@0.6
|
||||
val task = createTestTask(initialBalance = 1000.toBigDecimal())
|
||||
val trades = listOf(
|
||||
createBuyTrade(quantity = 100.toBigDecimal(), price = 0.5.toBigDecimal()),
|
||||
createSellTrade(quantity = 100.toBigDecimal(), price = 0.6.toBigDecimal())
|
||||
)
|
||||
|
||||
// When: 执行回测
|
||||
val result = executionService.executeBacktest(task)
|
||||
|
||||
// Then: 验证收益
|
||||
// 买入: 100 * 0.5 = 50, 手续费0.1, 总成本50.1
|
||||
// 卖出: 100 * 0.6 = 60, 手续费0.12, 净收入59.88
|
||||
// 盈利: 59.88 - 50.1 = 9.78
|
||||
// 最终余额: 1000 - 50.1 + 59.88 = 1009.78
|
||||
assertEquals(1009.78.toBigDecimal(), result.finalBalance)
|
||||
assertEquals(9.78.toBigDecimal(), result.profitAmount)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 集成测试
|
||||
|
||||
**测试场景**:
|
||||
1. 端到端测试: 创建任务 → 执行回测 → 查询结果
|
||||
2. 异常场景: 历史数据为空、API调用失败
|
||||
3. 边界条件: 余额刚好为0、单笔交易耗尽余额
|
||||
|
||||
### 5.3 性能测试
|
||||
|
||||
**测试指标**:
|
||||
- 30天历史数据 (假设1000笔交易) 的回测执行时间 < 5分钟
|
||||
- 并发5个回测任务时的系统资源占用
|
||||
- 查询包含10000笔交易的回测详情页面加载时间 < 2秒
|
||||
|
||||
## 六、风险评估和缓解方案
|
||||
|
||||
### 6.1 数据准确性风险
|
||||
|
||||
**风险**: 历史数据不完整或API返回数据有误
|
||||
|
||||
**缓解方案**:
|
||||
1. 数据验证: 检查返回数据的完整性 (是否有时间断层)
|
||||
2. 数据对比: 使用多个数据源交叉验证
|
||||
3. 错误标记: 回测结果标注数据质量等级
|
||||
|
||||
### 6.2 计算精度风险
|
||||
|
||||
**风险**: BigDecimal计算中的舍入误差累积
|
||||
|
||||
**缓解方案**:
|
||||
1. 统一舍入模式: 使用 `RoundingMode.HALF_UP`
|
||||
2. 精度测试: 编写专门的精度测试用例
|
||||
3. 误差补偿: 最终余额与理论值的误差 < 0.01 USDC
|
||||
|
||||
### 6.3 性能风险
|
||||
|
||||
**风险**: 大量回测任务导致系统负载过高
|
||||
|
||||
**缓解方案**:
|
||||
1. 任务队列: 使用异步任务队列 (可选: Redis Queue 或 RabbitMQ)
|
||||
2. 资源限流: 限制单用户最多创建10个待执行任务
|
||||
3. 自动清理: 定期清理30天前的回测记录
|
||||
|
||||
## 七、需要与用户确认的问题
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **关键决策点 - 需要用户反馈**:
|
||||
|
||||
### 7.1 卖出匹配策略
|
||||
**问题**: 当用户多次买入同一市场时,卖出应该匹配哪笔买入?
|
||||
|
||||
**选项**:
|
||||
- **选项A**: FIFO (先进先出) - 先卖出最早的买入
|
||||
- **选项B**: 加权平均 - 按平均成本价计算盈亏
|
||||
- **选项C**: 完全跟随Leader - Leader卖多少比例,我们也卖多少比例
|
||||
|
||||
**建议**: 选项C (完全跟随),与实际跟单逻辑保持一致
|
||||
|
||||
### 7.2 价格滑点模拟
|
||||
**问题**: 是否需要在回测中模拟价格滑点?
|
||||
|
||||
**选项**:
|
||||
- **选项A**: 不模拟,使用Leader成交价 (乐观估计)
|
||||
- **选项B**: 固定滑点 (如买入+0.5%, 卖出-0.5%)
|
||||
- **选项C**: 可配置滑点,用户自定义
|
||||
|
||||
**建议**: 选项C,增加灵活性
|
||||
|
||||
### 7.3 数据源选择
|
||||
**问题**: 历史数据来源?
|
||||
|
||||
**选项**:
|
||||
- **选项A**: 仅使用 Polymarket API
|
||||
- **选项B**: 优先使用系统记录的 `ProcessedTrade` 表,不足时调用API
|
||||
- **选项C**: 仅使用 `ProcessedTrade` 表 (限制回测范围为系统运行期间)
|
||||
|
||||
**建议**: 选项B,兼顾数据完整性和性能
|
||||
|
||||
### 7.4 回测结果保留时长
|
||||
**问题**: 回测记录保留多久?
|
||||
|
||||
**选项**:
|
||||
- **选项A**: 永久保留
|
||||
- **选项B**: 保留30天,自动清理
|
||||
- **选项C**: 用户手动删除,无自动清理
|
||||
|
||||
**建议**: 选项B,避免数据库膨胀
|
||||
|
||||
## 八、文档总结
|
||||
|
||||
### 已完成的文档
|
||||
1. ✅ **BACKTEST_PRD.md** - 产品需求文档
|
||||
2. ✅ **BACKTEST_TECHNICAL_DESIGN.md** - 技术设计文档
|
||||
3. ✅ **BACKTEST_REVIEW_CHECKLIST.md** - 设计审查清单 (本文档)
|
||||
|
||||
### 建议补充的文档 (可选)
|
||||
1. **BACKTEST_API_SPEC.md** - API接口规范 (从技术设计文档提取)
|
||||
2. **BACKTEST_DATABASE_MIGRATION.md** - 数据库迁移脚本
|
||||
3. **BACKTEST_TEST_PLAN.md** - 详细测试计划
|
||||
|
||||
### 下一步行动
|
||||
1. **用户Review**: 请用户审查以上文档,确认关键设计点
|
||||
2. **补充遗漏**: 根据用户反馈补充缺失部分
|
||||
3. **进入执行**: 用户确认后开始实施开发
|
||||
|
||||
---
|
||||
|
||||
**审查日期**: 2026-01-30
|
||||
**审查人**: AI Assistant
|
||||
**状态**: 待用户确认
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
# Polymarket Data API 游标分页验证
|
||||
|
||||
回测拉取 Leader 历史交易改用 **start 游标分页**(不再使用 offset),避免 `offset` 过大(如 3100+)时 API 报错。
|
||||
|
||||
## 规则
|
||||
|
||||
- `limit` 固定为 500(快速验证可用 50)。
|
||||
- 首次请求:`start` = 回测开始时间(秒),`end` = 回测结束时间(秒)。
|
||||
- 若本批返回 **500 条**:取本批中**最大 timestamp**,下一页 `start = max_timestamp`(**不加 1**:同一秒可能有多笔订单,会漏单)。
|
||||
- 若本批 **不足 500 条**:视为最后一页,不再请求。
|
||||
- 下一页会与上一页在「最大 timestamp」这一秒重叠,必须按 **tradeId(transactionHash)去重**。
|
||||
|
||||
## 快速验证(limit=50)
|
||||
|
||||
```bash
|
||||
# 环境变量(替换为实际值)
|
||||
USER="0x1979ae6b7e6534de9c4539d0c205e582ca637c9d"
|
||||
START=1769961432
|
||||
END=1770566207
|
||||
LIMIT=50
|
||||
|
||||
# 第 1 页(游标分页不传 offset)
|
||||
curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'length'
|
||||
# 若输出 50,则取本批最大 timestamp 作为下一页 start(不加 1,同一秒可能多笔)
|
||||
curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'max_by(.timestamp) | .timestamp'
|
||||
# 假设得到 1770000000,则下一页 start=1770000000(与上一批重叠,需按 tradeId 去重)
|
||||
|
||||
# 第 2 页(游标分页,不使用 offset)
|
||||
NEXT_START=1770000000
|
||||
curl -s "https://data-api.polymarket.com/activity?user=${USER}&limit=${LIMIT}&type=TRADE&start=${NEXT_START}&end=${END}&sortBy=TIMESTAMP&sortDirection=ASC" | jq 'length'
|
||||
# 若输出 < 50,则为最后一页
|
||||
```
|
||||
|
||||
## 单条命令示例(第 1 页,limit=50)
|
||||
|
||||
```bash
|
||||
curl -s "https://data-api.polymarket.com/activity?user=0x1979ae6b7e6534de9c4539d0c205e582ca637c9d&limit=50&type=TRADE&start=1769961432&end=1770566207&sortBy=TIMESTAMP&sortDirection=ASC"
|
||||
```
|
||||
|
||||
注意:**不要传 offset**,下一页 `start = 上一批最大 timestamp`(不加 1),同一秒多笔订单不丢,重叠记录按 tradeId 去重。
|
||||
|
||||
## 代码位置
|
||||
|
||||
- 拉取批次:`BacktestDataService.getLeaderHistoricalTradesBatch()`,返回 `LeaderTradesBatchResult(trades, nextCursorSeconds)`。
|
||||
- 执行循环:`BacktestExecutionService.executeBacktest()`,按 `cursorSeconds` 循环,并用 `seenTradeIds` 去重。
|
||||
@@ -0,0 +1,76 @@
|
||||
# 跟单回测功能文档
|
||||
|
||||
## 📚 文档清单
|
||||
|
||||
本目录包含跟单回测功能的完整设计文档:
|
||||
|
||||
### 核心文档
|
||||
|
||||
1. **[BACKTEST_PRD.md](./BACKTEST_PRD.md)** - 产品需求文档
|
||||
- 功能概述与用户故事
|
||||
- UI/UX设计详细说明
|
||||
- 业务规则与数据要求
|
||||
|
||||
2. **[BACKTEST_TECHNICAL_DESIGN.md](./BACKTEST_TECHNICAL_DESIGN.md)** - 技术设计文档
|
||||
- 数据库表结构设计
|
||||
- RESTful API接口规范
|
||||
- 后端服务架构
|
||||
- 前端组件设计
|
||||
- 回测算法实现
|
||||
|
||||
3. **[BACKTEST_REVIEW_CHECKLIST.md](./BACKTEST_REVIEW_CHECKLIST.md)** - 设计审查清单
|
||||
- 设计完整性检查
|
||||
- 边缘场景处理
|
||||
- 风险评估与缓解
|
||||
|
||||
## 🎯 核心特性
|
||||
|
||||
- ✅ 完全复用现有跟单配置参数
|
||||
- ✅ 实时市场结算(按 `endDate` 检查)
|
||||
- ✅ 严格余额检查(避免过于乐观的回测)
|
||||
- ✅ 支持按Leader筛选、按收益排序
|
||||
- ✅ 详细的交易记录和资金曲线图
|
||||
|
||||
## 📖 阅读建议
|
||||
|
||||
**产品经理**: 先阅读 PRD,再查看审查清单中的关键决策点
|
||||
|
||||
**技术负责人**: 先阅读技术设计文档,再查看审查清单评估风险
|
||||
|
||||
**开发工程师**: 按顺序阅读所有文档,重点关注技术设计的实现细节
|
||||
|
||||
## 🔄 文档版本
|
||||
|
||||
- **创建日期**: 2026-01-30
|
||||
- **最后更新**: 2026-01-30
|
||||
- **当前版本**: v1.0
|
||||
|
||||
## 📝 关键设计决策
|
||||
|
||||
### 1. 市场结算机制
|
||||
- 采用**实时检查**方式:每笔交易前检查市场 `endDate`
|
||||
- 到期即结算,资金立即释放可用于后续交易
|
||||
|
||||
### 2. 余额检查策略
|
||||
- 采用**严格模式**:仅使用 `currentBalance`,不计入持仓市值
|
||||
- 停止条件:余额 < $1 **且** 无任何持仓
|
||||
|
||||
### 3. 数据源选择
|
||||
- 优先使用系统记录的 `ProcessedTrade` 表
|
||||
- 不足时调用 Polymarket API 补充历史数据
|
||||
|
||||
### 4. 代码复用策略
|
||||
- 后端:完全复用 `CopyTradingFilterService` 的所有过滤逻辑
|
||||
- 前端:复用跟单配置表单组件
|
||||
- 数据库:配置字段与 `CopyTrading` 表保持一致
|
||||
|
||||
## 🚀 下一步
|
||||
|
||||
完成文档审查后,可以:
|
||||
1. 创建 `implementation_plan.md` 详细规划实施步骤
|
||||
2. 开始开发(数据库表 → API → 前端页面)
|
||||
3. 单元测试和集成测试
|
||||
|
||||
---
|
||||
|
||||
**文档位置**: `/Users/wrbug/polyhermes/docs/zh/backtest/`
|
||||
@@ -0,0 +1,204 @@
|
||||
# 加密市场尾盘策略 - 流程图
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ POST 创建/更新 ┌──────────────────────────┐
|
||||
│ 前端 / API │ ──────────────────────►│ CryptoTailStrategyController│
|
||||
└─────────────────┘ └──────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ CryptoTailStrategyService │
|
||||
│ create / update │
|
||||
│ save → publishEvent │
|
||||
└──────────────┬─────────────┘
|
||||
│
|
||||
┌─────────────────────────────────────────┼─────────────────────────────────────────┐
|
||||
│ CryptoTailStrategyChangedEvent │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────┐ ┌──────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ CryptoTailStrategyScheduler │ │ CryptoTailOrderbookWsService │ │ (其他监听方,如有) │
|
||||
│ @EventListener │ │ @EventListener │ └──────────────────────────────┘
|
||||
│ → runCycle() 一次(补充) │ │ → refreshAndSubscribe() │
|
||||
└──────────────┬───────────────┘ └──────────────┬───────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────┐ │
|
||||
│ CryptoTailStrategyExecution │ │ 每 25 秒 + 事件时
|
||||
│ runCycle() │ │ refreshAndSubscribe()
|
||||
│ (HTTP 拉订单簿,满足则下单) │ ▼
|
||||
└──────────────────────────────┘ ┌──────────────────────────────┐
|
||||
│ CLOB Market WebSocket │
|
||||
│ wss://.../ws/market │
|
||||
│ subscribe assets_ids │
|
||||
└──────────────┬───────────────┘
|
||||
│ book / price_change
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ onBestBid(tokenId, bestBid) │
|
||||
│ → tryTriggerWithPriceFromWs │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ CryptoTailStrategyExecution │
|
||||
│ placeOrderForTrigger │
|
||||
│ → CLOB 下单 + 写触发记录 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、策略创建/更新流程(API → 事件)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant API as Controller
|
||||
participant Svc as CryptoTailStrategyService
|
||||
participant DB as DB
|
||||
participant Event as ApplicationEventPublisher
|
||||
|
||||
API->>Svc: create(request) / update(request)
|
||||
Svc->>Svc: 参数校验(账户、窗口、价格、金额模式等)
|
||||
Svc->>DB: save(entity)
|
||||
Svc->>Event: publishEvent(CryptoTailStrategyChangedEvent)
|
||||
Svc->>API: Result.success(dto)
|
||||
```
|
||||
|
||||
- **创建**:校验通过后落库,发布 `CryptoTailStrategyChangedEvent`,返回 DTO。
|
||||
- **更新**:同上,更新实体后发布同一事件。
|
||||
- **删除**:不发布事件(策略已移除,WS 下次刷新订阅时会自然不再包含该策略)。
|
||||
|
||||
---
|
||||
|
||||
## 三、策略变更后:双路响应
|
||||
|
||||
事件发出后,两个监听方并行执行,互不阻塞:
|
||||
|
||||
| 监听方 | 动作 | 说明 |
|
||||
|--------|------|------|
|
||||
| **CryptoTailStrategyScheduler** | `onStrategyChanged` → `runCycle()` 一次 | 用 HTTP 拉订单簿做一轮检查,作为 WS 未就绪时的补充。 |
|
||||
| **CryptoTailOrderbookWsService** | `onStrategyChanged` → `refreshAndSubscribe()` | 按当前启用策略重新算 token 列表,向 WS 发送新的 `assets_ids` 订阅。 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 事件
|
||||
E[CryptoTailStrategyChangedEvent]
|
||||
end
|
||||
subgraph 调度器
|
||||
S[Scheduler.onStrategyChanged]
|
||||
R[executionService.runCycle]
|
||||
S --> R
|
||||
end
|
||||
subgraph WS服务
|
||||
W[OrderbookWsService.onStrategyChanged]
|
||||
Ref[refreshAndSubscribe]
|
||||
W --> Ref
|
||||
end
|
||||
E --> S
|
||||
E --> W
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、WebSocket 订单簿监听流程(主路径)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph 启动与连接
|
||||
A[PostConstruct init] --> B[connect]
|
||||
B --> C[OkHttp WebSocket 连接 wss://.../ws/market]
|
||||
C --> D[onOpen: refreshAndSubscribe]
|
||||
end
|
||||
|
||||
subgraph 订阅维护
|
||||
D --> E[buildSubscriptionMap]
|
||||
E --> F[遍历 enabled 策略]
|
||||
F --> G[当前周期 periodStartUnix]
|
||||
G --> H[slug = prefix-periodStartUnix]
|
||||
H --> I[Gamma getEventBySlug]
|
||||
I --> J[得到 tokenIds]
|
||||
J --> K[tokenId → List of WsBookEntry]
|
||||
K --> L[发送 type=MARKET, assets_ids=[...]]
|
||||
T[每 25 秒 @Scheduled] --> E
|
||||
EV[onStrategyChanged] --> E
|
||||
end
|
||||
|
||||
subgraph 收消息与触发
|
||||
M[onMessage: book / price_change]
|
||||
M --> N[解析 asset_id, best_bid]
|
||||
N --> O[onBestBid tokenId, bestBid]
|
||||
O --> P[查 tokenToEntries 得到策略列表]
|
||||
P --> Q[筛时间窗内]
|
||||
Q --> R[scope.launch tryTriggerWithPriceFromWs]
|
||||
R --> S[placeOrderForTrigger]
|
||||
end
|
||||
|
||||
L --> M
|
||||
```
|
||||
|
||||
- **buildSubscriptionMap**:只包含「当前时间仍在窗口内」的策略(`nowSeconds < windowEnd`),并只订阅这些策略对应周期的 token。
|
||||
- **onBestBid**:再按当前时间过滤一次时间窗,对每个命中策略在协程里调用 `tryTriggerWithPriceFromWs`,内部会查「本周期是否已触发」和价格区间,通过则 `placeOrderForTrigger`。
|
||||
|
||||
---
|
||||
|
||||
## 五、执行层:下单条件与顺序(ExecutionService)
|
||||
|
||||
无论来自 **runCycle(HTTP)** 还是 **tryTriggerWithPriceFromWs(WS)**,最终都走同一套下单逻辑。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph runCycle 入口
|
||||
A[runCycle] --> B[findAllByEnabledTrue]
|
||||
B --> C[processStrategy 每个策略]
|
||||
C --> D[在时间窗? 本周期已触发?]
|
||||
D --> E[Gamma getEventBySlug]
|
||||
E --> F[HTTP getOrderbook 两个 token]
|
||||
F --> G[第一个 bestBid 在 minPrice~maxPrice?]
|
||||
G --> H[placeOrderForTrigger]
|
||||
end
|
||||
|
||||
subgraph tryTriggerWithPriceFromWs 入口
|
||||
I[WS onBestBid] --> J[tryTriggerWithPriceFromWs]
|
||||
J --> K[本周期已触发? bestBid 在区间?]
|
||||
K --> H
|
||||
end
|
||||
|
||||
subgraph placeOrderForTrigger 统一
|
||||
H --> L[账户、API 凭证]
|
||||
L --> M[余额、下单金额]
|
||||
M --> N[最优价、数量]
|
||||
N --> O[签名、CLOB 下单]
|
||||
O --> P[保存 CryptoTailStrategyTrigger]
|
||||
end
|
||||
```
|
||||
|
||||
- **每周期最多触发一次**:由 `triggerRepository.findByStrategyIdAndPeriodStartUnix` 保证。
|
||||
- **价格区间**:`minPrice ≤ bestBid ≤ maxPrice` 才触发。
|
||||
- **时间窗**:仅当 `windowStart ≤ now < windowEnd`(以当前周期的 `periodStartUnix` 为基准)才参与检查/下单。
|
||||
|
||||
---
|
||||
|
||||
## 六、关键数据流小结
|
||||
|
||||
| 阶段 | 输入 | 输出/动作 |
|
||||
|------|------|-----------|
|
||||
| 创建/更新策略 | API 请求体 | 落库 + 发布 `CryptoTailStrategyChangedEvent` |
|
||||
| 事件 → 调度器 | 事件 | 执行一次 `runCycle()`(HTTP 拉订单簿,满足则下单) |
|
||||
| 事件 → WS 服务 | 事件 | `refreshAndSubscribe()`,更新订阅的 `assets_ids` |
|
||||
| 定时刷新订阅 | 每 25 秒 | `refreshAndSubscribe()`,保证新周期、新策略被订阅 |
|
||||
| WS 收 book/price_change | asset_id, best_bid | `onBestBid` → 时间窗内策略 → `tryTriggerWithPriceFromWs` → 未触发且价格在区间则 `placeOrderForTrigger` |
|
||||
| placeOrderForTrigger | 策略、周期、token、outcome、价格 | 账户/余额/价格/签名 → CLOB 下单 → 写触发记录 |
|
||||
|
||||
---
|
||||
|
||||
## 七、涉及类与职责
|
||||
|
||||
| 类 | 职责 |
|
||||
|----|------|
|
||||
| **CryptoTailStrategyController** | 接收 list/create/update/delete/triggers/marketOptions 的 POST。 |
|
||||
| **CryptoTailStrategyService** | 策略 CRUD、校验、发布 `CryptoTailStrategyChangedEvent`。 |
|
||||
| **CryptoTailStrategyScheduler** | 监听策略变更事件,执行一次 `runCycle()`。 |
|
||||
| **CryptoTailOrderbookWsService** | 连接 CLOB Market WS、维护订阅(事件 + 每 25 秒)、处理 book/price_change、调用 `tryTriggerWithPriceFromWs`。 |
|
||||
| **CryptoTailStrategyExecutionService** | `runCycle()`(HTTP 路径)、`tryTriggerWithPriceFromWs()`(WS 路径)、`placeOrderForTrigger()`(统一下单与写触发记录)。 |
|
||||
@@ -0,0 +1,178 @@
|
||||
# 加密市场尾盘策略 - 5/15 分钟市场数据获取说明
|
||||
|
||||
> 前端 UI 与交互详见 `crypto-tail-strategy-ui-spec.md`。
|
||||
|
||||
## 1. 数据源
|
||||
|
||||
- **Gamma API**:`https://gamma-api.polymarket.com`
|
||||
- 用于获取市场元数据:conditionId、开始/结束时间、标题、clobTokenIds 等。
|
||||
- 无需鉴权。
|
||||
|
||||
## 2. 市场类型与 Slug 规则
|
||||
|
||||
| 类型 | Event Slug 规则 | 周期长度 | 说明 |
|
||||
|------|-----------------|----------|------|
|
||||
| Bitcoin 5 分钟 | `btc-updown-5m-{periodStartUnix}` | 5 min | periodStartUnix 为 5 分钟边界的 Unix 时间戳(秒) |
|
||||
| Bitcoin 15 分钟 | `btc-updown-15m-{periodStartUnix}` | 15 min | periodStartUnix 为 15 分钟边界:`(now // 900) * 900` |
|
||||
| Ethereum 5 分钟 | `eth-updown-5m-{ts}` | 5 min | 暂未验证是否在平台上线;如有可按相同规则推导 |
|
||||
| Ethereum 15 分钟 | `eth-updown-15m-{ts}` | 15 min | 已验证存在 |
|
||||
|
||||
- 5 分钟周期:按 **300 秒** 对齐;当前周期起点可用 `(nowUnix // 300) * 300`,下一周期为 `+300`。
|
||||
- 15 分钟周期:按 **900 秒** 对齐;当前周期起点可用 `(nowUnix // 900) * 900`。slug 中的时间戳即为周期起始 Unix 秒;周期结束以 API 的 endDate 为准。
|
||||
|
||||
## 3. 获取单个周期市场(开始时间、结束时间)
|
||||
|
||||
### 3.1 请求
|
||||
|
||||
```bash
|
||||
# 5 分钟 - 当前周期(示例时间戳需替换为当前周期起点)
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"
|
||||
|
||||
# 15 分钟 - 需使用实际存在的时间戳(可从前端或历史 slug 得知)
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1770882300"
|
||||
```
|
||||
|
||||
### 3.2 响应结构(与开始/结束时间相关)
|
||||
|
||||
- **Event 层**:`startDate`、`endDate`(ISO 8601)。
|
||||
- **markets[]**:每个市场有 `conditionId`、`question`、`startDate`、`endDate`、`clobTokenIds` 等。
|
||||
|
||||
**周期本身**:例如 5 分钟市场 "1:30PM-1:35PM ET",理应是 **startDate = 1:30 PM**、**endDate = 1:35 PM**。
|
||||
|
||||
**API 返回值与周期起止的对应关系(已用脚本验证)**:
|
||||
|
||||
| 字段 | 是否等于周期起止 | 说明 |
|
||||
|------|------------------|------|
|
||||
| **endDate**(Event / Market) | **是**,等于周期结束时间(如 1:35 PM) | API 的 endDate 即周期终点,可直接用。 |
|
||||
| **startDate**(Event / Market) | **否**,不等于周期开始时间(1:30 PM) | API 的 startDate 是市场创建/开放时间,不是周期起点,故**不能**当 1:30 PM 用。 |
|
||||
|
||||
**正确做法**:周期起点(1:30 PM)用 **slug 中的时间戳** 推导;周期终点(1:35 PM)用 API 的 **endDate**。
|
||||
|
||||
- **5 分钟**:周期开始 = `slug_ts`(即 slug 中的 Unix 秒),周期结束 = `endDate`(或 `slug_ts + 300`)。
|
||||
- **15 分钟**:周期开始 = `slug_ts`,周期结束 = `endDate`(或 `slug_ts + 900`)。
|
||||
|
||||
**示例(脚本输出解读)**:若 current 5m slug 为 `btc-updown-5m-1771007400`、title 为 "1:30PM-1:35PM ET"、endDate 为 `2026-02-13T18:35:00Z`,则 1771007400 = 18:30 UTC = 1:30 PM ET,即周期起点;endDate 18:35 UTC = 1:35 PM ET = 周期终点。next 5m slug 为 1771007700 = 1771007400 + 300,即下一周期起点。15m 同理:current slug 1771007400(1:30–1:45 PM ET),next 1771008300 = 1771007400 + 900(1:45–2:00 PM ET)。
|
||||
|
||||
## 4. 如何列出“当前及未来”5/15 分钟市场
|
||||
|
||||
- Gamma 未提供按“5 分钟 / 15 分钟”或“Up or Down”的 tag 筛选;`tag_id=744`(cryptocurrency)未返回这些短期市场。
|
||||
- **可行方式**:
|
||||
1. **按周期时间戳生成 slug 并逐个请求**
|
||||
- 5 分钟:当前周期 `ts = (nowUnix // 300) * 300`,下一周期 `ts + 300`,再下一周期 `ts + 600` …
|
||||
- 15 分钟:`ts = (nowUnix // 900) * 900`,然后 `ts + 900`、`ts + 1800` …
|
||||
- 请求 `GET /events/slug/btc-updown-5m-{ts}` 或 `btc-updown-15m-{ts}`;若返回 404 表示该周期尚未创建或已过期,可跳过。
|
||||
2. **用户选择“市场”时**:若前端/后端已知“系列”(如 Bitcoin 5 minute),则只需约定 slug 前缀(`btc-updown-5m`、`btc-updown-15m`)与周期长度(300/900),按当前时间计算周期起点并请求对应 slug 即可得到当前周期的 conditionId、startDate、endDate;下一周期同理。
|
||||
|
||||
## 5. 周期边界与“每周期监听”
|
||||
|
||||
- **周期开始**:使用 **slug 中的时间戳** `periodStartUnix`(即请求 slug 时的 `btc-updown-5m-{ts}` 里的 `ts`),不要用 API 返回的 startDate。
|
||||
- **周期结束**:使用 API 返回的 **event.endDate 或 market.endDate**(与 slug_ts + 300/900 一致)。
|
||||
- 判断“当前是否在该周期内”:`periodStartUnix <= nowUnix < endDateUnix`,其中 `periodStartUnix` 从 slug 得到,`endDateUnix` 由 endDate 解析。
|
||||
- 策略“每周期开始时开始监听”:当 `now` 跨过当前周期的 endDate(或下一周期的 periodStartUnix)时,视为新周期开始,重置“本周期是否已触发”等状态。
|
||||
|
||||
## 6. 如何保证每个周期的市场都能正确处理
|
||||
|
||||
### 6.1 用“当前时间”唯一确定当前周期
|
||||
|
||||
- 服务端只用**当前 Unix 时间**推导周期,不依赖 API 的 startDate。
|
||||
- **5 分钟**:`periodStartUnix = (nowUnix / 300) * 300`(整除)。
|
||||
- **15 分钟**:`periodStartUnix = (nowUnix / 900) * 900`。
|
||||
- 同一时刻算出的 `periodStartUnix` 唯一,对应唯一 slug(如 `btc-updown-5m-{periodStartUnix}`),从而对应唯一市场(conditionId、tokenIds、endDate)。
|
||||
|
||||
### 6.2 按周期拉取市场并切换
|
||||
|
||||
- **首次进入或策略启用**:用当前的 `periodStartUnix` 拼 slug,请求 Gamma `GET /events/slug/{slug}`,拿到该周期的 conditionId、endDate、clobTokenIds;用 endDate 解析得到 `endDateUnix`。
|
||||
- **每次需要判断“是否还在本周期”或“是否该下单”时**:先算当前 `currentPeriodStart = (nowUnix / interval) * interval`(interval 为 300 或 900)。若 `currentPeriodStart` 大于上一笔使用的 `periodStartUnix`,说明已进入**下一周期**:
|
||||
- 用新的 `currentPeriodStart` 拼 slug,重新请求 Gamma,拿到**新周期**的 conditionId、endDate、clobTokenIds;
|
||||
- 用新周期的 tokenIds 订阅/拉取订单簿,用新 endDate 作为本周期结束时间;
|
||||
- 重置本周期“是否已触发”等状态,避免把上一周期的状态带到新周期。
|
||||
- **周期内**:始终用**本周期**的 conditionId、tokenIds、endDate 做价格监听与下单,不要混用上一周期的数据。
|
||||
|
||||
### 6.3 周期切换时机与 404 处理
|
||||
|
||||
- **切换时机**:以 `nowUnix >= endDateUnix` 或 `(nowUnix / interval) * interval > periodStartUnix` 作为“本周期已结束”,立刻按 6.2 用新 `periodStartUnix` 拉新周期市场。
|
||||
- **新周期市场尚未创建(404)**:Gamma 可能稍晚才创建下一周期 event。若请求 slug 返回 404,可短间隔重试(如 5–15 秒)或等到下一整点/对齐点再试;重试时仍用**同一** `periodStartUnix`,避免用错周期。若长时间 404,可记录日志并跳过该周期,下一周期再正常拉取。
|
||||
|
||||
### 6.4 下单失败重试规则(每周期最多下单一次)
|
||||
|
||||
- 市价单提交失败时,**最多重试 2 次**(即 1 次初始 + 2 次重试,共 3 次尝试)。
|
||||
- 若 3 次均失败:
|
||||
- 本周期**不再**对该 outcome 下单;
|
||||
- 记录失败原因与状态(便于审计与前端展示触发记录)。
|
||||
- 周期切换时(6.2)重置为“未下单”,仅对新周期做新的判断与尝试。
|
||||
|
||||
### 6.5 去重与幂等(每周期最多触发一次)
|
||||
|
||||
- 以「策略 + 周期」唯一标识一次执行,例如 `(strategyId, periodStartUnix)` 或 `(accountId, slugPrefix, periodStartUnix)`。
|
||||
- 在数据库或内存中记录:本周期是否已触发、是否已下单。若已触发,同一周期内不再根据价格区间下单。
|
||||
- 周期切换时(6.2)清空或更新为“新周期未触发”,只对新周期的 conditionId/tokenIds 做监听与下单。
|
||||
|
||||
### 6.6 时间区间(窗口)内才触发
|
||||
|
||||
- 策略可配置**时间区间**:从周期起点起算的「开始秒数」与「结束秒数」,例如 5 分钟市场可选 0~300 秒内的一段,15 分钟市场可选 0~900 秒内的一段(对应前端“分+秒”下拉,如 3 分 0 秒~12 分 0 秒即 180~720 秒)。
|
||||
- **执行规则**:仅当 `periodStartUnix + windowStartSeconds <= nowUnix < periodStartUnix + windowEndSeconds` 时,才根据 7.1 判断价格是否进入 [minPrice, maxPrice] 并执行下单;**区间外不进行价格判断与下单**。
|
||||
- 存储:策略表(或配置)中保存 `windowStartSeconds`、`windowEndSeconds`(整数,单位秒);校验:`windowStartSeconds <= windowEndSeconds`,且不超过周期长度(5min 市场 ≤ 300,15min 市场 ≤ 900)。详见 [UI 规格 - 时间区间](crypto-tail-strategy-ui-spec.md)。
|
||||
|
||||
### 6.7 小结
|
||||
|
||||
| 要点 | 做法 |
|
||||
|------|------|
|
||||
| 周期唯一性 | 用 `(nowUnix / interval) * interval` 得到 periodStartUnix,再拼 slug,不依赖 API startDate。 |
|
||||
| 周期数据 | 每周期用**该周期**的 slug 请求 Gamma,使用返回的 conditionId、endDate、clobTokenIds。 |
|
||||
| 切换 | 当 `nowUnix >= endDateUnix` 或当前算出的 periodStartUnix 变化时,拉取新周期并重置状态。 |
|
||||
| 404 | 同一 periodStartUnix 重试;长时间 404 可跳过该周期并打日志。 |
|
||||
| 下单失败 | 失败后最多重试 2 次;仍失败则本周期不再下单并记录状态。 |
|
||||
| 每周期只触发一次 | 用 (策略, periodStartUnix) 做去重,周期切换时重置“已触发”状态。 |
|
||||
| 时间区间 | 仅当 periodStartUnix + windowStartSeconds ≤ now < periodStartUnix + windowEndSeconds 时做价格判断与下单;区间外不处理。 |
|
||||
|
||||
按上述方式,每个周期都会对应到正确的 slug、正确的市场与 endDate,并在周期结束时切换到下一周期;仅在配置的时间窗口内才根据价格触发下单,避免混周期或漏周期。
|
||||
|
||||
## 7. 与订单簿 / 价格的关系
|
||||
|
||||
- 价格由 **CLOB 订单簿**(或 WebSocket)获取,不依赖 Gamma;Gamma 仅提供市场元数据。
|
||||
- 使用 market.conditionId 与 markets[].clobTokenIds 解析出 tokenId,再订阅或请求该 token 的订单簿即可得到实时价格,用于区间判断与市价下单。
|
||||
|
||||
### 7.1 价格区间与「反方向」判断(如 minPrice = 0.92)
|
||||
|
||||
二元市场(Up or Down)有两个 outcome:通常 outcomeIndex 0 = Up,1 = Down,各对应一个 tokenId 和订单簿。
|
||||
|
||||
- **配置含义**:用户配置 minPrice = 0.92(及可选 maxPrice,默认 1)表示「当**某个 outcome 的价格**落在 [0.92, 1] 时触发市价买入**该** outcome」。
|
||||
- **不预先选方向**:不需要用户选「买 Up 还是买 Down」;谁的价格先进入区间就买谁。
|
||||
- **订单簿取价方式(与现有市价单逻辑一致)**:
|
||||
- 对每个 outcome,取该 tokenId 订单簿的 **bestBid**(最高买入价)作为当前价格用于区间判断;若取价规则与现有市价买入逻辑不同,请以系统现有规则为准并在实现文档中写明。
|
||||
- **判断方式**:
|
||||
- 同时取**两个 outcome** 的当前价格(按上述取价规则)。
|
||||
- 对 **outcome 0**:若 `price0 >= minPrice && price0 <= maxPrice` → 满足触发条件,买入 outcome 0(Up)。
|
||||
- 对 **outcome 1**:若 `price1 >= minPrice && price1 <= maxPrice` → 满足触发条件,买入 outcome 1(Down)。
|
||||
- **反方向**:「反方向」即另一个 outcome。例如若本轮已因 outcome 0 进入 [0.92, 1] 而买入 Up,则本周期内**不再**检查 outcome 1 是否也进入区间、也不再买 Down;反之若先触发的是 outcome 1(Down),则本周期不再买 Up。实现上:一旦本周期已对**任意一个** outcome 触发并下单,即标记本周期已触发,不再对**另一个 outcome(反方向)**做区间判断与下单。
|
||||
- **同一时刻两边都进区间**:若同一时刻 Up 和 Down 的价格都在 [0.92, 1](理论上二元市场 Up+Down≈1 时不会同时 ≥0.92,但若出现),可约定按 outcomeIndex 优先(如先判 0 再判 1)或先到先得,只执行一笔买入,本周期不再买反方向。
|
||||
|
||||
总结:配置 0.92 时,对**两个方向**都做同一区间判断;先满足区间的那一侧触发买入,另一侧即为反方向,本周期不再触发。
|
||||
|
||||
## 8. 验证方式
|
||||
|
||||
**startDate/endDate 验证结论**:已用脚本对比 slug 时间戳与 API 返回的 startDate/endDate。**endDate 等于当前周期结束时间**;**startDate 不等于周期起始点**(为市场创建/开放时间),周期起始点应以 slug 中的时间戳为准。详见上文 3.2、5 节。
|
||||
|
||||
### 8.1 脚本(推荐)
|
||||
|
||||
项目内脚本,会请求当前/下一 5 分钟与 15 分钟 BTC 市场并打印 conditionId、startDate、endDate、clobTokenIds:
|
||||
|
||||
```bash
|
||||
python3 scripts/fetch_crypto_minute_markets.py
|
||||
```
|
||||
|
||||
### 8.2 curl 示例
|
||||
|
||||
```bash
|
||||
# 5 分钟 - 当前或下一周期(时间戳需替换为实际周期起点)
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"
|
||||
|
||||
# 15 分钟 - 当前周期(时间戳需替换为实际周期起点)
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1771006500"
|
||||
|
||||
# 15 分钟 - 历史存在的事件
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/btc-updown-15m-1770882300"
|
||||
curl -s "https://gamma-api.polymarket.com/events/slug/eth-updown-15m-1770801300"
|
||||
```
|
||||
|
||||
若返回 403,可加 User-Agent:`curl -s -H "User-Agent: PolymarketBot/1.0" "https://gamma-api.polymarket.com/events/slug/btc-updown-5m-1771007100"`
|
||||
@@ -0,0 +1,247 @@
|
||||
# 尾盘策略 - 最小价差参数流程分析
|
||||
|
||||
## 一、需求摘要
|
||||
|
||||
在现有尾盘策略上增加**最小价差**参数:当策略条件(时间窗、价格区间)满足时,再判断**当前周期 Binance K 线的开盘价与收盘价价差**是否满足最小价差;满足才下单,不满足则等待,直到价差满足再下单。
|
||||
|
||||
- **后端**:需订阅币安对应币对(如 BTC/USDC)的 K 线,维护当前周期的**开盘价**与**实时收盘价**,并在触发时做价差校验。
|
||||
- **前端**:可配置三种场景——无、固定、自动(见下)。
|
||||
|
||||
---
|
||||
|
||||
## 二、前端配置场景
|
||||
|
||||
| 场景 | 配置方式 | 校验逻辑 |
|
||||
|------|----------|----------|
|
||||
| **无** | 不进行价差校验 | 与现有一致:仅判断时间窗 + 价格区间,满足即下单。 |
|
||||
| **固定** | 用户输入一个固定价差(如 30) | 当 \|收盘价 − 开盘价\| ≥ 该固定值时,校验通过,再下单。 |
|
||||
| **自动** | 由系统根据历史数据计算最小价差 | 见下文「自动模式计算逻辑」;得到数值后,后续与固定模式一致:\|收盘价 − 开盘价\| ≥ 计算值 则通过。 |
|
||||
|
||||
### 自动模式计算逻辑
|
||||
|
||||
- 通过币安 API 获取**历史 20 根** K 线(与策略周期一致:5m 取 5m K 线,15m 取 15m K 线)。
|
||||
- **下单方向 = Down**(outcomeIndex = 1):只取「收盘价 < 开盘价」的 K 线,得到价差序列(开盘价 − 收盘价)。
|
||||
- **下单方向 = Up**(outcomeIndex = 0):只取「收盘价 > 开盘价」的 K 线,得到价差序列(收盘价 − 开盘价)。
|
||||
- **异常值剔除**:对上述价差序列做异常值过滤(见下文「异常值剔除」),再用**剩余样本**求平均价差,乘以系数 **80%** 得到最小价差;后续用该值做 \|收盘价 − 开盘价\| ≥ 该值 的校验。
|
||||
- **历史数据获取时机**:**在该周期开始时就拉取并计算**,不在保存策略时计算。订单簿 WS 在周期开始时刷新订阅(含每 25 秒或周期切换时的 refreshAndSubscribe),此时对当前周期内所有启用且为 AUTO 的策略,按 (intervalSeconds, periodStartUnix) 预拉该周期前 20 根已收盘 K 线并计算 minSpreadUp/minSpreadDown 写入缓存;该周期内触发时直接用缓存,无需在触发时再调 REST。
|
||||
|
||||
### 异常值剔除
|
||||
|
||||
- **目的**:避免少数极端 K 线(如 14 组价差在 50 以内、1 组价差 200)拉高平均价差,导致最小价差偏大、难以触发。
|
||||
- **做法**:在按方向得到价差序列后,先**剔除异常值**,再对剩余价差求平均并 × 0.8。
|
||||
- **推荐方法:IQR(四分位距)**
|
||||
- 对价差序列排序,计算 Q1(25% 分位)、Q3(75% 分位)、IQR = Q3 − Q1。
|
||||
- 保留区间 **[Q1 − 1.5×IQR, Q3 + 1.5×IQR]** 内的价差,剔除该区间外的点。
|
||||
- 示例:15 组价差,14 组在 50 以内、1 组为 200 → 200 会超出上界被剔除,只用 14 组参与平均。
|
||||
- **边界与降级**
|
||||
- 若剔除后剩余样本数过少(如 < 3),则**不剔除**:用全部价差样本求平均 × 0.8。
|
||||
- 若无满足方向的 K 线(如 20 根里没有 close < open),仍按原文档降级处理(全量 \|close−open\| 或返回 0)。
|
||||
|
||||
---
|
||||
|
||||
## 三、整体流程(含价差校验)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. 数据源与订阅 │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ • CLOB 订单簿 WS(现有):Polymarket 订单簿 → bestBid。 │
|
||||
│ • 币安 K 线 WS(新增):订阅 BTCUSDC 对应周期(5m/15m),维护「当前周期」的开盘价 │
|
||||
│ open、实时收盘价 close(每根 K 线未收盘前 close 会持续更新)。 │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 2. 触发入口(与现有一致) │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ • 入口 A:CryptoTailOrderbookWsService.onBestBid(tokenId, bestBid) │
|
||||
│ • 入口 B:CryptoTailStrategyExecutionService.runCycle()(HTTP 拉订单簿) │
|
||||
│ 两者在「时间窗 + 价格区间 + 本周期未触发」通过后,都会调用执行层「尝试下单」。 │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 3. 执行层增加「价差校验」 │
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ 在现有 tryTriggerWithPriceFromWs / runCycle → placeOrderForTrigger 之前增加: │
|
||||
│ │
|
||||
│ if (策略.minSpreadMode == NONE) → 直接进入 placeOrderForTrigger。 │
|
||||
│ else: │
|
||||
│ • 从「币安 K 线服务」取当前周期(与 strategy.intervalSeconds 对齐)的 open、 │
|
||||
│ close(实时)。 │
|
||||
│ • 若取不到 open/close(例如该周期尚未有数据)→ 本轮不下单,等待下次 WS 更新。 │
|
||||
│ • 计算 effectiveMinSpread: │
|
||||
│ - FIXED:effectiveMinSpread = 策略.minSpreadValue(用户填的固定值) │
|
||||
│ - AUTO:effectiveMinSpread = 按当前下单方向(outcomeIndex)取「自动计算 │
|
||||
│ 的最小价差」(见下节;若尚未计算则先拉 20 根历史 K 线并计算、缓存)。 │
|
||||
│ • 若 |close − open| < effectiveMinSpread → 本轮不下单,等待价差满足。 │
|
||||
│ • 若 |close − open| >= effectiveMinSpread → 通过价差校验,进入 │
|
||||
│ placeOrderForTrigger(与现有逻辑一致:预签/签名、提交 CLOB 订单、写触发记录)。│
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **「等待价差满足」**:不主动轮询;下次 CLOB 订单簿或币安 K 线有推送时,会再次进入上述判断,此时 close 可能已更新,价差可能已满足,再决定是否下单。
|
||||
- **每周期最多触发一次**:仍由现有「本周期是否已触发」保证;价差不满足时**不写触发记录**,也不占「已触发」名额,直到某次检查同时满足价格区间与价差后才下单并标记已触发。
|
||||
|
||||
---
|
||||
|
||||
## 四、自动模式:何时拉历史、如何算、如何用
|
||||
|
||||
- **何时拉 20 根历史 K 线并计算**
|
||||
- **在该周期开始时就预计算**,不在保存策略时计算。
|
||||
- 订单簿 WS 在**周期开始时**会刷新订阅(`refreshAndSubscribe`:每 25 秒或检测到周期切换时),此时对当前周期内所有启用且 minSpreadMode=AUTO 的策略,按 `(intervalSeconds, periodStartUnix)` 异步拉取该周期前 20 根已收盘 K 线(REST `endTime = periodStartUnix * 1000`),按 Up/Down 分别算 avgSpread × 0.8(含 IQR 剔除)并写入缓存。该周期内后续触发时直接用缓存,**不在触发时再调 REST**。
|
||||
- 若某周期未做预计算(如服务刚启动且尚未到刷新时机),触发时仍会按需调用 `computeAndCache` 并缓存,保证逻辑正确。
|
||||
- 前端「自动最小价差」接口仅作**预览**,实际下单校验不依赖该接口。
|
||||
|
||||
- **计算细节**
|
||||
- 历史 20 根:币安 REST `GET /api/v3/klines?symbol=BTCUSDC&interval=5m|15m&limit=20`(或 21 取前 20 根已收盘),每根格式为 [openTime, open, high, low, close, ...]。
|
||||
- **Down(outcomeIndex=1)**:筛选 close < open,价差 = open − close,得到价差序列 → **异常值剔除(IQR)** → 对剩余价差求平均,再 × 0.8 → minSpreadDown。
|
||||
- **Up(outcomeIndex=0)**:筛选 close > open,价差 = close − open,得到价差序列 → **异常值剔除(IQR)** → 对剩余价差求平均,再 × 0.8 → minSpreadUp。
|
||||
- **异常值剔除**:见上文「异常值剔除」;剔除后再平均。若剔除后剩余样本 < 3,则不剔除,用全部价差样本求平均。
|
||||
- 若无满足方向的 K 线(例如 20 根里没有一根 close < open),可降级:用全部 20 根的 |close−open| 平均 × 0.8,或返回 0/不校验,具体产品可定。
|
||||
|
||||
- **触发时使用**
|
||||
- 当前要下单的是 outcomeIndex(0=Up, 1=Down),取对应的 minSpreadUp 或 minSpreadDown 作为 effectiveMinSpread,再与 |close − open| 比较。
|
||||
|
||||
---
|
||||
|
||||
## 五、后端模块与数据流
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| **BinanceKlineService(新)** | 1)订阅币安 WS:BTCUSDC 的 5m、15m K 线流(可按需只订阅有策略使用的周期)。<br>2)维护「当前周期」数据:以 periodStartUnix(或 K 线 t 对齐)为 key,存 (open, close);K 线 WS 推送时更新 close,新周期首条推送时更新 open。<br>3)提供 getCurrentOpenClose(symbol, intervalSeconds, periodStartUnix) → (open, close)?,供执行层价差校验使用。 |
|
||||
| **BinanceKlineAutoSpreadService 或合入上者(新)** | 1)按**周期**拉取:以 periodStartUnix 为界,REST 拉取该周期前的 20 根已收盘 K 线。<br>2)按 Up/Down 得到价差序列 → **IQR 异常值剔除** → 对剩余价差求平均 × 0.8,缓存 (intervalSeconds, periodStartUnix) → (minSpreadUp, minSpreadDown)。<br>3)提供 getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex) 与 computeAndCache(intervalSeconds, periodStartUnix)。**周期开始时**由 CryptoTailOrderbookWsService 在 refreshAndSubscribe 后对当前周期内 AUTO 策略预调 computeAndCache;触发时直接用缓存,未命中时再按需计算。 |
|
||||
| **CryptoTailStrategy(实体)** | 新增字段建议:minSpreadMode(NONE/FIXED/AUTO)、minSpreadValue(固定时使用;AUTO 时可为空或存上次计算值用于展示)。 |
|
||||
| **CryptoTailStrategyExecutionService(现有)** | 在 tryTriggerWithPriceFromWs 与 runCycle 分支中,在调用 placeOrderForTrigger 前:若 minSpreadMode != NONE,则取 open/close 与 effectiveMinSpread,校验 \|close−open\| >= effectiveMinSpread;不通过则 return,不调用 placeOrderForTrigger。 |
|
||||
| **CryptoTailOrderbookWsService(现有)** | 仍只根据 CLOB bestBid 触发;价差校验在执行层统一做。**新增**:refreshAndSubscribe 完成后,对当前周期内所有启用且 minSpreadMode=AUTO 的策略,异步调用 BinanceKlineAutoSpreadService.computeAndCache,在周期开始即预计算最小价差。 |
|
||||
|
||||
- **币安 K 线与周期对齐**
|
||||
- 策略周期:periodStartUnix 为秒(如 5m 周期 = 300 的倍数,15m = 900 的倍数)。
|
||||
- 币安 K 线:t 为毫秒,同一周期:t_ms = periodStartUnix * 1000。
|
||||
- 用 (intervalSeconds, periodStartUnix) 或 (interval, t_ms) 对齐即可从 BinanceKlineService 取到「当前周期」的 open 和实时 close。
|
||||
|
||||
---
|
||||
|
||||
## 六、固定(FIXED)与自动(AUTO)时序图
|
||||
|
||||
### 6.1 固定(FIXED)时序图
|
||||
|
||||
固定模式:用户保存策略时写入 `minSpreadValue`(如 30);触发时直接用该值与当前周期 \|close−open\| 比较,不拉历史 K 线。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant API as Controller
|
||||
participant Svc as CryptoTailStrategyService
|
||||
participant DB as 数据库
|
||||
participant CLOB_WS as CLOB 订单簿 WS
|
||||
participant Orderbook as CryptoTailOrderbookWsService
|
||||
participant Exec as CryptoTailStrategyExecutionService
|
||||
participant BinanceWS as BinanceKlineService
|
||||
participant CLOB as Polymarket CLOB
|
||||
|
||||
User->>API: 保存策略 minSpreadMode=FIXED, minSpreadValue=30
|
||||
API->>Svc: create/update
|
||||
Svc->>DB: 写入 min_spread_mode, min_spread_value
|
||||
Svc-->>API: 成功
|
||||
API-->>User: 成功
|
||||
|
||||
Note over BinanceWS: 后台持续:币安 K 线 WS 更新当前周期 (open, close)
|
||||
|
||||
CLOB_WS->>Orderbook: onMessage(book/price_change) → bestBid
|
||||
Orderbook->>Orderbook: 时间窗内?价格在 [min,max]?本周期未触发?
|
||||
Orderbook->>Exec: tryTriggerWithPriceFromWs(strategy, periodStartUnix, ..., bestBid)
|
||||
Exec->>Exec: mutex 锁
|
||||
Exec->>Exec: 本周期已触发?→ 是则 return
|
||||
Exec->>Exec: passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)
|
||||
Exec->>Exec: mode==FIXED → effectiveMinSpread = strategy.minSpreadValue (30)
|
||||
Exec->>BinanceWS: getCurrentOpenClose(intervalSeconds, periodStartUnix)
|
||||
BinanceWS-->>Exec: (open, close) 来自内存
|
||||
Exec->>Exec: |close−open| >= 30 ? 否 → return,不下单
|
||||
Exec->>Exec: 是 → 通过价差校验
|
||||
Exec->>Exec: ensurePeriodContext → placeOrderForTrigger
|
||||
Exec->>CLOB: 提交订单
|
||||
CLOB-->>Exec: orderId
|
||||
Exec->>DB: 写入触发记录 (本周期已触发)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.2 自动(AUTO)时序图
|
||||
|
||||
自动模式:不在保存策略时计算。**在该周期开始时就预计算**(订单簿 WS 刷新订阅时对该周期内 AUTO 策略异步拉 20 根历史 K 线并计算、缓存);触发时直接用缓存,同一周期内复用。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant API as Controller
|
||||
participant Svc as CryptoTailStrategyService
|
||||
participant DB as 数据库
|
||||
participant CLOB_WS as CLOB 订单簿 WS
|
||||
participant Orderbook as CryptoTailOrderbookWsService
|
||||
participant Exec as CryptoTailStrategyExecutionService
|
||||
participant BinanceWS as BinanceKlineService
|
||||
participant AutoSpread as BinanceKlineAutoSpreadService
|
||||
participant BinanceREST as 币安 REST API
|
||||
participant CLOB as Polymarket CLOB
|
||||
|
||||
User->>API: 保存策略 minSpreadMode=AUTO(不填 minSpreadValue)
|
||||
API->>Svc: create/update
|
||||
Svc->>DB: 写入 min_spread_mode=AUTO
|
||||
Svc-->>API: 成功
|
||||
API-->>User: 成功
|
||||
|
||||
Note over BinanceWS: 后台持续:币安 K 线 WS 更新当前周期 (open, close)
|
||||
|
||||
CLOB_WS->>Orderbook: onMessage → bestBid
|
||||
Orderbook->>Orderbook: 时间窗 + 价格区间 + 本周期未触发 ✓
|
||||
Orderbook->>Exec: tryTriggerWithPriceFromWs(strategy, periodStartUnix, ..., bestBid)
|
||||
Exec->>Exec: mutex 锁
|
||||
Exec->>Exec: passMinSpreadCheck(strategy, periodStartUnix, outcomeIndex)
|
||||
Exec->>BinanceWS: getCurrentOpenClose(intervalSeconds, periodStartUnix)
|
||||
BinanceWS-->>Exec: (open, close)
|
||||
Note over Orderbook,AutoSpread: 周期开始时 refreshAndSubscribe 已对该周期预计算(见下)
|
||||
Exec->>AutoSpread: getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)
|
||||
AutoSpread->>AutoSpread: 查缓存 (intervalSeconds, periodStartUnix) → 命中(周期开始已预计算)
|
||||
AutoSpread-->>Exec: effectiveMinSpread
|
||||
Exec->>Exec: |close−open| >= effectiveMinSpread ? 否 → return
|
||||
Exec->>Exec: 是 → 通过价差校验
|
||||
Exec->>Exec: placeOrderForTrigger → CLOB 下单
|
||||
Exec->>DB: 写入触发记录
|
||||
|
||||
Note over Orderbook,AutoSpread: 周期开始时(refreshAndSubscribe 或周期切换)
|
||||
Orderbook->>Orderbook: refreshAndSubscribe() → buildSubscriptionMap() → newMap
|
||||
Orderbook->>Orderbook: precomputeAutoMinSpreadForCurrentPeriods(newMap)
|
||||
Orderbook->>AutoSpread: computeAndCache(intervalSeconds, periodStartUnix) [异步]
|
||||
AutoSpread->>BinanceREST: GET /api/v3/klines?symbol=BTCUSDC&interval=15m&limit=20&endTime=periodStart*1000
|
||||
BinanceREST-->>AutoSpread: 20 根已收盘 K 线
|
||||
AutoSpread->>AutoSpread: 按 Up/Down 拆价差 → IQR 剔除 → 平均×0.8 → 缓存
|
||||
|
||||
Note over CLOB_WS,Exec: 同一周期内再次触发(如另一 outcome 或再次 bestBid)
|
||||
CLOB_WS->>Orderbook: onMessage → bestBid
|
||||
Orderbook->>Exec: tryTriggerWithPriceFromWs(...)
|
||||
Exec->>AutoSpread: getAutoMinSpread(intervalSeconds, periodStartUnix, outcomeIndex)
|
||||
AutoSpread->>AutoSpread: 查缓存 → 命中
|
||||
AutoSpread-->>Exec: effectiveMinSpread(不再调 REST)
|
||||
Exec->>Exec: 价差校验 → 通过则下单(或本周期已触发则跳过)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、流程小结(按执行顺序)
|
||||
|
||||
1. **策略配置**
|
||||
- 用户选择:无 / 固定(输入数值)/ 自动。
|
||||
- 固定:必填 minSpreadValue,保存到 DB。
|
||||
- 自动:不填 minSpreadValue,**不在保存时计算**;按周期在首次需要时计算并缓存。
|
||||
|
||||
2. **运行时**
|
||||
- 币安 WS 持续更新当前周期的 (open, close)。
|
||||
- CLOB 订单簿(或 HTTP)带来 bestBid;若时间窗 + 价格区间 + 本周期未触发 均满足:
|
||||
- 若 minSpreadMode == NONE → 直接 placeOrderForTrigger。
|
||||
- 否则取当前周期 open/close 与 effectiveMinSpread(固定值或自动缓存值),若 \|close−open\| >= effectiveMinSpread → placeOrderForTrigger;否则本轮不下单,等后续推送再判。
|
||||
|
||||
3. **下单与去重**
|
||||
- 仍保持「每周期最多触发一次」;价差不满足时不写触发记录,直到某次同时满足价格与价差后才下单并写记录。
|
||||
|
||||
按上述流程即可在现有尾盘策略上接入「最小价差」参数,并由后端订阅币安 K 线、在触发前做价差校验;固定与自动的时序差异见**第六节时序图**。
|
||||
@@ -0,0 +1,150 @@
|
||||
# 加密市场尾盘策略 - 任务梳理
|
||||
|
||||
> 需求与 UI 见 `crypto-tail-strategy-ui-spec.md`,市场数据与执行规则见 `crypto-tail-strategy-market-data.md`。
|
||||
|
||||
以下按**文档 / 数据库 / 后端 / 前端**拆分为可执行任务,便于排期与验收。
|
||||
|
||||
---
|
||||
|
||||
## 一、文档(已完成)
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| PRD 与需求 | ✅ | 周期、价格区间、每周期最多触发一次、重试 2 次等 |
|
||||
| 市场数据文档 | ✅ | `crypto-tail-strategy-market-data.md`:Gamma slug、周期、时间区间、价格判断 |
|
||||
| UI 规格 | ✅ | `crypto-tail-strategy-ui-spec.md`:列表、表单、时间区间、触发记录、赎回前置检查 |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| D1 | 策略表 migration | 新建表,字段建议:id, account_id, name, market_slug_prefix(如 btc-updown-5m), interval_seconds(300/900), window_start_seconds, window_end_seconds, min_price, max_price, amount_mode(ratio/fixed), amount_value(比例或 USDC 字符串), enabled, created_at, updated_at。唯一/外键按现有规范。 |
|
||||
| D2 | 触发记录表 migration | 新建表,字段建议:id, strategy_id, period_start_unix, market_title, outcome_index(0=Up/1=Down), trigger_price, amount_usdc, order_id(可空), status(success/fail), fail_reason(可空), created_at。便于列表与筛选。 |
|
||||
|
||||
---
|
||||
|
||||
## 三、后端(Kotlin)
|
||||
|
||||
### 3.1 实体与 Repository
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B1 | 策略实体 Entity | 对应策略表;ID 用 Long?;时间 Long 时间戳;金额 BigDecimal;遵守 backend.mdc 实体规范。 |
|
||||
| B2 | 触发记录实体 Entity | 对应触发记录表。 |
|
||||
| B3 | JpaRepository | 策略、触发记录的 Repository;按 strategyId、时间等查记录。 |
|
||||
|
||||
### 3.2 外部依赖与领域
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B4 | Gamma API 按 slug 拉市场 | 已有或扩展 PolymarketGammaApi:GET /events/slug/{slug},返回 conditionId、endDate、clobTokenIds 等;与 market-data 文档 3、4 节一致。 |
|
||||
| B5 | 周期与 slug 推导 | 工具或 Service:根据 interval(300/900)、当前时间算 periodStartUnix;拼 slug(如 btc-updown-5m-{ts});解析 endDate 得 endDateUnix。 |
|
||||
| B6 | 订单簿价格 | 使用现有 CLOB/订单簿能力,按 conditionId、clobTokenIds 取各 outcome 的 bestBid;与 market-data 7.1 一致。 |
|
||||
| B7 | 市价单与重试 | 按策略的 amount 计算下单金额;市价买入指定 outcome;失败时最多重试 2 次(共 3 次),仍失败则写触发记录状态为失败并记原因。 |
|
||||
|
||||
### 3.3 策略执行核心逻辑(按 market-data 第 6、7 节)
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B8 | 周期内时间窗口判断 | 仅当 `periodStartUnix + windowStartSeconds <= nowUnix < periodStartUnix + windowEndSeconds` 时,才做价格区间判断与下单;区间外不处理。 |
|
||||
| B9 | 价格区间与「先满足先买」 | 对两个 outcome 取价,若某 outcome 价格 ∈ [minPrice, maxPrice],则触发买该 outcome;另一 outcome 本周期不再触发(7.1)。 |
|
||||
| B10 | 每周期只触发一次 | 以 (strategyId, periodStartUnix) 去重;周期切换时重置「本周期已触发」状态;结合 B8、B9 实现。 |
|
||||
| B11 | 周期切换与 404 | 当 now >= endDateUnix 或新 periodStartUnix 时,用新 periodStartUnix 拉新 slug;404 时同 periodStartUnix 短间隔重试,长时间 404 可跳过本周期并打日志。 |
|
||||
|
||||
### 3.4 API 与 DTO
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B12 | 策略 CRUD API | 列表(分页/筛选)、创建、更新、删除、启用/停用;请求/响应为 DTO,不用 Map;统一 ApiResponse;错误码与 MessageSource。 |
|
||||
| B13 | 策略 DTO | 创建/更新包含:accountId, name, marketSlugPrefix, intervalSeconds, windowStartSeconds, windowEndSeconds, minPrice, maxPrice(可选默认 1), amountMode, amountValue;校验 windowStart <= windowEnd,且不超过周期长度。 |
|
||||
| B14 | 触发记录 API | 按 strategyId 分页查询触发记录;返回列表 DTO(时间、市场、方向、价格、金额、订单 ID、状态)。 |
|
||||
| B15 | 5/15 分钟市场列表 API(可选) | 若前端需要「可选市场」列表:可按当前/下一周期拼 slug 调 Gamma 返回市场信息,供前端选择;或前端直接按 slug 规则+周期展示。 |
|
||||
|
||||
### 3.5 自动赎回与调度
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| B16 | 自动赎回包含尾盘策略仓位 | 尾盘策略产生的仓位与跟单/手动一视同仁,纳入现有自动赎回逻辑,不排除(见 UI 规格附录 A)。 |
|
||||
| B17 | 调度/定时或常驻 | 对已启用策略按周期(如每 10–30 秒)检查:当前周期、是否在时间窗口内、是否已触发、价格是否进区间;满足则执行下单并写触发记录。 |
|
||||
|
||||
---
|
||||
|
||||
## 四、前端(React + TypeScript)
|
||||
|
||||
### 4.1 路由与导航
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F1 | 路由 | App.tsx 增加 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id`。 |
|
||||
| F2 | 菜单 | Layout 中增加「尾盘策略」菜单项,与跟单同级或在其下;key 与路由一致。 |
|
||||
|
||||
### 4.2 列表页
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F3 | 列表页组件 | 如 CryptoTailStrategyList.tsx;页面标题、钱包提示 Alert、新增按钮、筛选(账户、状态)。 |
|
||||
| F4 | 列表展示 | 桌面 Table / 移动 Card:策略名、关联市场、时间区间、价格区间、投入方式、状态、最近触发、操作(编辑、启用/停用、删除、查看触发记录);删除 Popconfirm。 |
|
||||
| F5 | 创建前检查 | 点击「新增策略」先调接口判断是否已配置自动赎回(如 builderApiKeyConfigured);未配置则弹出「请先配置自动赎回」Modal(去配置 → /system-settings,取消),不打开表单。 |
|
||||
|
||||
### 4.3 新增/编辑表单
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F6 | 表单弹窗 | 策略名、选择账户、选择市场、时间区间、minPrice、maxPrice、投入方式(比例/固定)、启用状态。 |
|
||||
| F7 | 时间区间控件 | 区间开始/结束:下拉选「分钟」+「秒」;5min 市场 0–5 分+0–59 秒(总≤5min),15min 市场 0–15 分+0–59 秒(总≤15min);校验**开始 ≤ 结束**;提交时转为 windowStartSeconds、windowEndSeconds。 |
|
||||
| F8 | 市场选择器 | 仅展示 5/15 分钟加密市场;支持搜索;展示市场标题+周期;选后用于校验时间区间上界(5min 结束≤300s,15min≤900s)。 |
|
||||
| F9 | 表单校验与提交 | 市场类型、时间区间 start≤end 且不超周期、minPrice/maxPrice、比例或固定金额合法;提交后刷新列表、成功提示。 |
|
||||
|
||||
### 4.4 触发记录
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F10 | 触发记录展示 | 弹窗或独立页:触发时间、市场、方向(Up/Down)、触发价格、投入金额、订单 ID、状态;支持按时间、状态筛选;formatUSDC;移动端 Card/折叠。 |
|
||||
|
||||
### 4.5 通用
|
||||
|
||||
| 序号 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| F11 | 类型定义 | 策略、触发记录等 TypeScript 类型;无 any。 |
|
||||
| F12 | API 封装 | apiService 中 cryptoTailStrategy.list/create/update/delete/toggle、records(strategyId) 等。 |
|
||||
| F13 | 多语言 | locales 中 zh-CN、zh-TW、en 的 cryptoTailStrategy.*:list.title、list.walletTip、form.walletTip、redeemRequiredModal.*、时间区间/价格区间等文案。 |
|
||||
|
||||
---
|
||||
|
||||
## 五、依赖关系简图
|
||||
|
||||
```
|
||||
文档 ✅
|
||||
↓
|
||||
D1,D2 数据库
|
||||
↓
|
||||
B1–B3 实体与 Repository
|
||||
↓
|
||||
B4–B7 外部 API、周期、价格、下单
|
||||
↓
|
||||
B8–B11 执行逻辑(时间窗口+价格+去重+周期切换)
|
||||
↓
|
||||
B12–B15 API 与 DTO
|
||||
B16 自动赎回
|
||||
B17 调度
|
||||
↓
|
||||
F1–F2 路由与菜单
|
||||
F11–F12 类型与 API 封装
|
||||
F13 多语言
|
||||
↓
|
||||
F3–F5 列表与创建前检查
|
||||
F6–F9 表单(含时间区间)
|
||||
F10 触发记录
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、验收要点
|
||||
|
||||
- **时间区间**:仅当周期内当前时间落在 [windowStartSeconds, windowEndSeconds] 时才判断价格并下单;前端区间开始 ≤ 结束,且不超出 5min/15min。
|
||||
- **每周期一次**:同一策略同一周期只触发一次(先满足价格的 outcome 买入,反方向不买)。
|
||||
- **重试**:下单失败最多重试 2 次,共 3 次;仍失败记入触发记录为失败。
|
||||
- **自动赎回**:尾盘策略产生的仓位可被自动赎回,无排除逻辑。
|
||||
- **创建前检查**:未配置自动赎回时点击新增策略弹出「去配置」弹窗,不打开表单。
|
||||
@@ -0,0 +1,177 @@
|
||||
# 加密市场尾盘策略 - 前端 UI 规格
|
||||
|
||||
> 周期推导与市场数据获取详见 `crypto-tail-strategy-market-data.md`。
|
||||
|
||||
与现有跟单/回测保持同一风格(Ant Design、响应式、多语言),以下为页面结构及所含元素。
|
||||
|
||||
---
|
||||
|
||||
## 1. 导航与路由
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **菜单** | 在「跟单管理」同级或其下增加一项,如「尾盘策略」,key 建议 `/crypto-tail-strategy`。 |
|
||||
| **路由** | 列表页 `/crypto-tail-strategy`;可选详情/触发记录 `/crypto-tail-strategy/records/:id`。 |
|
||||
|
||||
参考:`Layout.tsx` 中 `/copy-trading`、`/backtest` 的配置;`App.tsx` 中对应 `Route`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 列表页(主页面)
|
||||
|
||||
**路径**:`/crypto-tail-strategy`
|
||||
**组件**:如 `CryptoTailStrategyList.tsx`(或 `TailStrategyList.tsx`)。
|
||||
|
||||
### 2.1 顶部操作区
|
||||
|
||||
| 元素 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| 页面标题 | 标题文案 | 如「加密尾盘策略」,用 `t('cryptoTailStrategy.list.title')`。 |
|
||||
| **钱包使用提示** | **Alert(Warning)** | **必须**在页面顶部或标题下方展示:提示用户**使用单独/专用钱包**运行本策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,进而造成策略执行异常(如余额不足、下单失败等)。文案走多语言 `t('cryptoTailStrategy.list.walletTip')`,可带 `showIcon`。 |
|
||||
| 新增策略 | Button(Primary) | 点击时**先检查自动赎回相关配置**(见 2.4);若未配置则弹出「去配置」简易弹窗,若已配置则打开「新增策略」表单弹窗。图标可用 `PlusOutlined`。 |
|
||||
| 筛选(可选) | Select / 筛选项 | 按账户、启用状态筛选;移动端可收起到抽屉或折叠。 |
|
||||
|
||||
### 2.2 列表内容(桌面端:Table,移动端:Card 列表)
|
||||
|
||||
| 列/卡片项 | 说明 |
|
||||
|-----------|------|
|
||||
| 策略名称 | 用户填的配置名或自动生成名。 |
|
||||
| 关联市场 | 展示市场标题 + 周期,如「Bitcoin Up or Down - 5 minute」。 |
|
||||
| 时间区间 | 如「3 分 0 秒 ~ 12 分 0 秒」(与周期类型一致:5min 为 0–5 分,15min 为 0–15 分)。 |
|
||||
| 价格区间 | 如 `[0.92, 1]` 或「0.92 ~ 1」(maxPrice 为空时显示为 1)。 |
|
||||
| 投入方式 | 「比例 10%」或「固定 100 USDC」,用 `formatUSDC` 格式化金额。 |
|
||||
| 状态 | Tag 或 Switch:启用 / 停用。 |
|
||||
| 最近触发 | 最近一次触发时间(若有);无则「-」。 |
|
||||
| 操作 | 编辑、启用/停用、删除、查看触发记录。删除前 Popconfirm 二次确认。 |
|
||||
|
||||
### 2.3 与现有风格对齐
|
||||
|
||||
- 加载态:`Spin` 包裹列表。
|
||||
- 空状态:无数据时展示空状态插画 + 引导「新增策略」。
|
||||
- 响应式:`useMediaQuery({ maxWidth: 768 })`,桌面用 Table,移动用 Card + 操作折叠/抽屉。
|
||||
|
||||
参考:`CopyTradingList.tsx` 的 Table 列、Card 布局、筛选与 Modal 打开方式。
|
||||
|
||||
### 2.4 创建前检查:自动赎回配置(必须)
|
||||
|
||||
策略依赖**自动赎回**(需通过 Relayer/Builder API 提交链上赎回)。用户点击「新增策略」时:
|
||||
|
||||
1. **检查**:请求系统配置(如 `apiService.systemConfig.getConfig()` 或已有接口),判断是否已配置 Builder API Key(及可选:自动赎回已开启)。若 `builderApiKeyConfigured === false`(或后端约定之「未配置」状态),视为未配置。
|
||||
2. **未配置时**:不打开新增策略表单,改为弹出**简易弹窗**(Modal),内容建议:
|
||||
- **标题**:如「请先配置自动赎回」,`t('cryptoTailStrategy.redeemRequiredModal.title')`。
|
||||
- **正文**:简短说明尾盘策略依赖自动赎回,需要先在「系统设置」中配置 Builder API Key 及自动赎回。文案 `t('cryptoTailStrategy.redeemRequiredModal.description')`。
|
||||
- **操作**:
|
||||
- **去配置**:主按钮,点击后关闭弹窗并跳转到系统设置页(如 `/system-settings`,该页含 Relayer 配置与自动赎回开关)。
|
||||
- **取消**:次按钮或关闭图标,仅关闭弹窗。
|
||||
3. **已配置时**:正常打开新增策略表单弹窗。
|
||||
|
||||
弹窗保持简易,无需表单,仅提示 + 跳转;多语言键示例:`cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 新增 / 编辑策略弹窗(Modal)
|
||||
|
||||
**组件**:如 `CryptoTailStrategyFormModal.tsx` 或内嵌在列表页的 Modal。
|
||||
|
||||
### 3.1 表单字段
|
||||
|
||||
| 表单项 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| **钱包提示(简短)** | **Alert(Warning)** | - | 在「选择账户」上方或表单单列顶部展示简短提示:建议使用**专用钱包**,避免手动操作等导致异常。文案如 `t('cryptoTailStrategy.form.walletTip')`。 |
|
||||
| 策略名称 | Input | 否 | 用于列表展示,可占位「自动生成」。 |
|
||||
| 选择账户 | Select | 是 | 下拉已导入账户(与跟单一致,来自 `useAccountStore()` 或接口)。 |
|
||||
| 选择市场 | 市场选择器 | 是 | 仅展示 5/15 分钟加密市场;支持搜索;展示市场标题 + 周期(5min/15min);一个策略绑一个市场。 |
|
||||
| **时间区间** | **开始 / 结束** | 是 | 仅在本周期内的该时间窗口内,价格满足时才下单;区间外不处理。见下方说明。 |
|
||||
| 区间开始 | 下拉(分 + 秒) | 是 | 从周期起点起算的「开始」偏移。5 分钟市场可选 0~5 分 + 0~59 秒(总不超过 5 分钟);15 分钟市场可选 0~15 分 + 0~59 秒(总不超过 15 分钟)。 |
|
||||
| 区间结束 | 下拉(分 + 秒) | 是 | 从周期起点起算的「结束」偏移。范围同上,且**区间开始不得大于区间结束**(前端校验)。 |
|
||||
| 最低价 minPrice | InputNumber | 是 | 0~1,精度 2~4 位小数;校验 minPrice ≤ 1。 |
|
||||
| 最高价 maxPrice | InputNumber | 否 | 0~1,占位「不填默认为 1」;若填则校验 minPrice ≤ maxPrice ≤ 1。 |
|
||||
| 投入方式 | Radio.Group | 是 | 选项:「按比例」「固定金额」。 |
|
||||
| 比例 % | InputNumber | 条件必填 | 选「按比例」时显示;0~100;可展示当前账户 USDC 余额与预估金额。 |
|
||||
| 固定金额 (USDC) | InputNumber | 条件必填 | 选「固定金额」时显示;≥ 最小下单额,≤ 账户余额;用 `formatUSDC` 展示。 |
|
||||
| 启用状态 | Switch | 否 | 新增默认开启;编辑可切换。 |
|
||||
|
||||
**时间区间说明**:例如 15 分钟市场配置「3 分 0 秒」~「12 分 0 秒」,表示从周期开始后第 3 分钟到第 12 分钟之间,若价格进入 [minPrice, maxPrice] 才下单;第 0~3 分钟、第 12~15 分钟即使价格满足也不下单。5 分钟市场同理,可选 0~5 分钟内的一段(如 0~2、2~5)。前端用下拉选择「分钟」+「秒」,后端存为相对周期起点的秒数(如 windowStartSeconds、windowEndSeconds)。
|
||||
|
||||
### 3.2 校验与提交
|
||||
|
||||
- 提交前:市场为 5/15 分钟、**时间区间开始 ≤ 时间区间结束**、时间区间不超出周期长度(5min 市场结束 ≤ 5 分 0 秒,15min 市场结束 ≤ 15 分 0 秒)、minPrice 合法、maxPrice 若填则 ≥ minPrice、余额/比例合法。
|
||||
- 提交后:关闭弹窗、刷新列表、`message.success`;失败在表单上展示接口错误信息。
|
||||
|
||||
参考:`CopyTradingOrders/AddModal.tsx` 的 Form 布局、`Form.Item` + `rules`、条件显示(比例/固定金额)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 触发记录
|
||||
|
||||
**入口**:列表行操作「查看触发记录」或单独 Tab/页。
|
||||
|
||||
### 4.1 展示方式(二选一或并存)
|
||||
|
||||
- **弹窗**:Modal 内 Table,按策略 ID 拉取该策略的触发记录。
|
||||
- **独立页**:路由如 `/crypto-tail-strategy/records/:strategyId`,页面内 Table 或 Card 列表。
|
||||
|
||||
### 4.2 记录列表字段
|
||||
|
||||
| 列/项 | 说明 |
|
||||
|-------|------|
|
||||
| 触发时间 | 时间戳格式化为本地时间。 |
|
||||
| 市场 | 市场标题 + 周期。 |
|
||||
| 方向 (outcome) | Up / Down。 |
|
||||
| 触发价格 | 当时进入区间的价格。 |
|
||||
| 投入金额 | USDC,用 `formatUSDC`。 |
|
||||
| 订单 ID | 若有;可截断 + Tooltip 全量。 |
|
||||
| 状态 | 成功 / 失败。 |
|
||||
|
||||
支持按时间范围、状态筛选;移动端用 Card 或折叠列表。
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件与技术要点
|
||||
|
||||
| 要点 | 说明 |
|
||||
|------|------|
|
||||
| **钱包提示** | 列表页与新增/编辑表单**必须**包含「使用单独钱包」的 Alert 提示,避免用户用混用钱包导致异常;文案走多语言。 |
|
||||
| **创建前检查** | 点击「新增策略」时先检查自动赎回/Builder API 是否已配置;未配置则弹出简易「去配置」弹窗,引导用户到系统设置配置 API Key 与自动赎回,不打开策略表单。 |
|
||||
| 多语言 | 所有文案 `t('cryptoTailStrategy.xxx')`,在 `locales/zh-CN`、`zh-TW`、`en` 的 `common.json` 中增加键。需包含:`cryptoTailStrategy.list.walletTip`、`cryptoTailStrategy.form.walletTip`,以及 `cryptoTailStrategy.redeemRequiredModal.title`、`cryptoTailStrategy.redeemRequiredModal.description`、`cryptoTailStrategy.redeemRequiredModal.goToSettings`、`cryptoTailStrategy.redeemRequiredModal.cancel`。文案示例:列表页 `walletTip`:「请使用单独的钱包运行尾盘策略,避免该钱包用于手动交易、跟单等其他操作,否则可能导致余额或仓位变化,造成策略执行异常。」表单内 `walletTip`:「建议使用专用钱包,避免手动操作等导致余额或下单异常。」未配置赎回弹窗 `title`:「请先配置自动赎回」;`description`:「尾盘策略依赖自动赎回功能,请先在系统设置中配置 Builder API Key 并开启自动赎回。」;`goToSettings`:「去配置」;`cancel`:「取消」。 |
|
||||
| 金额 | 统一 `formatUSDC`(见 frontend.mdc)。 |
|
||||
| 响应式 | `useMediaQuery`;按钮触摸目标 ≥ 44px;移动端主操作突出。 |
|
||||
| 类型 | 不用 `any`;为策略、触发记录定义 TypeScript 类型。 |
|
||||
| API | 通过 `apiService` 封装(如 `apiService.cryptoTailStrategy.list/create/update/delete/records`)。 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 页面与文件建议对应
|
||||
|
||||
| 功能 | 建议路径/文件 |
|
||||
|------|----------------|
|
||||
| 列表页 | `frontend/src/pages/CryptoTailStrategyList.tsx` |
|
||||
| 未配置赎回时的简易弹窗 | 内嵌在列表页的 Modal,或 `CryptoTailStrategyList/RedeemRequiredModal.tsx` |
|
||||
| 新增/编辑弹窗 | `frontend/src/pages/CryptoTailStrategyList/FormModal.tsx` 或内嵌 Modal |
|
||||
| 触发记录 | `frontend/src/pages/CryptoTailStrategyList/TriggerRecordsModal.tsx` 或 `CryptoTailStrategyRecords.tsx` |
|
||||
| 路由 | `App.tsx` 中 `/crypto-tail-strategy`、可选 `/crypto-tail-strategy/records/:id` |
|
||||
| 菜单 | `Layout.tsx` 中增加「尾盘策略」菜单项 |
|
||||
| 类型 | `frontend/src/types/index.ts` 或 `types/cryptoTailStrategy.ts` 中增加策略与触发记录类型 |
|
||||
| 多语言 | `frontend/src/locales/{zh-CN,zh-TW,en}/common.json` 中增加 `cryptoTailStrategy.*` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 小结:UI 包含的主要元素
|
||||
|
||||
- **导航**:主导航中「尾盘策略」入口。
|
||||
- **列表页**:标题、钱包提示 Alert、新增按钮(点击前先检查赎回配置,未配置则弹「去配置」简易弹窗)、筛选、表格/卡片(策略名、市场、价格区间、投入方式、状态、最近触发、操作)、加载与空状态。
|
||||
- **未配置赎回弹窗**:简易 Modal,提示依赖自动赎回、需先配置 Builder API Key 与自动赎回;按钮「去配置」(跳转 `/system-settings`)、「取消」。
|
||||
- **表单弹窗**:策略名、账户、市场选择、minPrice/maxPrice、投入方式(比例/固定)、启用开关、提交/取消。
|
||||
- **触发记录**:时间、市场、outcome、触发价格、金额、订单 ID、状态;支持弹窗或独立页。
|
||||
- **通用**:Ant Design 组件、响应式、多语言、formatUSDC、TypeScript 类型。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A 后端/产品要求:自动赎回须支持本策略仓位
|
||||
|
||||
自动赎回逻辑**必须支持赎回由尾盘策略产生的订单所对应的仓位**。即:本策略触发的市价买入会形成仓位,这些仓位在满足「可赎回」条件时,应被纳入现有自动赎回流程并正常发起赎回,不得因来源为「尾盘策略」而被排除。后端实现时需保证:
|
||||
|
||||
- 尾盘策略下单产生的仓位,与跟单/手动下单等来源的仓位一视同仁,参与可赎回查询与批量赎回;
|
||||
- 若当前自动赎回按账户或仓位类型过滤,需将「尾盘策略订单产生的仓位」包含在内。
|
||||
|
||||
这样前端所依赖的「自动赎回」对该策略才完整有效。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user