Compare commits
80 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 | |||
| f1eae8a40b | |||
| ca655f351d | |||
| 40081c2464 | |||
| f1f809f54b | |||
| e8fd1b503b | |||
| 390b3ee876 | |||
| 80976609c7 | |||
| 17eea0183f | |||
| 6980781f89 | |||
| 3350039f05 | |||
| 0bdc0c74d1 | |||
| cccc829cef | |||
| fa1a915e9c | |||
| e5992b5145 | |||
| 42472f6b03 |
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,24 @@ on:
|
||||
release:
|
||||
types:
|
||||
- published # 当通过 GitHub Releases 页面创建 release 时触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
description: '构建类型'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- package-only # 只打包产物
|
||||
- package-and-docker # 打包产物 + Docker 镜像
|
||||
default: 'package-and-docker'
|
||||
version:
|
||||
description: '版本号(例如: v1.0.0)'
|
||||
required: false
|
||||
type: string
|
||||
tag_name:
|
||||
description: 'Git Tag 名称(留空则使用 version)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -16,25 +34,61 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }} # 使用 release 对应的 tag
|
||||
ref: ${{ github.event.release.tag_name || github.event.inputs.tag_name || github.event.inputs.version || github.ref }}
|
||||
|
||||
- name: Determine build type
|
||||
id: build_config
|
||||
run: |
|
||||
# 确定构建类型
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
# Release 事件:默认只打包产物(不构建 Docker)
|
||||
BUILD_TYPE="package-only"
|
||||
echo "📦 Release 事件:将只打包产物(不构建 Docker)"
|
||||
else
|
||||
# workflow_dispatch 事件:使用用户输入
|
||||
BUILD_TYPE="${{ github.event.inputs.build_type }}"
|
||||
echo "🔧 手动触发:构建类型 = ${BUILD_TYPE}"
|
||||
fi
|
||||
|
||||
echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract version and check if pre-release
|
||||
id: extract_version
|
||||
run: |
|
||||
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0)
|
||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
# 从不同事件源提取版本号
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
# Release 事件:从 release tag 中提取
|
||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
||||
IS_PRERELEASE="${{ github.event.release.prerelease }}"
|
||||
else
|
||||
# workflow_dispatch 事件:从输入参数中提取
|
||||
TAG_NAME="${{ github.event.inputs.tag_name }}"
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
TAG_NAME="${{ github.event.inputs.version }}"
|
||||
fi
|
||||
# 如果仍然为空,尝试从 git ref 中提取
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
|
||||
# 不是 tag,尝试从分支名或 commit SHA 获取
|
||||
TAG_NAME=${GITHUB_REF#refs/heads/}
|
||||
if [ "$TAG_NAME" = "$GITHUB_REF" ]; then
|
||||
TAG_NAME="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "⚠️ 未指定版本号,使用临时版本: $TAG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
IS_PRERELEASE="false"
|
||||
fi
|
||||
|
||||
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta)
|
||||
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
||||
exit 1
|
||||
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
|
||||
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
|
||||
echo " 当前版本号: $TAG_NAME"
|
||||
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
||||
fi
|
||||
|
||||
VERSION=${TAG_NAME#v} # 移除 v 前缀
|
||||
IS_PRERELEASE="${{ github.event.release.prerelease }}"
|
||||
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||
@@ -47,7 +101,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Send Telegram notification (build started)
|
||||
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
||||
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
@@ -59,12 +113,15 @@ jobs:
|
||||
fi
|
||||
|
||||
# 获取构建信息
|
||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
MESSAGE="🔨 <b>Release 构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
|
||||
else
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
MESSAGE="🔨 <b>构建中</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"
|
||||
fi
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
@@ -141,12 +198,18 @@ jobs:
|
||||
echo "✅ 前端文件已复制"
|
||||
|
||||
# 创建版本信息文件
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_NOTES=$(echo '${{ github.event.release.body }}' | jq -Rs .)
|
||||
else
|
||||
RELEASE_NOTES="\"手动构建 - workflow_dispatch\""
|
||||
fi
|
||||
|
||||
cat > update-package/version.json <<EOF
|
||||
{
|
||||
"version": "${{ steps.extract_version.outputs.VERSION }}",
|
||||
"tag": "${{ steps.extract_version.outputs.TAG }}",
|
||||
"buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"releaseNotes": $(echo '${{ github.event.release.body }}' | jq -Rs .)
|
||||
"releaseNotes": ${RELEASE_NOTES}
|
||||
}
|
||||
EOF
|
||||
echo "✅ 版本信息已创建"
|
||||
@@ -169,6 +232,7 @@ jobs:
|
||||
echo "$CHECKSUM $FILE" > checksums.txt
|
||||
|
||||
- name: Upload Update Package to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -178,7 +242,8 @@ jobs:
|
||||
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||
asset_content_type: application/gzip
|
||||
|
||||
- name: Upload Checksums
|
||||
- name: Upload Checksums to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -188,19 +253,32 @@ jobs:
|
||||
asset_name: checksums.txt
|
||||
asset_content_type: text/plain
|
||||
|
||||
- name: Upload Update Package as Artifact
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update
|
||||
path: |
|
||||
polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||
checksums.txt
|
||||
retention-days: 30
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# 启用多架构构建支持
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Prepare Docker build context
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
run: |
|
||||
echo "📦 准备 Docker 构建上下文..."
|
||||
# 确保构建产物存在且可访问
|
||||
@@ -217,6 +295,7 @@ jobs:
|
||||
ls -lh backend/build/libs/*.jar
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
@@ -234,6 +313,12 @@ jobs:
|
||||
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
||||
cache-to: type=inline
|
||||
|
||||
- name: Skip Docker build notice
|
||||
if: steps.build_config.outputs.BUILD_TYPE == 'package-only'
|
||||
run: |
|
||||
echo "⏭️ 跳过 Docker 镜像构建(构建类型:package-only)"
|
||||
echo "✅ 仅打包产物已完成"
|
||||
|
||||
- name: Send Telegram notification
|
||||
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
||||
@@ -250,13 +335,26 @@ jobs:
|
||||
# 获取构建信息
|
||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||
RELEASE_NAME="${{ github.event.release.name }}"
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
REPO_NAME="${{ github.repository }}"
|
||||
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
|
||||
MESSAGE="✅ <b>Docker 镜像构建成功</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
||||
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
|
||||
MESSAGE="✅ <b>Release 构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
else
|
||||
MESSAGE="✅ <b>Release 打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
|
||||
fi
|
||||
else
|
||||
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
if [ "$BUILD_TYPE" = "package-and-docker" ]; then
|
||||
MESSAGE="✅ <b>构建成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> Docker 升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📚 <a href=\"${DEPLOY_DOC_URL}\">Docker 部署文档</a>"
|
||||
else
|
||||
MESSAGE="✅ <b>打包成功</b>"$'\n'$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔧 <b>构建类型:</b> 在线升级"$'\n'"🔗 <a href=\"${WORKFLOW_URL}\">查看 Workflow</a>"$'\n'"📍 <b>升级路径:</b> 系统管理 → 概览 → 检查更新"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
name: Telegram Notification on PR Merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed # 当 PR 被关闭(合并或关闭)时触发
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# 只在 PR 被合并到 main 分支时执行
|
||||
if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get PR details
|
||||
id: pr_details
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
# 获取 PR 详细信息
|
||||
PR_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}")
|
||||
|
||||
# 获取 PR 变更的文件列表
|
||||
FILES_RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files")
|
||||
|
||||
# 提取 PR 描述(body),保留换行,限制长度
|
||||
PR_BODY=$(echo "$PR_RESPONSE" | jq -r '.body // ""')
|
||||
if [ ${#PR_BODY} -gt 500 ]; then
|
||||
PR_BODY="${PR_BODY:0:500}..."
|
||||
fi
|
||||
|
||||
# 保存到输出变量(使用 base64 编码避免特殊字符问题)
|
||||
echo "pr_body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$PR_BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Send Telegram notification
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
run: |
|
||||
# 检查 PR 是否被合并(而不是仅关闭)
|
||||
PR_MERGED="${{ github.event.pull_request.merged }}"
|
||||
if [ "$PR_MERGED" != "true" ]; then
|
||||
echo "ℹ️ PR 仅关闭,未合并,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查必要的环境变量
|
||||
# 注意:TELEGRAM_CHAT_ID 可以是个人聊天 ID(正数)或群组 ID(负数,如 -1001234567890)
|
||||
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
||||
echo "⚠️ Telegram Bot Token 或 Chat ID 未配置,跳过通知"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取 PR 基本信息
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_URL="${{ github.event.pull_request.html_url }}"
|
||||
PR_MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
|
||||
# 获取 PR 详细信息
|
||||
PR_BODY="${{ steps.pr_details.outputs.pr_body }}"
|
||||
|
||||
# 转义 PR 标题中的 HTML 特殊字符
|
||||
PR_TITLE_ESCAPED=$(echo "$PR_TITLE" | sed 's/&/\&/g' | sed 's/</\</g' | sed 's/>/\>/g')
|
||||
|
||||
# 构建消息内容(仅包含关键信息)
|
||||
MESSAGE="🚀 <b>main 分支代码更新</b>"$'\n'$'\n'"📝 <b>PR #${PR_NUMBER}:</b> ${PR_TITLE_ESCAPED}"$'\n'"🔗 <a href=\"${PR_URL}\">查看 PR</a>"
|
||||
|
||||
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg chat_id "$TELEGRAM_CHAT_ID" \
|
||||
--arg text "$MESSAGE" \
|
||||
'{chat_id: $chat_id, text: $text, parse_mode: "HTML", disable_web_page_preview: false}')" > /tmp/telegram_response.json
|
||||
|
||||
# 检查发送结果
|
||||
if [ $? -eq 0 ]; then
|
||||
RESPONSE=$(cat /tmp/telegram_response.json)
|
||||
if echo "$RESPONSE" | grep -q '"ok":true'; then
|
||||
echo "✅ Telegram 通知发送成功"
|
||||
else
|
||||
echo "❌ Telegram 通知发送失败: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ 发送 Telegram 消息时发生错误"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+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 路由和改进健康检查逻辑
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢所有贡献者和用户的支持与反馈!
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -140,7 +140,7 @@ class LeaderController(
|
||||
if (request.leaderId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
|
||||
|
||||
val result = leaderService.getLeaderDetail(request.leaderId)
|
||||
result.fold(
|
||||
onSuccess = { leader ->
|
||||
@@ -159,6 +159,36 @@ class LeaderController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询被跟单者余额
|
||||
*/
|
||||
@PostMapping("/balance")
|
||||
fun getLeaderBalance(@RequestBody request: LeaderBalanceRequest): ResponseEntity<ApiResponse<LeaderBalanceResponse>> {
|
||||
return try {
|
||||
if (request.leaderId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = leaderService.getLeaderBalance(request.leaderId)
|
||||
result.fold(
|
||||
onSuccess = { balance ->
|
||||
ResponseEntity.ok(ApiResponse.success(balance))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("查询 Leader 余额失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource))
|
||||
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 余额异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, 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个)
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户更新请求
|
||||
*/
|
||||
@@ -93,6 +124,16 @@ data class AccountListResponse(
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 钱包余额响应(通用类,用于 Account 和 Leader)
|
||||
*/
|
||||
data class WalletBalanceResponse(
|
||||
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||
val positions: List<PositionDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 账户余额响应
|
||||
*/
|
||||
@@ -108,6 +149,7 @@ data class AccountBalanceResponse(
|
||||
*/
|
||||
data class PositionDto(
|
||||
val marketId: String,
|
||||
val title: String?, // 市场名称
|
||||
val side: String, // YES 或 NO
|
||||
val quantity: String,
|
||||
val avgPrice: String,
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
@@ -36,7 +36,6 @@ data class CopyTradingCreateRequest(
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
@@ -75,7 +74,6 @@ data class CopyTradingUpdateRequest(
|
||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
@@ -151,7 +149,6 @@ data class CopyTradingDto(
|
||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||
// 最大仓位配置
|
||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
// 关键字过滤配置
|
||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 尾盘策略创建请求
|
||||
* 金额与价格使用 String,后端转为 BigDecimal
|
||||
*/
|
||||
data class CryptoTailStrategyCreateRequest(
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val intervalSeconds: Int = 300,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略更新请求
|
||||
*/
|
||||
data class CryptoTailStrategyUpdateRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val windowStartSeconds: Int? = null,
|
||||
val windowEndSeconds: Int? = null,
|
||||
val minPrice: String? = null,
|
||||
val maxPrice: String? = null,
|
||||
val amountMode: String? = null,
|
||||
val amountValue: String? = null,
|
||||
val minSpreadMode: String? = null,
|
||||
val minSpreadValue: String? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略列表请求
|
||||
*/
|
||||
data class CryptoTailStrategyListRequest(
|
||||
val accountId: Long? = null,
|
||||
val enabled: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略 DTO(列表与详情)
|
||||
*/
|
||||
data class CryptoTailStrategyDto(
|
||||
val id: Long = 0L,
|
||||
val accountId: Long = 0L,
|
||||
val name: String? = null,
|
||||
val marketSlugPrefix: String = "",
|
||||
val marketTitle: String? = null,
|
||||
val intervalSeconds: Int = 0,
|
||||
val windowStartSeconds: Int = 0,
|
||||
val windowEndSeconds: Int = 0,
|
||||
val minPrice: String = "0",
|
||||
val maxPrice: String = "1",
|
||||
val amountMode: String = "RATIO",
|
||||
val amountValue: String = "0",
|
||||
val minSpreadMode: String = "NONE",
|
||||
val minSpreadValue: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
val lastTriggerAt: Long? = null,
|
||||
/** 已实现总收益 USDC(已结算订单的 realizedPnl 之和) */
|
||||
val totalRealizedPnl: String? = null,
|
||||
/** 已结算笔数(用于胜率分母) */
|
||||
val settledCount: Long = 0L,
|
||||
/** 已结算中赢的笔数(用于胜率分子) */
|
||||
val winCount: Long = 0L,
|
||||
/** 胜率 0~1(已结算时 = winCount/settledCount,无结算为 null) */
|
||||
val winRate: String? = null,
|
||||
val createdAt: Long = 0L,
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略列表响应
|
||||
*/
|
||||
data class CryptoTailStrategyListResponse(
|
||||
val list: List<CryptoTailStrategyDto> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* 尾盘策略删除请求
|
||||
*/
|
||||
data class CryptoTailStrategyDeleteRequest(
|
||||
val strategyId: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录列表请求
|
||||
* @param startDate 开始日期(当天 00:00:00.000 的时间戳毫秒),为 null 表示不限制
|
||||
* @param endDate 结束日期(当天 23:59:59.999 的时间戳毫秒),为 null 表示不限制
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 20,
|
||||
val status: String? = null,
|
||||
val startDate: Long? = null,
|
||||
val endDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录 DTO
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerDto(
|
||||
val id: Long = 0L,
|
||||
val strategyId: Long = 0L,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val marketTitle: String? = null,
|
||||
val outcomeIndex: Int = 0,
|
||||
val triggerPrice: String = "0",
|
||||
val amountUsdc: String = "0",
|
||||
val orderId: String? = null,
|
||||
val status: String = "success",
|
||||
val failReason: String? = null,
|
||||
/** 是否已结算 */
|
||||
val resolved: Boolean = false,
|
||||
/** 已实现盈亏 USDC(结算后有值) */
|
||||
val realizedPnl: String? = null,
|
||||
/** 市场赢家 outcome 索引(结算后有值) */
|
||||
val winnerOutcomeIndex: Int? = null,
|
||||
val settledAt: Long? = null,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 触发记录分页响应
|
||||
*/
|
||||
data class CryptoTailStrategyTriggerListResponse(
|
||||
val list: List<CryptoTailStrategyTriggerDto> = emptyList(),
|
||||
val total: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 自动最小价差计算响应(按 30 根历史 K 线 + IQR 剔除后 × 0.7)
|
||||
*/
|
||||
data class CryptoTailAutoMinSpreadResponse(
|
||||
val minSpreadUp: String = "0",
|
||||
val minSpreadDown: String = "0"
|
||||
)
|
||||
|
||||
/**
|
||||
* 5/15 分钟市场项(供前端选择市场)
|
||||
*/
|
||||
data class CryptoTailMarketOptionDto(
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
val intervalSeconds: Int = 0,
|
||||
val periodStartUnix: Long = 0L,
|
||||
val endDate: String? = null
|
||||
)
|
||||
@@ -36,6 +36,13 @@ data class LeaderListRequest(
|
||||
val category: String? = null // sports 或 crypto
|
||||
)
|
||||
|
||||
/**
|
||||
* Leader 余额请求
|
||||
*/
|
||||
data class LeaderBalanceRequest(
|
||||
val leaderId: Long // LeaderID(必需)
|
||||
)
|
||||
|
||||
/**
|
||||
* Leader 信息响应
|
||||
*/
|
||||
@@ -47,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,
|
||||
@@ -61,3 +69,16 @@ data class LeaderListResponse(
|
||||
val total: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Leader 余额响应
|
||||
*/
|
||||
data class LeaderBalanceResponse(
|
||||
val leaderId: Long,
|
||||
val leaderAddress: String,
|
||||
val leaderName: String?,
|
||||
val availableBalance: String, // 可用余额(RPC 查询的 USDC 余额)
|
||||
val positionBalance: String, // 仓位余额(持仓总价值)
|
||||
val totalBalance: String, // 总余额 = 可用余额 + 仓位余额
|
||||
val positions: List<PositionDto> = emptyList()
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -83,9 +83,6 @@ data class CopyTrading(
|
||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||
|
||||
@Column(name = "max_position_count")
|
||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
||||
|
||||
// 关键字过滤配置
|
||||
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+234
-88
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户信息
|
||||
*/
|
||||
@@ -278,7 +468,7 @@ class AccountService(
|
||||
if (accountId == null) {
|
||||
return Result.failure(IllegalArgumentException("账户ID不能为空"))
|
||||
}
|
||||
|
||||
|
||||
val account = accountRepository.findById(accountId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
|
||||
@@ -288,68 +478,19 @@ class AccountService(
|
||||
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
|
||||
}
|
||||
|
||||
// 查询 USDC 余额和持仓信息
|
||||
// 使用通用方法查询余额
|
||||
val balanceResult = runBlocking {
|
||||
try {
|
||||
// 查询持仓信息(用于返回持仓列表)
|
||||
// 使用代理地址查询持仓(Polymarket 使用代理地址存储持仓)
|
||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
||||
val positions = if (positionsResult.isSuccess) {
|
||||
positionsResult.getOrNull()?.map { pos ->
|
||||
PositionDto(
|
||||
marketId = pos.conditionId ?: "",
|
||||
side = pos.outcome ?: "",
|
||||
quantity = pos.size?.toString() ?: "0",
|
||||
avgPrice = pos.avgPrice?.toString() ?: "0",
|
||||
currentValue = pos.currentValue?.toString() ?: "0",
|
||||
pnl = pos.cashPnl?.toString()
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 使用 /value 接口获取仓位总价值(而不是累加)
|
||||
val positionBalanceResult = blockchainService.getTotalValue(account.proxyAddress)
|
||||
val positionBalance = if (positionBalanceResult.isSuccess) {
|
||||
positionBalanceResult.getOrNull() ?: "0"
|
||||
} else {
|
||||
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 查询可用余额(通过 RPC 查询 USDC 余额)
|
||||
// 必须使用代理地址查询
|
||||
val availableBalanceResult = blockchainService.getUsdcBalance(
|
||||
walletAddress = account.walletAddress,
|
||||
proxyAddress = account.proxyAddress
|
||||
)
|
||||
val availableBalance = if (availableBalanceResult.isSuccess) {
|
||||
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
|
||||
} else {
|
||||
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
|
||||
val error = availableBalanceResult.exceptionOrNull()
|
||||
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
|
||||
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
|
||||
}
|
||||
|
||||
// 计算总余额 = 可用余额 + 仓位余额
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
|
||||
|
||||
AccountBalanceResponse(
|
||||
availableBalance = availableBalance,
|
||||
positionBalance = positionBalance,
|
||||
totalBalance = totalBalance.toPlainString(),
|
||||
positions = positions
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询余额失败: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
blockchainService.getWalletBalance(account.proxyAddress)
|
||||
}
|
||||
|
||||
Result.success(balanceResult)
|
||||
balanceResult.map { walletBalance: WalletBalanceResponse ->
|
||||
AccountBalanceResponse(
|
||||
availableBalance = walletBalance.availableBalance,
|
||||
positionBalance = walletBalance.positionBalance,
|
||||
totalBalance = walletBalance.totalBalance,
|
||||
positions = walletBalance.positions
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询账户余额失败", e)
|
||||
Result.failure(e)
|
||||
@@ -876,7 +1017,7 @@ class AccountService(
|
||||
"0"
|
||||
}
|
||||
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
||||
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType)
|
||||
val signedOrder = try {
|
||||
orderSigningService.createAndSignOrder(
|
||||
privateKey = decryptedPrivateKey,
|
||||
@@ -885,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
|
||||
@@ -1238,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("赎回仓位列表不能为空"))
|
||||
}
|
||||
@@ -1266,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>>()
|
||||
@@ -1309,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
|
||||
|
||||
@@ -1329,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(
|
||||
@@ -1364,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
|
||||
}
|
||||
}
|
||||
+93
-18
@@ -8,9 +8,13 @@ import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.PositionResponse
|
||||
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
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||
@@ -90,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) {
|
||||
@@ -107,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")
|
||||
@@ -255,7 +259,6 @@ class BlockchainService(
|
||||
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
||||
}
|
||||
|
||||
|
||||
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
||||
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
||||
Result.success(balance)
|
||||
@@ -264,6 +267,82 @@ class BlockchainService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询钱包余额(通用方法)
|
||||
* 用于 Account 和 Leader 的余额查询
|
||||
* @param walletAddress 钱包地址(代理地址,Polymarket 使用代理地址存储资产)
|
||||
* @return WalletBalanceResponse 包含可用余额、仓位余额、总余额和持仓列表
|
||||
*/
|
||||
suspend fun getWalletBalance(walletAddress: String): Result<WalletBalanceResponse> {
|
||||
return try {
|
||||
if (walletAddress.isBlank()) {
|
||||
logger.error("钱包地址为空,无法查询余额")
|
||||
return Result.failure(IllegalArgumentException("钱包地址不能为空"))
|
||||
}
|
||||
|
||||
// 1. 查询持仓信息(用于返回持仓列表)
|
||||
val positionsResult = getPositions(walletAddress)
|
||||
val positions = if (positionsResult.isSuccess) {
|
||||
// 过滤掉价值为0的仓位
|
||||
positionsResult.getOrNull()?.filter { pos ->
|
||||
val currentValue = pos.currentValue ?: 0.0
|
||||
currentValue > 0
|
||||
}?.map { pos ->
|
||||
PositionDto(
|
||||
marketId = pos.conditionId ?: "",
|
||||
title = pos.title,
|
||||
side = pos.outcome ?: "",
|
||||
quantity = pos.size?.toString() ?: "0",
|
||||
avgPrice = pos.avgPrice?.toString() ?: "0",
|
||||
currentValue = pos.currentValue?.toString() ?: "0",
|
||||
pnl = pos.cashPnl?.toString()
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
logger.warn("持仓信息查询失败: ${positionsResult.exceptionOrNull()?.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// 2. 使用 /value 接口获取仓位总价值
|
||||
val positionBalanceResult = getTotalValue(walletAddress)
|
||||
val positionBalance = if (positionBalanceResult.isSuccess) {
|
||||
positionBalanceResult.getOrNull() ?: "0"
|
||||
} else {
|
||||
logger.warn("仓位总价值查询失败: ${positionBalanceResult.exceptionOrNull()?.message}")
|
||||
"0"
|
||||
}
|
||||
|
||||
// 3. 查询可用余额(通过 RPC 查询 USDC 余额)
|
||||
val availableBalanceResult = getUsdcBalance(
|
||||
walletAddress = walletAddress,
|
||||
proxyAddress = walletAddress
|
||||
)
|
||||
val availableBalance = if (availableBalanceResult.isSuccess) {
|
||||
availableBalanceResult.getOrNull() ?: throw Exception("USDC 余额查询返回空值")
|
||||
} else {
|
||||
// 如果 RPC 查询失败,返回错误(不返回 mock 数据)
|
||||
val error = availableBalanceResult.exceptionOrNull()
|
||||
logger.error("USDC 可用余额 RPC 查询失败: ${error?.message}")
|
||||
throw Exception("USDC 可用余额查询失败: ${error?.message}。请确保已配置 Ethereum RPC URL")
|
||||
}
|
||||
|
||||
// 4. 计算总余额 = 可用余额 + 仓位余额
|
||||
val totalBalance = availableBalance.toSafeBigDecimal().add(positionBalance.toSafeBigDecimal())
|
||||
|
||||
Result.success(
|
||||
WalletBalanceResponse(
|
||||
availableBalance = availableBalance,
|
||||
positionBalance = positionBalance,
|
||||
totalBalance = totalBalance.toPlainString(),
|
||||
positions = positions
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询钱包余额失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 RPC 查询 USDC 余额
|
||||
@@ -502,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)
|
||||
|
||||
|
||||
+1
-34
@@ -315,7 +315,7 @@ class CopyTradingFilterService(
|
||||
outcomeIndex: Int?
|
||||
): FilterResult {
|
||||
// 如果未配置仓位限制,直接通过
|
||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
||||
if (copyTrading.maxPositionValue == null) {
|
||||
return FilterResult.passed()
|
||||
}
|
||||
|
||||
@@ -369,39 +369,6 @@ class CopyTradingFilterService(
|
||||
}
|
||||
}
|
||||
|
||||
// 检查最大仓位数量(如果配置了)
|
||||
if (copyTrading.maxPositionCount != null) {
|
||||
// 使用数据库中的订单记录计算活跃仓位数量(解决延迟问题)
|
||||
val dbCount = copyOrderTrackingRepository.countActivePositions(copyTrading.id!!)
|
||||
|
||||
// 计算外部持仓中的唯一市场数量(防止遗漏非本项目创建的仓位)
|
||||
val extCount = positions.currentPositions
|
||||
.filter { it.accountId == copyTrading.accountId }
|
||||
.map { it.marketId }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
val currentPositionCount = maxOf(dbCount, extCount)
|
||||
|
||||
// 检查:如果当前没有该市场该方向的活跃仓位,且总仓位数量已达到限制,则不允许开新仓
|
||||
// 判断当前市场该方向是否已有活跃仓位(数据库)
|
||||
val hasDbPosition = if (outcomeIndex != null) {
|
||||
copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||
copyTrading.id, marketId, outcomeIndex
|
||||
).isNotEmpty()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val hasExtPosition = marketPositions.isNotEmpty()
|
||||
val hasCurrentMarketPosition = hasDbPosition || hasExtPosition
|
||||
|
||||
if (!hasCurrentMarketPosition && currentPositionCount >= copyTrading.maxPositionCount) {
|
||||
return FilterResult.maxPositionCountFailed(
|
||||
"超过最大仓位数量限制: 当前活跃仓位总数(取最大值)=${currentPositionCount} (DB=${dbCount}, Ext=${extCount}) >= 最大限制=${copyTrading.maxPositionCount}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return FilterResult.passed()
|
||||
} catch (e: Exception) {
|
||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
||||
|
||||
-15
@@ -100,7 +100,6 @@ class CopyTradingService(
|
||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
@@ -132,7 +131,6 @@ class CopyTradingService(
|
||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||
maxPositionCount = request.maxPositionCount,
|
||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||
keywords = convertKeywordsToJson(request.keywords),
|
||||
maxMarketEndDate = request.maxMarketEndDate,
|
||||
@@ -164,7 +162,6 @@ class CopyTradingService(
|
||||
minPrice = config.minPrice,
|
||||
maxPrice = config.maxPrice,
|
||||
maxPositionValue = config.maxPositionValue,
|
||||
maxPositionCount = config.maxPositionCount,
|
||||
keywordFilterMode = config.keywordFilterMode,
|
||||
keywords = config.keywords,
|
||||
configName = configName,
|
||||
@@ -282,16 +279,6 @@ class CopyTradingService(
|
||||
} else {
|
||||
copyTrading.maxPositionValue
|
||||
},
|
||||
// 处理 maxPositionCount:-1 表示要清空(设置为 null),null 表示不更新
|
||||
maxPositionCount = if (request.maxPositionCount != null) {
|
||||
if (request.maxPositionCount == -1) {
|
||||
null
|
||||
} else {
|
||||
request.maxPositionCount
|
||||
}
|
||||
} else {
|
||||
copyTrading.maxPositionCount
|
||||
},
|
||||
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
|
||||
keywords = if (request.keywords != null) {
|
||||
convertKeywordsToJson(request.keywords)
|
||||
@@ -520,7 +507,6 @@ class CopyTradingService(
|
||||
minPrice = copyTrading.minPrice?.toPlainString(),
|
||||
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
||||
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
||||
maxPositionCount = copyTrading.maxPositionCount,
|
||||
keywordFilterMode = copyTrading.keywordFilterMode,
|
||||
keywords = convertJsonToKeywords(copyTrading.keywords),
|
||||
configName = copyTrading.configName,
|
||||
@@ -585,7 +571,6 @@ class CopyTradingService(
|
||||
val minPrice: BigDecimal?,
|
||||
val maxPrice: BigDecimal?,
|
||||
val maxPositionValue: BigDecimal?,
|
||||
val maxPositionCount: Int?,
|
||||
val keywordFilterMode: String,
|
||||
val keywords: String?, // JSON 字符串
|
||||
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
|
||||
|
||||
-8
@@ -20,8 +20,6 @@ enum class FilterStatus {
|
||||
FAILED_ORDER_DEPTH,
|
||||
/** 失败:超过最大仓位金额 */
|
||||
FAILED_MAX_POSITION_VALUE,
|
||||
/** 失败:超过最大仓位数量 */
|
||||
FAILED_MAX_POSITION_COUNT,
|
||||
/** 失败:关键字过滤 */
|
||||
FAILED_KEYWORD_FILTER,
|
||||
/** 失败:市场截止时间超出限制 */
|
||||
@@ -87,12 +85,6 @@ data class FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 超过最大仓位数量 */
|
||||
fun maxPositionCountFailed(reason: String) = FilterResult(
|
||||
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
|
||||
reason = reason
|
||||
)
|
||||
|
||||
/** 关键字过滤失败 */
|
||||
fun keywordFilterFailed(reason: String) = FilterResult(
|
||||
|
||||
+48
-6
@@ -3,12 +3,15 @@ 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
|
||||
import com.wrbug.polymarketbot.util.CategoryValidator
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Leader 管理服务
|
||||
@@ -17,9 +20,11 @@ import org.springframework.transaction.annotation.Transactional
|
||||
class LeaderService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val backtestTaskRepository: BacktestTaskRepository,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
|
||||
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
|
||||
|
||||
/**
|
||||
@@ -154,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(
|
||||
@@ -176,19 +182,54 @@ class LeaderService(
|
||||
return try {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 Leader 余额
|
||||
* 使用代理地址查询 USDC 余额和持仓信息
|
||||
*/
|
||||
fun getLeaderBalance(leaderId: Long): Result<LeaderBalanceResponse> {
|
||||
return try {
|
||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
|
||||
// Leader 的 leaderAddress 就是代理地址
|
||||
val walletAddress = leader.leaderAddress
|
||||
|
||||
// 使用通用方法查询余额
|
||||
val balanceResult = runBlocking {
|
||||
blockchainService.getWalletBalance(walletAddress)
|
||||
}
|
||||
|
||||
balanceResult.map { walletBalance: WalletBalanceResponse ->
|
||||
LeaderBalanceResponse(
|
||||
leaderId = leader.id!!,
|
||||
leaderAddress = leader.leaderAddress,
|
||||
leaderName = leader.leaderName,
|
||||
availableBalance = walletBalance.availableBalance,
|
||||
positionBalance = walletBalance.positionBalance,
|
||||
totalBalance = walletBalance.totalBalance,
|
||||
positions = walletBalance.positions
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 余额失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 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,
|
||||
@@ -197,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)
|
||||
|
||||
+65
-38
@@ -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 位小数
|
||||
@@ -94,14 +115,16 @@ class OrderSigningService {
|
||||
if (side.uppercase() == "BUY") {
|
||||
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
|
||||
// 参考 clob-client/src/order-builder/helpers.ts 第 73-89 行
|
||||
val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
||||
// 注意:Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
|
||||
// takerAmount (shares) 使用 4 位小数
|
||||
val rawTakerAmt = roundDown(sizeDecimal, 4)
|
||||
|
||||
var rawMakerAmt = rawTakerAmt.multiply(rawPrice)
|
||||
// 如果 makerAmount 的小数位数超过 roundConfig.amount,进行特殊舍入处理
|
||||
if (decimalPlaces(rawMakerAmt) > roundConfig.amount) {
|
||||
rawMakerAmt = roundUp(rawMakerAmt, roundConfig.amount + 4)
|
||||
if (decimalPlaces(rawMakerAmt) > roundConfig.amount) {
|
||||
rawMakerAmt = roundDown(rawMakerAmt, roundConfig.amount)
|
||||
// makerAmount (USDC) 使用 2 位小数
|
||||
if (decimalPlaces(rawMakerAmt) > 2) {
|
||||
rawMakerAmt = roundUp(rawMakerAmt, 2 + 4)
|
||||
if (decimalPlaces(rawMakerAmt) > 2) {
|
||||
rawMakerAmt = roundDown(rawMakerAmt, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,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(
|
||||
@@ -157,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. 从私钥获取签名地址
|
||||
@@ -190,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,
|
||||
@@ -255,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,
|
||||
@@ -281,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)
|
||||
@@ -311,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
-43
@@ -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 的交易,避免订单长期挂单导致价格不匹配
|
||||
@@ -1430,7 +1486,6 @@ open class CopyOrderTrackingService(
|
||||
FilterStatus.FAILED_SPREAD -> "SPREAD"
|
||||
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
|
||||
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
|
||||
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
|
||||
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
|
||||
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
|
||||
}
|
||||
|
||||
-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,8 @@
|
||||
-- ============================================
|
||||
-- V26: 移除最大仓位数量配置字段
|
||||
-- 从 copy_trading 表中删除 max_position_count 字段
|
||||
-- ============================================
|
||||
|
||||
ALTER TABLE copy_trading
|
||||
DROP COLUMN max_position_count;
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user