Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5acad5091 | ||
|
|
1688fa9633 | ||
|
|
1967d97c31 | ||
|
|
79b154515d | ||
|
|
9b4d8fc001 | ||
|
|
cb1f43871a | ||
|
|
a2be5b7f52 | ||
|
|
708d6ddb41 | ||
|
|
c5d59dfdf9 | ||
|
|
4b277eaeab | ||
|
|
f35bad78d4 | ||
|
|
0740abcf16 | ||
|
|
377da4fff6 | ||
|
|
84c79d8812 | ||
|
|
d3196a783f | ||
|
|
4c989a48c4 | ||
|
|
5bb46ebb97 | ||
|
|
cbcebf6e28 | ||
|
|
2013a2eb70 | ||
|
|
de89175c8e | ||
|
|
4e1bb0bbcf | ||
|
|
24a4487d0b | ||
|
|
24bb7bed40 | ||
|
|
6b56f62532 | ||
|
|
c38529546f | ||
|
|
08e1219f77 | ||
|
|
62842f5aa8 | ||
|
|
13662dd0d5 | ||
|
|
07c3548401 | ||
|
|
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 | ||
|
|
364db124d1 | ||
|
|
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 | ||
|
|
c56e08e681 | ||
|
|
7e87965418 | ||
|
|
e115d459f7 | ||
|
|
dd39e59304 | ||
|
|
2efc04a3c3 | ||
|
|
2b20f4b2e2 | ||
|
|
419c68c024 | ||
|
|
8889803dd7 | ||
|
|
9c5517768f | ||
|
|
4aa85a9c2d | ||
|
|
a77b3b10ee | ||
|
|
532f4c3e25 | ||
|
|
04629e73b6 | ||
|
|
696193c571 | ||
|
|
38e256c4fb | ||
|
|
64a78406ed | ||
|
|
59297ec52f | ||
|
|
0ff6832dc1 | ||
|
|
56a8928631 | ||
|
|
266c6d595d | ||
|
|
b2e0816968 | ||
|
|
dc47583565 | ||
|
|
26dd3bb387 | ||
|
|
fabfe601c6 | ||
|
|
662aa47de6 | ||
|
|
c3d9d10d5d | ||
|
|
9926533049 | ||
|
|
4d72017b97 | ||
|
|
45734c051e | ||
|
|
db8471bb16 | ||
|
|
0dc6f5894f | ||
|
|
c9769aa17a | ||
|
|
07b4d654b4 | ||
|
|
b65827038f | ||
|
|
d768da72c6 | ||
|
|
7385efff1a | ||
|
|
3e2e97e572 | ||
|
|
ae68a33c1e | ||
|
|
deea59fdbf | ||
|
|
b90f86b081 | ||
|
|
f6f5866118 | ||
|
|
b1e69135b8 | ||
|
|
0c7f34a28a | ||
|
|
c53fcde5d7 | ||
|
|
81a620af12 | ||
|
|
5f44a0ca20 | ||
|
|
5d2cf945f3 | ||
|
|
227a38fa89 | ||
|
|
abcc004606 | ||
|
|
64391503c2 | ||
|
|
92b75d1926 | ||
|
|
e072d0c894 | ||
|
|
cc40493ec6 | ||
|
|
3a78a84610 | ||
|
|
692cbd9a80 | ||
|
|
8f01588221 | ||
|
|
f6fc836e79 | ||
|
|
9c303e0a82 | ||
|
|
cb8e46919f | ||
|
|
279806da2b | ||
|
|
90fa487d1b | ||
|
|
b58bb267a1 | ||
|
|
6af76c4d80 | ||
|
|
43de0104e2 | ||
|
|
d624eaeb57 | ||
|
|
6ad40242b3 | ||
|
|
2ccab42894 | ||
|
|
3008cbcb50 | ||
|
|
42a318b501 | ||
|
|
ecb737ec67 | ||
|
|
0f3baec6cb | ||
|
|
764d684846 |
@@ -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"]
|
||||||
|
}
|
||||||
|
|
||||||
+2
-2
@@ -14,11 +14,11 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# 构建产物
|
# 构建产物
|
||||||
backend/build/
|
# 注意:frontend/dist 和 backend/build/libs 在使用 BUILD_IN_DOCKER=false 时是必需的
|
||||||
|
# 所以不能忽略它们。在 BUILD_IN_DOCKER=true 时,它们会被 Docker 内部编译覆盖
|
||||||
backend/.gradle/
|
backend/.gradle/
|
||||||
backend/out/
|
backend/out/
|
||||||
backend/bin/
|
backend/bin/
|
||||||
frontend/dist/
|
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/.vite/
|
frontend/.vite/
|
||||||
frontend/.cache/
|
frontend/.cache/
|
||||||
|
|||||||
@@ -4,39 +4,104 @@ on:
|
|||||||
release:
|
release:
|
||||||
types:
|
types:
|
||||||
- published # 当通过 GitHub Releases 页面创建 release 时触发
|
- 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:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # 需要写权限以上传 Assets
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
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: Extract version from release
|
- 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
|
id: extract_version
|
||||||
run: |
|
run: |
|
||||||
# 从 release tag 中提取版本号(例如 v1.0.0 -> 1.0.0)
|
# 从不同事件源提取版本号
|
||||||
TAG_NAME="${{ github.event.release.tag_name }}"
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
if [ -z "$TAG_NAME" ]; then
|
# Release 事件:从 release tag 中提取
|
||||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
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
|
fi
|
||||||
|
|
||||||
# 验证版本号格式:v数字.数字.数字[-后缀](例如 v1.0.0, v2.10.102, v1.0.0-beta)
|
# 验证版本号格式: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
|
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] && [[ ! "$TAG_NAME" =~ ^dev- ]]; then
|
||||||
echo "错误: 版本号格式不正确,应为 v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
echo "⚠️ 警告: 版本号格式不符合标准,但仍将继续构建"
|
||||||
exit 1
|
echo " 当前版本号: $TAG_NAME"
|
||||||
|
echo " 标准格式应为: v数字.数字.数字 或 v数字.数字.数字-后缀 (例如: v1.0.0, v1.0.0-beta)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION=${TAG_NAME#v} # 移除 v 前缀
|
VERSION=${TAG_NAME#v} # 移除 v 前缀(如果存在)
|
||||||
|
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
|
echo "TAG=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||||
echo "Extracted version: $VERSION"
|
echo "IS_PRERELEASE=$IS_PRERELEASE" >> $GITHUB_OUTPUT
|
||||||
echo "Full tag: $TAG_NAME"
|
|
||||||
|
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||||
|
echo "📋 这是 Pre-release: $TAG_NAME"
|
||||||
|
else
|
||||||
|
echo "📦 这是正式版本: $TAG_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Send Telegram notification (build started)
|
- name: Send Telegram notification (build started)
|
||||||
|
if: steps.extract_version.outputs.IS_PRERELEASE == 'false' && steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||||
env:
|
env:
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
@@ -48,12 +113,15 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 获取构建信息
|
# 获取构建信息
|
||||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
|
||||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
|
||||||
|
|
||||||
# 构建消息内容(仅包含关键信息)
|
if [ "${{ github.event_name }}" = "release" ]; then
|
||||||
MESSAGE="🔨 <b>Docker 镜像构建中</b>"$'\n'$'\n'"📦 <b>版本:</b> ${VERSION}"$'\n'"🏷️ <b>Tag:</b> <code>${TAG}</code>"$'\n'"🔗 <a href=\"${RELEASE_URL}\">查看 Release</a>"
|
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)
|
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
@@ -79,19 +147,155 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ============ 编译前后端产物 ============
|
||||||
|
- name: Setup JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Build Backend JAR
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew bootJar --no-daemon
|
||||||
|
echo "✅ 后端构建完成"
|
||||||
|
ls -lh build/libs/*.jar
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.extract_version.outputs.VERSION }}
|
||||||
|
GIT_TAG: ${{ steps.extract_version.outputs.TAG }}
|
||||||
|
GITHUB_REPO_URL: https://github.com/WrBug/PolyHermes
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
echo "✅ 前端构建完成"
|
||||||
|
echo "📦 版本信息: VERSION=${{ steps.extract_version.outputs.VERSION }}, GIT_TAG=${{ steps.extract_version.outputs.TAG }}"
|
||||||
|
du -sh dist/
|
||||||
|
|
||||||
|
# ============ 打包更新包 ============
|
||||||
|
- name: Create Update Package
|
||||||
|
run: |
|
||||||
|
echo "📦 开始打包更新包..."
|
||||||
|
|
||||||
|
# 创建目录结构
|
||||||
|
mkdir -p update-package/backend
|
||||||
|
mkdir -p update-package/frontend
|
||||||
|
|
||||||
|
# 复制后端 JAR
|
||||||
|
cp backend/build/libs/*.jar update-package/backend/polyhermes.jar
|
||||||
|
echo "✅ 后端 JAR 已复制"
|
||||||
|
|
||||||
|
# 复制前端产物
|
||||||
|
cp -r frontend/dist/* update-package/frontend/
|
||||||
|
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": ${RELEASE_NOTES}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
echo "✅ 版本信息已创建"
|
||||||
|
|
||||||
|
# 打包成 tar.gz
|
||||||
|
cd update-package
|
||||||
|
tar -czf ../polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz .
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "✅ 打包完成: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
|
||||||
|
ls -lh polyhermes-*.tar.gz
|
||||||
|
|
||||||
|
- name: Calculate Checksum
|
||||||
|
id: checksum
|
||||||
|
run: |
|
||||||
|
FILE="polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz"
|
||||||
|
CHECKSUM=$(sha256sum "$FILE" | awk '{print $1}')
|
||||||
|
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||||
|
echo "✅ SHA256: $CHECKSUM"
|
||||||
|
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 }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ github.event.release.upload_url }}
|
||||||
|
asset_path: ./polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||||
|
asset_name: polyhermes-${{ steps.extract_version.outputs.TAG }}-update.tar.gz
|
||||||
|
asset_content_type: application/gzip
|
||||||
|
|
||||||
|
- name: Upload Checksums to Release
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
uses: actions/upload-release-asset@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ github.event.release.upload_url }}
|
||||||
|
asset_path: ./checksums.txt
|
||||||
|
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
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
# 启用多架构构建支持
|
# 启用多架构构建支持
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
|
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Prepare Docker build context
|
||||||
|
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||||
|
run: |
|
||||||
|
echo "📦 准备 Docker 构建上下文..."
|
||||||
|
# 确保构建产物存在且可访问
|
||||||
|
if [ ! -d "frontend/dist" ]; then
|
||||||
|
echo "❌ 错误:frontend/dist 不存在"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -d "backend/build/libs" ] || [ -z "$(ls -A backend/build/libs/*.jar 2>/dev/null)" ]; then
|
||||||
|
echo "❌ 错误:backend/build/libs/*.jar 不存在"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ 构建产物已准备好"
|
||||||
|
ls -lh frontend/dist/ | head -5
|
||||||
|
ls -lh backend/build/libs/*.jar
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
|
if: steps.build_config.outputs.BUILD_TYPE == 'package-and-docker'
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -101,15 +305,23 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
tags: |
|
tags: |
|
||||||
wrbug/polyhermes:${{ steps.extract_version.outputs.TAG }}
|
wrbug/polyhermes:${{ steps.extract_version.outputs.TAG }}
|
||||||
wrbug/polyhermes:latest
|
${{ steps.extract_version.outputs.IS_PRERELEASE == 'false' && 'wrbug/polyhermes:latest' || '' }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
BUILD_IN_DOCKER=false
|
||||||
VERSION=${{ steps.extract_version.outputs.VERSION }}
|
VERSION=${{ steps.extract_version.outputs.VERSION }}
|
||||||
GIT_TAG=${{ steps.extract_version.outputs.TAG }}
|
GIT_TAG=${{ steps.extract_version.outputs.TAG }}
|
||||||
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
||||||
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
cache-from: type=registry,ref=wrbug/polyhermes:latest
|
||||||
cache-to: type=inline
|
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
|
- name: Send Telegram notification
|
||||||
|
if: steps.extract_version.outputs.IS_PRERELEASE == 'false'
|
||||||
env:
|
env:
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
@@ -123,13 +335,26 @@ jobs:
|
|||||||
# 获取构建信息
|
# 获取构建信息
|
||||||
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
VERSION="${{ steps.extract_version.outputs.VERSION }}"
|
||||||
TAG="${{ steps.extract_version.outputs.TAG }}"
|
TAG="${{ steps.extract_version.outputs.TAG }}"
|
||||||
RELEASE_NAME="${{ github.event.release.name }}"
|
BUILD_TYPE="${{ steps.build_config.outputs.BUILD_TYPE }}"
|
||||||
RELEASE_URL="${{ github.event.release.html_url }}"
|
|
||||||
REPO_NAME="${{ github.repository }}"
|
|
||||||
|
|
||||||
# 构建消息内容(仅包含关键信息)
|
# 构建消息内容(仅包含关键信息)
|
||||||
DEPLOY_DOC_URL="https://github.com/WrBug/PolyHermes/blob/main/docs/zh/DEPLOYMENT.md"
|
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)
|
# 发送 Telegram 消息(使用 jq 转义 JSON)
|
||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
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
|
|
||||||
|
|
||||||
+4
-2
@@ -17,14 +17,16 @@ backend/out/
|
|||||||
backend/*.log
|
backend/*.log
|
||||||
backend/gradle-app.setting
|
backend/gradle-app.setting
|
||||||
backend/.gradle
|
backend/.gradle
|
||||||
backend/gradle-wrapper.jar
|
# 注意:gradle-wrapper.jar 应该被提交,不要忽略
|
||||||
|
# backend/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
polyhub/
|
||||||
# Kotlin
|
# Kotlin
|
||||||
*.kt.bak
|
*.kt.bak
|
||||||
*.class
|
*.class
|
||||||
|
|
||||||
# Java
|
# Java
|
||||||
*.jar
|
*.jar
|
||||||
|
!backend/gradle/wrapper/gradle-wrapper.jar # Gradle Wrapper JAR 应该被提交
|
||||||
*.war
|
*.war
|
||||||
*.ear
|
*.ear
|
||||||
*.class
|
*.class
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
# 🎉 PolyHermes v1.1.7 发布公告
|
|
||||||
|
|
||||||
**发布日期:2026年1月7日**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ 新功能
|
|
||||||
|
|
||||||
### 💰 支持 Maker Rebates Program 费率
|
|
||||||
|
|
||||||
我们新增了对 Polymarket Maker Rebates Program 的支持!系统现在会自动获取并应用最新的费率,帮助您享受更优惠的交易成本。
|
|
||||||
|
|
||||||
**这意味着什么?**
|
|
||||||
- 系统会自动查询并应用最新的费率
|
|
||||||
- 所有订单(买入、卖出)都会使用正确的费率
|
|
||||||
- 无需手动配置,系统会自动处理
|
|
||||||
|
|
||||||
### 🔧 Docker 部署更灵活
|
|
||||||
|
|
||||||
现在您可以通过环境变量轻松配置日志级别,无需修改配置文件!
|
|
||||||
|
|
||||||
**新增配置项:**
|
|
||||||
- `LOG_LEVEL_ROOT` - 系统日志级别(默认:INFO)
|
|
||||||
- `LOG_LEVEL_APP` - 应用日志级别(默认:DEBUG)
|
|
||||||
|
|
||||||
只需在 `.env` 文件中添加这些配置,重启服务即可生效。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 问题修复
|
|
||||||
|
|
||||||
### 修复市场价格查询问题
|
|
||||||
|
|
||||||
修复了某些市场无法正确查询价格的问题。现在系统会:
|
|
||||||
- 优先从链上查询市场价格
|
|
||||||
- 如果链上查询失败,自动降级到其他数据源
|
|
||||||
- 提高系统的稳定性和容错性
|
|
||||||
|
|
||||||
### 修复自动卖出误判问题
|
|
||||||
|
|
||||||
修复了在某些情况下系统会误判市场已卖出,导致创建错误记录的问题。现在系统会:
|
|
||||||
- 更准确地判断市场状态
|
|
||||||
- 避免误判导致的错误记录
|
|
||||||
- 提高仓位管理的准确性
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 文档更新
|
|
||||||
|
|
||||||
### 更新联系方式
|
|
||||||
|
|
||||||
- **Telegram 群组**:https://t.me/polyhermes
|
|
||||||
- 欢迎加入我们的 Telegram 群组,获取最新资讯和技术支持!
|
|
||||||
|
|
||||||
### 版本信息更清晰
|
|
||||||
|
|
||||||
- README 中新增了 Docker 版本徽章
|
|
||||||
- 可以一目了然地看到最新的 Docker 镜像版本
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 本次更新统计
|
|
||||||
|
|
||||||
- **5 个提交**
|
|
||||||
- **16 个文件变更**
|
|
||||||
- **主要改进**:费率支持、错误修复、部署优化
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 如何升级
|
|
||||||
|
|
||||||
### Docker 部署用户(推荐)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 停止当前服务
|
|
||||||
docker-compose -f docker-compose.prod.yml down
|
|
||||||
|
|
||||||
# 2. 拉取最新镜像
|
|
||||||
docker pull wrbug/polyhermes:latest
|
|
||||||
|
|
||||||
# 3. 重新启动服务
|
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
|
||||||
|
|
||||||
# 4. 查看日志确认升级成功
|
|
||||||
docker-compose -f docker-compose.prod.yml logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### 本地构建用户
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 拉取最新代码
|
|
||||||
git pull origin dev
|
|
||||||
|
|
||||||
# 2. 切换到 v1.1.7 标签
|
|
||||||
git checkout v1.1.7
|
|
||||||
|
|
||||||
# 3. 重新构建并启动
|
|
||||||
./deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 重要提示
|
|
||||||
|
|
||||||
### 数据库迁移
|
|
||||||
|
|
||||||
**本次更新无需数据库迁移**,可以直接升级,不会影响现有数据。
|
|
||||||
|
|
||||||
### 配置变更
|
|
||||||
|
|
||||||
- 新增的日志级别配置为**可选配置**
|
|
||||||
- 如果不配置,系统会使用默认值(INFO/DEBUG)
|
|
||||||
- 不影响现有功能
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🙏 感谢
|
|
||||||
|
|
||||||
感谢所有用户的支持和反馈!如果您在使用过程中遇到任何问题,欢迎:
|
|
||||||
|
|
||||||
- 📧 提交 Issue:https://github.com/WrBug/PolyHermes/issues
|
|
||||||
- 💬 加入 Telegram 群组:https://t.me/polyhermes
|
|
||||||
- 🐦 关注 Twitter:@polyhermes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 相关链接
|
|
||||||
|
|
||||||
- **GitHub 仓库**:https://github.com/WrBug/PolyHermes
|
|
||||||
- **完整更新日志**:https://github.com/WrBug/PolyHermes/releases/tag/v1.1.7
|
|
||||||
- **Docker Hub**:https://hub.docker.com/r/wrbug/polyhermes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**祝您交易顺利!** 🚀
|
|
||||||
|
|
||||||
|
|
||||||
+88
-31
@@ -1,6 +1,12 @@
|
|||||||
# 多阶段构建:前后端一体化部署
|
# 多阶段构建:前后端一体化部署(支持混合编译)
|
||||||
# 阶段1:构建前端
|
# 构建参数:控制是否在 Docker 内编译
|
||||||
|
# - BUILD_IN_DOCKER=true (默认): Docker 内部编译(本地开发)
|
||||||
|
# - BUILD_IN_DOCKER=false: 使用外部产物(GitHub Actions)
|
||||||
|
ARG BUILD_IN_DOCKER=true
|
||||||
|
|
||||||
|
# ==================== 阶段1:构建前端 ====================
|
||||||
FROM node:18-alpine AS frontend-build
|
FROM node:18-alpine AS frontend-build
|
||||||
|
ARG BUILD_IN_DOCKER
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
@@ -13,19 +19,37 @@ ARG GITHUB_REPO_URL=https://github.com/WrBug/PolyHermes
|
|||||||
ENV VERSION=${VERSION}
|
ENV VERSION=${VERSION}
|
||||||
ENV GIT_TAG=${GIT_TAG}
|
ENV GIT_TAG=${GIT_TAG}
|
||||||
ENV GITHUB_REPO_URL=${GITHUB_REPO_URL}
|
ENV GITHUB_REPO_URL=${GITHUB_REPO_URL}
|
||||||
|
# 复制前端文件(先复制 package.json 以利用 Docker 缓存)
|
||||||
# 复制前端文件
|
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
|
# 条件:仅在 Docker 内部编译时安装依赖
|
||||||
|
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||||
|
npm ci; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 复制所有前端源文件
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
|
||||||
# 构建前端(使用相对路径,通过 Nginx 代理)
|
# 条件:仅在 Docker 内部编译时执行构建
|
||||||
# 版本号会通过环境变量注入到构建产物中
|
# 如果 BUILD_IN_DOCKER=false,需要确保构建上下文中存在 frontend/dist
|
||||||
RUN npm run build
|
# 注意:COPY frontend/ ./ 已经复制了整个 frontend 目录(包括 dist,如果存在)
|
||||||
|
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||||
|
echo "🔨 Docker 内部编译前端..."; \
|
||||||
|
npm run build; \
|
||||||
|
else \
|
||||||
|
echo "⏭️ 使用外部产物..."; \
|
||||||
|
if [ ! -d "dist" ] || [ -z "$(ls -A dist 2>/dev/null)" ]; then \
|
||||||
|
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 frontend/dist"; \
|
||||||
|
echo " 请先执行: cd frontend && npm install && npm run build"; \
|
||||||
|
exit 1; \
|
||||||
|
else \
|
||||||
|
echo "✅ 找到外部构建的前端产物"; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
# 阶段2:构建后端
|
# ==================== 阶段2:构建后端 ====================
|
||||||
FROM gradle:8.5-jdk17 AS backend-build
|
FROM gradle:8.5-jdk17 AS backend-build
|
||||||
|
ARG BUILD_IN_DOCKER
|
||||||
|
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
|
|
||||||
@@ -33,60 +57,93 @@ WORKDIR /app/backend
|
|||||||
COPY backend/build.gradle.kts backend/settings.gradle.kts ./
|
COPY backend/build.gradle.kts backend/settings.gradle.kts ./
|
||||||
COPY backend/gradle ./gradle
|
COPY backend/gradle ./gradle
|
||||||
|
|
||||||
# 下载依赖(利用 Docker 缓存)
|
# 条件:仅在 Docker 内部编译时下载依赖
|
||||||
RUN gradle dependencies --no-daemon || true
|
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||||
|
gradle dependencies --no-daemon || true; \
|
||||||
|
fi
|
||||||
|
|
||||||
# 复制源代码
|
# 复制源代码
|
||||||
COPY backend/src ./src
|
COPY backend/src ./src
|
||||||
|
|
||||||
# 构建应用
|
# 尝试复制外部构建的 JAR(如果存在)
|
||||||
RUN gradle bootJar --no-daemon
|
# 注意:COPY 指令如果源不存在会失败
|
||||||
|
# GitHub Actions 使用 BUILD_IN_DOCKER=false,会先构建产物,所以 backend/build 应该存在
|
||||||
|
# 本地开发使用 BUILD_IN_DOCKER=true,会在 Docker 内编译,所以 backend/build 可能不存在
|
||||||
|
# 解决方案:先复制整个 backend 目录(包括 build,如果存在),然后只使用需要的部分
|
||||||
|
# 使用 .dockerignore 确保不会复制不需要的文件(如 .gradle、out、bin 等)
|
||||||
|
COPY backend/build ./build-external
|
||||||
|
|
||||||
# 阶段3:运行环境
|
# 处理外部构建的 JAR(如果存在)
|
||||||
|
RUN if [ -d "build-external/libs" ] && [ -n "$(ls -A build-external/libs/*.jar 2>/dev/null)" ]; then \
|
||||||
|
echo "📦 找到外部构建的后端产物,复制到 build/libs..."; \
|
||||||
|
mkdir -p build/libs; \
|
||||||
|
cp build-external/libs/*.jar build/libs/; \
|
||||||
|
rm -rf build-external; \
|
||||||
|
else \
|
||||||
|
echo "⏭️ 未找到外部构建的 JAR,将在 Docker 内编译"; \
|
||||||
|
rm -rf build-external; \
|
||||||
|
mkdir -p build/libs; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 条件:仅在 Docker 内部编译时执行构建(会覆盖外部产物)
|
||||||
|
RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||||
|
echo "🔨 Docker 内部编译后端..."; \
|
||||||
|
gradle bootJar --no-daemon; \
|
||||||
|
else \
|
||||||
|
echo "⏭️ 使用外部产物"; \
|
||||||
|
if [ -z "$(ls -A build/libs/*.jar 2>/dev/null)" ]; then \
|
||||||
|
echo "❌ 错误:BUILD_IN_DOCKER=false 但找不到外部产物 backend/build/libs/*.jar"; \
|
||||||
|
echo " 请先执行: cd backend && ./gradlew bootJar"; \
|
||||||
|
exit 1; \
|
||||||
|
else \
|
||||||
|
echo "✅ 使用外部构建的后端产物"; \
|
||||||
|
fi; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==================== 阶段3:运行环境 ====================
|
||||||
FROM eclipse-temurin:17-jre-jammy
|
FROM eclipse-temurin:17-jre-jammy
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 安装 Nginx 和必要的工具
|
# 安装 Nginx、Python 和必要的工具
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y nginx curl && \
|
apt-get install -y nginx curl tzdata jq python3 python3-flask python3-requests && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
rm -rf /etc/nginx/sites-enabled/default
|
rm -rf /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
# 从构建阶段复制文件
|
# 从构建阶段复制文件
|
||||||
|
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
|
||||||
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
|
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
|
||||||
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
|
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
|
||||||
|
|
||||||
# 复制 Nginx 配置
|
# 复制 Nginx 配置
|
||||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
# 创建启动脚本
|
# 创建更新服务相关目录和脚本
|
||||||
|
RUN mkdir -p /app/updates /app/backups /var/log/polyhermes
|
||||||
|
COPY docker/update-service.py /app/update-service.py
|
||||||
COPY docker/start.sh /app/start.sh
|
COPY docker/start.sh /app/start.sh
|
||||||
RUN chmod +x /app/start.sh
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
# 创建非 root 用户(用于运行后端应用)
|
# 记录初始版本(从构建参数)
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_TAG=dev
|
||||||
|
RUN echo "{\"version\":\"${VERSION}\",\"tag\":\"${GIT_TAG}\",\"buildTime\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > /app/version.json
|
||||||
|
|
||||||
|
# 创建非 root 用户
|
||||||
RUN useradd -m -u 1000 appuser
|
RUN useradd -m -u 1000 appuser
|
||||||
|
|
||||||
# 设置目录权限(Nginx 以 root 运行,后端应用以 appuser 运行)
|
# 设置目录权限
|
||||||
RUN mkdir -p /var/log/nginx /var/lib/nginx /var/cache/nginx /var/run && \
|
RUN mkdir -p /var/log/nginx /var/lib/nginx /var/cache/nginx /var/run && \
|
||||||
chown -R appuser:appuser /app && \
|
chown -R appuser:appuser /app && \
|
||||||
chown -R root:root /usr/share/nginx/html && \
|
chown -R root:root /usr/share/nginx/html /var/log/nginx /var/lib/nginx /var/cache/nginx /etc/nginx /var/run
|
||||||
chown -R root:root /var/log/nginx && \
|
|
||||||
chown -R root:root /var/lib/nginx && \
|
|
||||||
chown -R root:root /var/cache/nginx && \
|
|
||||||
chown -R root:root /etc/nginx && \
|
|
||||||
chown -R root:root /var/run
|
|
||||||
|
|
||||||
# 保持 root 用户(Nginx 需要 root 权限绑定 80 端口)
|
|
||||||
# USER appuser
|
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||||
CMD curl -f http://localhost/api/health || exit 1
|
CMD curl -f http://localhost/api/system/health || exit 1
|
||||||
|
|
||||||
# 启动服务(同时启动 Nginx 和后端)
|
# 启动服务
|
||||||
ENTRYPOINT ["/app/start.sh"]
|
ENTRYPOINT ["/app/start.sh"]
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
- **API 健康检查**:实时监控 Polymarket API 的健康状态
|
- **API 健康检查**:实时监控 Polymarket API 的健康状态
|
||||||
- **用户管理**:管理系统用户,支持添加、编辑、删除用户
|
- **用户管理**:管理系统用户,支持添加、编辑、删除用户
|
||||||
- **公告管理**:查看系统公告和更新信息
|
- **公告管理**:查看系统公告和更新信息
|
||||||
|
- **动态更新**:支持在线更新系统版本,无需重新部署容器
|
||||||
|
|
||||||
### 🚀 技术特性
|
### 🚀 技术特性
|
||||||
|
|
||||||
@@ -164,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. **使用 Docker Hub 镜像(推荐,生产环境首选)**
|
||||||
|
|
||||||
**方式 1:独立部署(无需 clone 代码,推荐)**
|
**方式 1:独立部署(无需 clone 代码,推荐)**
|
||||||
@@ -397,6 +421,7 @@ cd frontend
|
|||||||
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
|
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
|
||||||
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
|
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
|
||||||
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
|
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
|
||||||
|
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
|
||||||
|
|
||||||
### 🤝 贡献指南
|
### 🤝 贡献指南
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,29 @@ Deploy both frontend and backend together in a single Docker container, using Ng
|
|||||||
|
|
||||||
**Deployment Steps**:
|
**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)**
|
1. **Using Docker Hub Images (Recommended, Production First Choice)**
|
||||||
|
|
||||||
**Method 1: Standalone Deployment (No code clone required, Recommended)**
|
**Method 1: Standalone Deployment (No code clone required, Recommended)**
|
||||||
|
|||||||
-684
@@ -1,684 +0,0 @@
|
|||||||
# 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 支持一下!**
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
## 版本信息
|
||||||
|
- **版本号**: v1.1.15
|
||||||
|
- **发布日期**: 2026-01-19
|
||||||
|
- **基础版本**: v1.1.14
|
||||||
|
|
||||||
|
## 改动摘要
|
||||||
|
本次版本优化了订单详情处理逻辑,提升了系统稳定性和开发体验。
|
||||||
|
|
||||||
|
## 主要改动
|
||||||
|
|
||||||
|
### 🐛 Bug 修复
|
||||||
|
|
||||||
|
#### 1. 优化订单详情为 null 时的处理逻辑
|
||||||
|
- **提交**: 7385eff
|
||||||
|
- **文件**: OrderStatusUpdateService.kt
|
||||||
|
- **问题**:
|
||||||
|
- 订单详情为 null 且已发送通知超过 60 秒时,订单被直接删除
|
||||||
|
- 导致已经正确处理并发送 TG 通知的订单被意外删除
|
||||||
|
- **修复**:
|
||||||
|
- 当订单详情为 null 且 notificationSent = true 超过 60 秒时,将订单状态改为 fully_matched
|
||||||
|
- fully_matched 状态的订单会被自动过滤,不再查询详情
|
||||||
|
- 避免已处理的订单被误删除
|
||||||
|
|
||||||
|
### 🧹 日志清理
|
||||||
|
|
||||||
|
#### 2. 清理 MarketPollingService 中多余的 debug 日志
|
||||||
|
- **提交**: d768da7, 07b4d65
|
||||||
|
- **文件**: MarketPollingService.kt
|
||||||
|
- **改进**:
|
||||||
|
- 删除多余的 debug 日志输出
|
||||||
|
- 减少冗余日志,提升日志可读性
|
||||||
|
- 优化性能(减少日志 I/O)
|
||||||
|
|
||||||
|
### ✨ 新增功能
|
||||||
|
|
||||||
|
#### 3. 添加订单详情查询工具脚本
|
||||||
|
- **提交**: b658270
|
||||||
|
- **新增文件**:
|
||||||
|
- scripts/get-order-detail.js - 订单详情查询脚本
|
||||||
|
- scripts/package.json - 依赖配置文件
|
||||||
|
- **功能**:
|
||||||
|
- 快速查询 Polymarket 订单详情
|
||||||
|
- 支持自动创建 API Key
|
||||||
|
- 完善的错误处理和参数验证
|
||||||
|
- 详细的订单信息输出
|
||||||
|
|
||||||
|
## 文件变更统计
|
||||||
|
- **修改文件数**: 3
|
||||||
|
- **新增文件数**: 2
|
||||||
|
- **新增行数**: 192
|
||||||
|
- **删除行数**: 6
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
### 订单状态管理优化
|
||||||
|
- 使用 fully_matched 状态标记已处理订单
|
||||||
|
- 通过数据库查询条件自动过滤,无需额外缓存
|
||||||
|
- 保持数据一致性和可追溯性
|
||||||
|
|
||||||
|
### 工具脚本特性
|
||||||
|
- 基于 Polymarket CLOB Client v5.2.1
|
||||||
|
- 支持 derive/create API Key 自动处理
|
||||||
|
- 完整的参数验证和错误提示
|
||||||
|
- 友好的命令行交互体验
|
||||||
|
|
||||||
|
## 升级建议
|
||||||
|
- 无需特殊操作,直接部署即可
|
||||||
|
- 建议验证订单处理逻辑是否正常工作
|
||||||
|
- 可以使用新增的工具脚本进行调试
|
||||||
|
|
||||||
|
## 完整提交列表
|
||||||
|
- 7385eff - 优化订单详情为null时的处理逻辑
|
||||||
|
- d768da7 - 清理 MarketPollingService 中多余的 debug 日志
|
||||||
|
- b658270 - 添加订单详情查询脚本
|
||||||
|
- 07b4d65 - 清理 MarketPollingService 调试日志
|
||||||
|
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ export PROXY_PORT=8888
|
|||||||
- 代理配置错误
|
- 代理配置错误
|
||||||
|
|
||||||
**排查步骤**:
|
**排查步骤**:
|
||||||
1. 检查 `polymarket.rtds.ws-url` 配置是否正确
|
1. 检查 Polymarket RTDS WebSocket URL(现在使用代码常量 `PolymarketConstants.RTDS_WS_URL`)
|
||||||
2. 检查网络连接
|
2. 检查网络连接
|
||||||
3. 查看详细错误日志
|
3. 查看详细错误日志
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-8000}:8000"
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
- SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE:-prod}
|
||||||
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
- DB_URL=${DB_URL:-jdbc:mysql://mysql:3306/polyhermes?useSSL=false&serverTimezone=UTC&characterEncoding=utf8&allowPublicKeyRetrieval=true}
|
||||||
- DB_USERNAME=${DB_USERNAME:-root}
|
- DB_USERNAME=${DB_USERNAME:-root}
|
||||||
@@ -16,6 +17,8 @@ services:
|
|||||||
- SERVER_PORT=8000
|
- SERVER_PORT=8000
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||||
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
- ADMIN_RESET_PASSWORD_KEY=${ADMIN_RESET_PASSWORD_KEY:-change-me-in-production}
|
||||||
|
volumes:
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -29,12 +32,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${MYSQL_PORT:-3306}:3306"
|
- "${MYSQL_PORT:-3306}:3306"
|
||||||
environment:
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-rootpassword}
|
||||||
- MYSQL_DATABASE=polyhermes
|
- MYSQL_DATABASE=polyhermes
|
||||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-data:/var/lib/mysql
|
- mysql-data:/var/lib/mysql
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-rootpassword}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
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 "✅ 清理完成!现在可以重启应用了"
|
||||||
BIN
Binary file not shown.
@@ -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("address") address: String,
|
||||||
@Query("type") type: String
|
@Query("type") type: String
|
||||||
): Response<NoncePayload>
|
): 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>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取交易状态
|
* 获取交易状态
|
||||||
@@ -81,7 +92,7 @@ interface BuilderRelayerApi {
|
|||||||
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
|
val data: String, // 调用数据(十六进制字符串,带 0x 前缀)
|
||||||
|
|
||||||
@SerializedName("nonce")
|
@SerializedName("nonce")
|
||||||
val nonce: String, // Safe nonce(字符串)
|
val nonce: String? = null, // Safe nonce(SAFE 必填,SAFE-CREATE 不传)
|
||||||
|
|
||||||
@SerializedName("signature")
|
@SerializedName("signature")
|
||||||
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
|
val signature: String, // Safe 签名(packed signature,十六进制字符串,带 0x 前缀)
|
||||||
@@ -96,6 +107,7 @@ interface BuilderRelayerApi {
|
|||||||
/**
|
/**
|
||||||
* 签名参数
|
* 签名参数
|
||||||
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
|
* 参考: builder-relayer-client/src/types.ts 的 SignatureParams
|
||||||
|
* Safe 使用 operation/safeTxnGas/baseGas 等,PROXY 使用 relayHub/relay/relayerFee 等
|
||||||
*/
|
*/
|
||||||
data class SignatureParams(
|
data class SignatureParams(
|
||||||
@SerializedName("gasPrice")
|
@SerializedName("gasPrice")
|
||||||
@@ -114,7 +126,29 @@ interface BuilderRelayerApi {
|
|||||||
val gasToken: String? = null,
|
val gasToken: String? = null,
|
||||||
|
|
||||||
@SerializedName("refundReceiver")
|
@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,
|
||||||
|
|
||||||
|
/** SAFE-CREATE 签名参数 */
|
||||||
|
@SerializedName("paymentToken")
|
||||||
|
val paymentToken: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("payment")
|
||||||
|
val payment: String? = null,
|
||||||
|
|
||||||
|
@SerializedName("paymentReceiver")
|
||||||
|
val paymentReceiver: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,6 +176,17 @@ interface BuilderRelayerApi {
|
|||||||
@SerializedName("nonce")
|
@SerializedName("nonce")
|
||||||
val nonce: String
|
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 交易详情
|
* Relayer 交易详情
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.api
|
package com.wrbug.polymarketbot.api
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.*
|
import retrofit2.http.*
|
||||||
|
|
||||||
@@ -164,10 +165,10 @@ interface PolymarketClobApi {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取服务器时间
|
* 获取服务器时间
|
||||||
* 端点: /time
|
* 端点: /time 返回纯数字(Unix 时间戳),非 JSON
|
||||||
*/
|
*/
|
||||||
@GET("/time")
|
@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 timestamp: String, // ISO 8601 格式字符串或时间戳
|
||||||
val user: String?,
|
val user: String?,
|
||||||
val outcomeIndex: Int? = null, // 结果索引(0=YES, 1=NO)
|
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
|
val passphrase: String
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* 服务器时间响应
|
|
||||||
*/
|
|
||||||
data class ServerTimeResponse(
|
|
||||||
val timestamp: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 费率响应
|
* 费率响应
|
||||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
* 文档: 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.Response
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,23 +27,56 @@ interface PolymarketGammaApi {
|
|||||||
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
@Query("clob_token_ids") clobTokenIds: List<String>? = null,
|
||||||
@Query("include_tag") includeTag: Boolean? = null
|
@Query("include_tag") includeTag: Boolean? = null
|
||||||
): Response<List<MarketResponse>>
|
): 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 解析)
|
* 事件响应(从 MarketResponse.events 解析)
|
||||||
|
* Gamma API Event 含 negRisk,用于判断是否使用 Neg Risk Exchange 签约
|
||||||
*/
|
*/
|
||||||
data class EventResponse(
|
data class EventResponse(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val ticker: String? = null,
|
val ticker: String? = null,
|
||||||
val slug: String,
|
val slug: String? = null,
|
||||||
val title: String,
|
val title: String? = null,
|
||||||
val category: String? = null,
|
val category: String? = null,
|
||||||
val active: Boolean? = null,
|
val active: Boolean? = null,
|
||||||
val closed: Boolean? = null,
|
val closed: Boolean? = null,
|
||||||
val archived: Boolean? = null,
|
val archived: Boolean? = null,
|
||||||
val startDate: String? = null,
|
val startDate: String? = null,
|
||||||
val endDate: 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 events: List<EventResponse>? = null, // 事件列表(从 events[0] 获取 slug)
|
||||||
// 以下字段可能存在于响应中,但不在标准文档中
|
// 以下字段可能存在于响应中,但不在标准文档中
|
||||||
val clobTokenIds: String? = null, // CLOB token IDs(可能是 JSON 字符串或数组)
|
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 标记
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.wrbug.polymarketbot.config
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||||
|
import jakarta.annotation.PostConstruct
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控服务配置
|
||||||
|
* 处理 WebSocketSubscriptionService 和 CryptoTailMonitorService 之间的循环依赖
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
class MonitorServiceConfig(
|
||||||
|
private val webSocketSubscriptionService: WebSocketSubscriptionService,
|
||||||
|
private val cryptoTailMonitorService: CryptoTailMonitorService
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
fun init() {
|
||||||
|
// 在所有 Bean 初始化后设置引用
|
||||||
|
webSocketSubscriptionService.setCryptoTailMonitorService(cryptoTailMonitorService)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ package com.wrbug.polymarketbot.config
|
|||||||
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -23,9 +23,6 @@ class RetrofitConfig(
|
|||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Value("\${polymarket.clob.base-url}")
|
|
||||||
private lateinit var clobBaseUrl: String
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 CLOB API 客户端
|
* 创建 CLOB API 客户端
|
||||||
* 用于跟单系统的订单操作和交易查询
|
* 用于跟单系统的订单操作和交易查询
|
||||||
@@ -38,7 +35,7 @@ class RetrofitConfig(
|
|||||||
val okHttpClient = createClient().build()
|
val okHttpClient = createClient().build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.wrbug.polymarketbot.constants
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket API 常量
|
||||||
|
* 集中管理所有 Polymarket API 的 URL 配置
|
||||||
|
*/
|
||||||
|
object PolymarketConstants {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket CLOB API 基础 URL
|
||||||
|
*/
|
||||||
|
const val CLOB_BASE_URL = "https://clob.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket RTDS WebSocket URL
|
||||||
|
* 用于订单推送服务
|
||||||
|
*/
|
||||||
|
const val RTDS_WS_URL = "wss://ws-subscriptions-clob.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket User Channel WebSocket URL
|
||||||
|
* 用于跟单服务(订阅 Leader 交易)
|
||||||
|
*/
|
||||||
|
const val USER_WS_URL = "wss://ws-live-data.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket Activity WebSocket URL
|
||||||
|
* 用于 Activity 全局交易流监听
|
||||||
|
*/
|
||||||
|
const val ACTIVITY_WS_URL = "wss://ws-live-data.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket Data API 基础 URL
|
||||||
|
*/
|
||||||
|
const val DATA_API_BASE_URL = "https://data-api.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket Gamma API 基础 URL
|
||||||
|
*/
|
||||||
|
const val GAMMA_BASE_URL = "https://gamma-api.polymarket.com"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder Relayer API URL
|
||||||
|
* 用于 Gasless 交易
|
||||||
|
*/
|
||||||
|
const val BUILDER_RELAYER_URL = "https://relayer-v2.polymarket.com/"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket Safe 代理工厂合约地址(Polygon 主网)
|
||||||
|
* 用于 Safe 类型账户的代理部署(SAFE-CREATE)
|
||||||
|
*/
|
||||||
|
const val SAFE_PROXY_FACTORY_ADDRESS = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b"
|
||||||
|
|
||||||
|
/** SafeCreate 用 EIP-712 domain name,与 builder-relayer-client 一致 */
|
||||||
|
const val SAFE_FACTORY_EIP712_NAME = "Polymarket Contract Proxy Factory"
|
||||||
|
}
|
||||||
|
|
||||||
+130
-7
@@ -23,6 +23,50 @@ class AccountController(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(AccountController::class.java)
|
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 ->
|
onFailure = { e ->
|
||||||
logger.error("导入账户失败: ${e.message}", e)
|
logger.error("导入账户失败: ${e.message}", e)
|
||||||
when (e) {
|
when (e) {
|
||||||
is IllegalArgumentException -> ResponseEntity.ok(
|
is IllegalArgumentException -> if (e.message == "ACCOUNT_ALREADY_EXISTS") {
|
||||||
ApiResponse.error(
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.ACCOUNT_ALREADY_EXISTS, messageSource = messageSource))
|
||||||
ErrorCode.PARAM_ERROR,
|
} else {
|
||||||
e.message,
|
ResponseEntity.ok(
|
||||||
messageSource
|
ApiResponse.error(
|
||||||
|
ErrorCode.PARAM_ERROR,
|
||||||
|
e.message,
|
||||||
|
messageSource
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ACCOUNT_IMPORT_FAILED, e.message, messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +204,82 @@ class AccountController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||||
|
*/
|
||||||
|
@PostMapping("/check-setup-status")
|
||||||
|
fun checkSetupStatus(@RequestBody request: AccountDetailRequest): ResponseEntity<ApiResponse<AccountSetupStatusDto>> {
|
||||||
|
return try {
|
||||||
|
if (request.accountId == null || request.accountId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = runBlocking { accountService.checkAccountSetupStatus(request.accountId) }
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { status ->
|
||||||
|
ResponseEntity.ok(ApiResponse.success(status))
|
||||||
|
},
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行设置步骤(步骤1 返回跳转 URL,步骤2/3 由后端执行)
|
||||||
|
*/
|
||||||
|
@PostMapping("/execute-setup-step")
|
||||||
|
fun executeSetupStep(@RequestBody request: ExecuteSetupStepRequest): ResponseEntity<ApiResponse<ExecuteSetupStepResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.accountId == null || request.accountId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val step = request.step ?: 0
|
||||||
|
if (step !in 1..3) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, "步骤必须为 1、2 或 3", messageSource))
|
||||||
|
}
|
||||||
|
val result = runBlocking { accountService.executeSetupStep(request.accountId, step) }
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询账户详情
|
* 查询账户详情
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.controller.auth
|
|||||||
|
|
||||||
import com.wrbug.polymarketbot.dto.*
|
import com.wrbug.polymarketbot.dto.*
|
||||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
|
import com.wrbug.polymarketbot.repository.UserRepository
|
||||||
import com.wrbug.polymarketbot.service.auth.AuthService
|
import com.wrbug.polymarketbot.service.auth.AuthService
|
||||||
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
|
import com.wrbug.polymarketbot.service.auth.WebSocketTicketService
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
@@ -18,7 +19,8 @@ import org.springframework.web.bind.annotation.*
|
|||||||
class AuthController(
|
class AuthController(
|
||||||
private val authService: AuthService,
|
private val authService: AuthService,
|
||||||
private val messageSource: MessageSource,
|
private val messageSource: MessageSource,
|
||||||
private val webSocketTicketService: WebSocketTicketService
|
private val webSocketTicketService: WebSocketTicketService,
|
||||||
|
private val userRepository: UserRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(AuthController::class.java)
|
private val logger = LoggerFactory.getLogger(AuthController::class.java)
|
||||||
@@ -184,5 +186,32 @@ class AuthController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证当前用户权限
|
||||||
|
* 用于动态更新服务验证管理员权限
|
||||||
|
* 管理员权限判断:是否为默认账户(isDefault == true)
|
||||||
|
*/
|
||||||
|
@GetMapping("/verify")
|
||||||
|
fun verify(httpRequest: HttpServletRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||||
|
return try {
|
||||||
|
// 从请求属性中获取用户名(由 JWT 拦截器设置)
|
||||||
|
val username = httpRequest.getAttribute("username") as? String
|
||||||
|
if (username == null) {
|
||||||
|
return ResponseEntity.status(401).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为默认账户(管理员)
|
||||||
|
val user = userRepository.findByUsername(username)
|
||||||
|
if (user == null || !user.isDefault) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error(ErrorCode.AUTH_ERROR, "需要管理员权限", messageSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseEntity.ok(ApiResponse.success(Unit))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("验证权限异常: ${e.message}", e)
|
||||||
|
ResponseEntity.status(500).body(ApiResponse.error(ErrorCode.SERVER_ERROR, "验证失败", 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) {
|
if (request.leaderId <= 0) {
|
||||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = leaderService.getLeaderDetail(request.leaderId)
|
val result = leaderService.getLeaderDetail(request.leaderId)
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { leader ->
|
onSuccess = { leader ->
|
||||||
@@ -159,6 +159,36 @@ class LeaderController(
|
|||||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_DETAIL_FETCH_FAILED, e.message, messageSource))
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
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.dto.CryptoTailMonitorInitRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
|
||||||
|
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||||
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyExecutionService
|
||||||
|
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
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/crypto-tail-strategy")
|
||||||
|
class CryptoTailStrategyController(
|
||||||
|
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
||||||
|
private val cryptoTailMonitorService: CryptoTailMonitorService,
|
||||||
|
private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService,
|
||||||
|
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),
|
||||||
|
CryptoTailMarketOptionDto(slug = "eth-updown-5m", title = "Ethereum Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "eth-updown-15m", title = "Ethereum Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "sol-updown-5m", title = "Solana Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "sol-updown-15m", title = "Solana Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "xrp-updown-5m", title = "XRP Up or Down - 5 minute", intervalSeconds = 300, periodStartUnix = 0L, endDate = null),
|
||||||
|
CryptoTailMarketOptionDto(slug = "xrp-updown-15m", title = "XRP Up or Down - 15 minute", intervalSeconds = 900, periodStartUnix = 0L, endDate = null)
|
||||||
|
)
|
||||||
|
ResponseEntity.ok(ApiResponse.success(options))
|
||||||
|
} 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)
|
||||||
|
// 默认使用 BTC 市场(向后兼容)
|
||||||
|
val marketSlugPrefix = (request["marketSlugPrefix"] as? String) ?: "btc-updown"
|
||||||
|
val pair = binanceKlineAutoSpreadService.computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||||
|
?: return ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "fetch_failed", messageSource))
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化加密价差策略监控
|
||||||
|
* 返回策略信息、开盘价、tokenIds等初始化数据
|
||||||
|
*/
|
||||||
|
@PostMapping("/monitor/init")
|
||||||
|
fun initMonitor(@RequestBody request: CryptoTailMonitorInitRequest): ResponseEntity<ApiResponse<CryptoTailMonitorInitResponse>> {
|
||||||
|
return try {
|
||||||
|
if (request.strategyId <= 0) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||||
|
}
|
||||||
|
val result = cryptoTailMonitorService.initMonitor(request)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("初始化加密价差策略监控失败: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("初始化加密价差策略监控异常: ${e.message}", e)
|
||||||
|
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动下单
|
||||||
|
* 用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单
|
||||||
|
*/
|
||||||
|
@PostMapping("/manual-order")
|
||||||
|
fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity<ApiResponse<CryptoTailManualOrderResponse>> {
|
||||||
|
return runBlocking {
|
||||||
|
try {
|
||||||
|
if (request.strategyId <= 0) {
|
||||||
|
return@runBlocking ResponseEntity.ok(
|
||||||
|
ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val result = cryptoTailStrategyExecutionService.manualOrder(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
|
||||||
|
"当前周期已下单" -> ErrorCode.PARAM_ERROR
|
||||||
|
"价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR
|
||||||
|
"数量不能少于 1" -> ErrorCode.PARAM_ERROR
|
||||||
|
"总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR
|
||||||
|
"总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR
|
||||||
|
else -> ErrorCode.SERVER_ERROR
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} 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浏览器钱包)
|
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个)
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账户更新请求
|
* 账户更新请求
|
||||||
*/
|
*/
|
||||||
@@ -37,9 +68,9 @@ data class SystemConfigDto(
|
|||||||
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
val builderApiKeyConfigured: Boolean, // Builder API Key 是否已配置
|
||||||
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
val builderSecretConfigured: Boolean, // Builder Secret 是否已配置
|
||||||
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
val builderPassphraseConfigured: Boolean, // Builder Passphrase 是否已配置
|
||||||
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(部分显示,用于前端展示)
|
val builderApiKeyDisplay: String? = null, // Builder API Key 显示值(完整,用于前端展示)
|
||||||
val builderSecretDisplay: String? = null, // Builder Secret 显示值(部分显示,用于前端展示)
|
val builderSecretDisplay: String? = null, // Builder Secret 显示值(完整,用于前端展示)
|
||||||
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(部分显示,用于前端展示)
|
val builderPassphraseDisplay: String? = null, // Builder Passphrase 显示值(完整,用于前端展示)
|
||||||
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
val autoRedeemEnabled: Boolean = true // 自动赎回(系统级别配置,默认开启)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,6 +124,16 @@ data class AccountListResponse(
|
|||||||
val total: Long
|
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(
|
data class PositionDto(
|
||||||
val marketId: String,
|
val marketId: String,
|
||||||
|
val title: String?, // 市场名称
|
||||||
val side: String, // YES 或 NO
|
val side: String, // YES 或 NO
|
||||||
val quantity: String,
|
val quantity: String,
|
||||||
val avgPrice: String,
|
val avgPrice: String,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账户设置状态检查结果
|
||||||
|
*/
|
||||||
|
data class AccountSetupStatusDto(
|
||||||
|
/**
|
||||||
|
* 步骤1:代理钱包是否已部署
|
||||||
|
*/
|
||||||
|
val proxyDeployed: Boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 步骤2:交易是否已启用(API Key 是否已配置)
|
||||||
|
*/
|
||||||
|
val tradingEnabled: Boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 步骤3:代币是否已批准
|
||||||
|
*/
|
||||||
|
val tokensApproved: Boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代币批准详情(各合约的授权额度)
|
||||||
|
* Key: 合约名称(CTF_CONTRACT, CTF_EXCHANGE, NEG_RISK_EXCHANGE, NEG_RISK_ADAPTER)
|
||||||
|
* Value: 授权额度(USDC,6位小数)
|
||||||
|
*/
|
||||||
|
val approvalDetails: Map<String, String>? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查错误信息(如果有)
|
||||||
|
*/
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行设置步骤请求
|
||||||
|
*/
|
||||||
|
data class ExecuteSetupStepRequest(
|
||||||
|
/** 账户 ID */
|
||||||
|
val accountId: Long? = null,
|
||||||
|
/** 步骤:1=部署代理, 2=启用交易, 3=批准代币 */
|
||||||
|
val step: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行设置步骤响应
|
||||||
|
*/
|
||||||
|
data class ExecuteSetupStepResponse(
|
||||||
|
/** 是否由后端执行成功(步骤1 仅返回跳转链接,为 false) */
|
||||||
|
val success: Boolean = false,
|
||||||
|
/** 需跳转时由后端提供的 URL(步骤1 使用) */
|
||||||
|
val redirectUrl: String? = null,
|
||||||
|
/** 链上交易哈希(步骤3 批准代币成功时返回) */
|
||||||
|
val transactionHash: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 账户导入响应(扩展,包含设置状态)
|
||||||
|
*/
|
||||||
|
data class AccountImportResponse(
|
||||||
|
val account: AccountDto,
|
||||||
|
val setupStatus: AccountSetupStatusDto? = null // 设置状态检查结果(可选)
|
||||||
|
)
|
||||||
@@ -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,13 +36,13 @@ data class CopyTradingCreateRequest(
|
|||||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
// 最大仓位配置
|
// 最大仓位配置
|
||||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
|
||||||
// 关键字过滤配置
|
// 关键字过滤配置
|
||||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
val configName: String? = null, // 配置名(可选)
|
val configName: String? = null, // 配置名(可选)
|
||||||
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||||
|
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||||
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,13 +74,13 @@ data class CopyTradingUpdateRequest(
|
|||||||
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
// 最大仓位配置
|
// 最大仓位配置
|
||||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
|
||||||
// 关键字过滤配置
|
// 关键字过滤配置
|
||||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
val configName: String? = null, // 配置名(可选,但提供时必须非空)
|
||||||
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
val pushFailedOrders: Boolean? = null, // 推送失败订单(可选)
|
||||||
|
val pushFilteredOrders: Boolean? = null, // 推送已过滤订单(可选)
|
||||||
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -149,13 +149,13 @@ data class CopyTradingDto(
|
|||||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||||
// 最大仓位配置
|
// 最大仓位配置
|
||||||
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
val maxPositionValue: String? = null, // 最大仓位金额(USDC),NULL表示不启用
|
||||||
val maxPositionCount: Int? = null, // 最大仓位数量,NULL表示不启用
|
|
||||||
// 关键字过滤配置
|
// 关键字过滤配置
|
||||||
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
val keywordFilterMode: String? = null, // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
val keywords: List<String>? = null, // 关键字列表,当keywordFilterMode为DISABLED时为null
|
||||||
// 新增配置字段
|
// 新增配置字段
|
||||||
val configName: String? = null, // 配置名(可选)
|
val configName: String? = null, // 配置名(可选)
|
||||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||||
|
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||||
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val updatedAt: Long
|
val updatedAt: Long
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ data class OrderTrackingRequest(
|
|||||||
val page: Int? = 1,
|
val page: Int? = 1,
|
||||||
val limit: Int? = 20,
|
val limit: Int? = 20,
|
||||||
val marketId: String? = null,
|
val marketId: String? = null,
|
||||||
val side: String? = null,
|
val marketTitle: String? = null, // 市场标题关键字筛选
|
||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val sellOrderId: String? = null,
|
val sellOrderId: String? = null,
|
||||||
val buyOrderId: String? = null
|
val buyOrderId: String? = null
|
||||||
@@ -123,7 +123,9 @@ data class MarketGroupedOrdersRequest(
|
|||||||
val copyTradingId: Long,
|
val copyTradingId: Long,
|
||||||
val type: String, // buy, sell, matched
|
val type: String, // buy, sell, matched
|
||||||
val page: Int? = 1,
|
val page: Int? = 1,
|
||||||
val limit: Int? = 20
|
val limit: Int? = 20,
|
||||||
|
val marketId: String? = null,
|
||||||
|
val marketTitle: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ data class TemplateCreateRequest(
|
|||||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +51,8 @@ data class TemplateUpdateRequest(
|
|||||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,7 +86,8 @@ data class TemplateCopyRequest(
|
|||||||
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
val minOrderDepth: String? = null, // 最小订单深度(USDC金额),NULL表示不启用
|
||||||
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
val maxSpread: String? = null, // 最大价差(绝对价格),NULL表示不启用
|
||||||
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String? = null, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String? = null // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pushFilteredOrders: Boolean? = null // 推送已过滤订单(默认关闭)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,6 +122,7 @@ data class TemplateDto(
|
|||||||
val maxSpread: String?,
|
val maxSpread: String?,
|
||||||
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
val minPrice: String?, // 最低价格(可选),NULL表示不限制最低价
|
||||||
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: String?, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
val pushFilteredOrders: Boolean, // 推送已过滤订单(默认关闭)
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val updatedAt: Long
|
val updatedAt: Long
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略手动下单请求
|
||||||
|
*/
|
||||||
|
data class CryptoTailManualOrderRequest(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
/** 下单方向: UP or DOWN */
|
||||||
|
val direction: String = "UP",
|
||||||
|
/** 下单价格 */
|
||||||
|
val price: String = "0",
|
||||||
|
/** 下单数量 */
|
||||||
|
val size: String = "1",
|
||||||
|
/** 市场标题(用于记录) */
|
||||||
|
val marketTitle: String = "",
|
||||||
|
/** Token IDs */
|
||||||
|
val tokenIds: List<String> = emptyList()
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略手动下单响应
|
||||||
|
*/
|
||||||
|
data class CryptoTailManualOrderResponse(
|
||||||
|
/** 是否成功 */
|
||||||
|
val success: Boolean = false,
|
||||||
|
/** 订单ID */
|
||||||
|
val orderId: String? = null,
|
||||||
|
/** 提示消息 */
|
||||||
|
val message: String = "",
|
||||||
|
/** 下单详情 */
|
||||||
|
val orderDetails: ManualOrderDetails? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动下单详情
|
||||||
|
*/
|
||||||
|
data class ManualOrderDetails(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 方向 */
|
||||||
|
val direction: String = "",
|
||||||
|
/** 下单价格 */
|
||||||
|
val price: String = "",
|
||||||
|
/** 下单数量 */
|
||||||
|
val size: String = "",
|
||||||
|
/** 总金额 */
|
||||||
|
val totalAmount: String = ""
|
||||||
|
)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.wrbug.polymarketbot.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控初始化请求
|
||||||
|
*/
|
||||||
|
data class CryptoTailMonitorInitRequest(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 指定周期开始时间 (Unix 秒),不传则用服务器当前周期 */
|
||||||
|
val periodStartUnix: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控初始化响应
|
||||||
|
*/
|
||||||
|
data class CryptoTailMonitorInitResponse(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 策略名称 */
|
||||||
|
val name: String = "",
|
||||||
|
/** 账户ID */
|
||||||
|
val accountId: Long = 0L,
|
||||||
|
/** 账户名称 */
|
||||||
|
val accountName: String = "",
|
||||||
|
/** 市场 slug 前缀 */
|
||||||
|
val marketSlugPrefix: String = "",
|
||||||
|
/** 市场标题 */
|
||||||
|
val marketTitle: String = "",
|
||||||
|
/** 周期秒数 (300=5m, 900=15m) */
|
||||||
|
val intervalSeconds: Int = 300,
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
/** 时间窗口开始秒数 */
|
||||||
|
val windowStartSeconds: Int = 0,
|
||||||
|
/** 时间窗口结束秒数 */
|
||||||
|
val windowEndSeconds: Int = 0,
|
||||||
|
/** 最低价格 */
|
||||||
|
val minPrice: String = "0",
|
||||||
|
/** 最高价格 */
|
||||||
|
val maxPrice: String = "1",
|
||||||
|
/** 最小价差模式: NONE, FIXED, AUTO */
|
||||||
|
val minSpreadMode: String = "NONE",
|
||||||
|
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
|
||||||
|
val spreadDirection: String = "MIN",
|
||||||
|
/** 最小价差数值 (FIXED 时有值) */
|
||||||
|
val minSpreadValue: String? = null,
|
||||||
|
/** 自动计算的最小价差 (Up方向) */
|
||||||
|
val autoMinSpreadUp: String? = null,
|
||||||
|
/** 自动计算的最小价差 (Down方向) */
|
||||||
|
val autoMinSpreadDown: String? = null,
|
||||||
|
/** BTC 开盘价 USDC(来自币安 K 线 open) */
|
||||||
|
val openPriceBtc: String? = null,
|
||||||
|
/** Up tokenId */
|
||||||
|
val tokenIdUp: String? = null,
|
||||||
|
/** Down tokenId */
|
||||||
|
val tokenIdDown: String? = null,
|
||||||
|
/** 当前时间 (毫秒时间戳) */
|
||||||
|
val currentTimestamp: Long = System.currentTimeMillis(),
|
||||||
|
/** 是否启用 */
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
/** 投入金额模式: FIXED or RATIO */
|
||||||
|
val amountMode: String? = null,
|
||||||
|
/** 投入金额数值 */
|
||||||
|
val amountValue: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控实时推送数据
|
||||||
|
*/
|
||||||
|
data class CryptoTailMonitorPushData(
|
||||||
|
/** 策略ID */
|
||||||
|
val strategyId: Long = 0L,
|
||||||
|
/** 推送时间 (毫秒时间戳) */
|
||||||
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
/** 当前周期开始时间 (Unix 秒) */
|
||||||
|
val periodStartUnix: Long = 0L,
|
||||||
|
/** 当前周期市场标题(周期切换时更新) */
|
||||||
|
val marketTitle: String = "",
|
||||||
|
/** 当前价格 (Up方向,来自订单簿) */
|
||||||
|
val currentPriceUp: String? = null,
|
||||||
|
/** 当前价格 (Down方向,来自订单簿) */
|
||||||
|
val currentPriceDown: String? = null,
|
||||||
|
/** 当前价差 (Up方向: 1 - currentPriceUp) */
|
||||||
|
val spreadUp: String? = null,
|
||||||
|
/** 当前价差 (Down方向: currentPriceUp) */
|
||||||
|
val spreadDown: String? = null,
|
||||||
|
/** 最小价差线 (Up方向) */
|
||||||
|
val minSpreadLineUp: String? = null,
|
||||||
|
/** 最小价差线 (Down方向,USDC 价差) */
|
||||||
|
val minSpreadLineDown: String? = null,
|
||||||
|
/** BTC 开盘价 USDC(币安 K 线 open) */
|
||||||
|
val openPriceBtc: String? = null,
|
||||||
|
/** BTC 最新价 USDC(币安 K 线 close,当前周期实时) */
|
||||||
|
val currentPriceBtc: String? = null,
|
||||||
|
/** BTC 价差 USDC(currentPriceBtc - openPriceBtc) */
|
||||||
|
val spreadBtc: String? = null,
|
||||||
|
/** 周期剩余秒数 */
|
||||||
|
val remainingSeconds: Int = 0,
|
||||||
|
/** 是否在时间窗口内 */
|
||||||
|
val inTimeWindow: Boolean = false,
|
||||||
|
/** 是否在价格区间内 (Up方向) */
|
||||||
|
val inPriceRangeUp: Boolean = false,
|
||||||
|
/** 是否在价格区间内 (Down方向) */
|
||||||
|
val inPriceRangeDown: Boolean = false,
|
||||||
|
/** 是否已触发 */
|
||||||
|
val triggered: Boolean = false,
|
||||||
|
/** 触发方向: UP, DOWN, null */
|
||||||
|
val triggerDirection: String? = null,
|
||||||
|
/** 周期是否已结束 */
|
||||||
|
val periodEnded: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
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",
|
||||||
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
|
val spreadMode: String = "NONE",
|
||||||
|
/** 价差数值 */
|
||||||
|
val spreadValue: String? = null,
|
||||||
|
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||||
|
val spreadDirection: String = "MIN",
|
||||||
|
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,
|
||||||
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
|
val spreadMode: String? = null,
|
||||||
|
/** 价差数值 */
|
||||||
|
val spreadValue: String? = null,
|
||||||
|
/** 价差方向: MIN=最小价差, MAX=最大价差 */
|
||||||
|
val spreadDirection: 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",
|
||||||
|
/** 价差模式: NONE, FIXED, AUTO */
|
||||||
|
val spreadMode: String = "NONE",
|
||||||
|
/** 价差数值 */
|
||||||
|
val spreadValue: String? = null,
|
||||||
|
/** 价差方向: MIN=最小价差(价差>=配置值触发), MAX=最大价差(价差<=配置值触发) */
|
||||||
|
val spreadDirection: String = "MIN",
|
||||||
|
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
|
val category: String? = null // sports 或 crypto
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leader 余额请求
|
||||||
|
*/
|
||||||
|
data class LeaderBalanceRequest(
|
||||||
|
val leaderId: Long // LeaderID(必需)
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leader 信息响应
|
* Leader 信息响应
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +54,7 @@ data class LeaderDto(
|
|||||||
val remark: String? = null, // Leader 备注(可选)
|
val remark: String? = null, // Leader 备注(可选)
|
||||||
val website: String? = null, // Leader 网站(可选)
|
val website: String? = null, // Leader 网站(可选)
|
||||||
val copyTradingCount: Long = 0, // 跟单关系数量
|
val copyTradingCount: Long = 0, // 跟单关系数量
|
||||||
|
val backtestCount: Long = 0, // 回测数量
|
||||||
val totalOrders: Long? = null, // 总订单数(可选)
|
val totalOrders: Long? = null, // 总订单数(可选)
|
||||||
val totalPnl: String? = null, // 总盈亏(可选)
|
val totalPnl: String? = null, // 总盈亏(可选)
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
@@ -61,3 +69,16 @@ data class LeaderListResponse(
|
|||||||
val total: Long
|
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)
|
@Column(name = "private_key", nullable = false, length = 500)
|
||||||
val privateKey: String, // 私钥(AES 加密存储)
|
val privateKey: String, // 私钥(AES 加密存储)
|
||||||
|
|
||||||
@Column(name = "wallet_address", unique = true, nullable = false, length = 42)
|
@Column(name = "wallet_address", nullable = false, length = 42)
|
||||||
val walletAddress: String, // 钱包地址(从私钥推导)
|
val walletAddress: String, // 钱包地址(从私钥推导),同一 EOA 可有多个账户(不同代理类型)
|
||||||
|
|
||||||
@Column(name = "proxy_address", nullable = false, length = 42)
|
@Column(name = "proxy_address", unique = true, nullable = false, length = 42)
|
||||||
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须)
|
val proxyAddress: String, // Polymarket 代理钱包地址(从合约获取,必须),唯一
|
||||||
|
|
||||||
@Column(name = "api_key", length = 500)
|
@Column(name = "api_key", length = 500)
|
||||||
val apiKey: String? = null, // Polymarket API Key(可选,明文存储)
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
@@ -58,7 +58,10 @@ data class CopyOrderTracking(
|
|||||||
|
|
||||||
@Column(name = "notification_sent", nullable = false)
|
@Column(name = "notification_sent", nullable = false)
|
||||||
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
var notificationSent: Boolean = false, // 是否已发送通知(从订单详情获取实际数据后发送)
|
||||||
|
|
||||||
|
@Column(name = "source", nullable = false, length = 20)
|
||||||
|
val source: String, // 订单来源:activity-ws(Polymarket WebSocket)、onchain-ws(OnChain WebSocket)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -83,9 +83,6 @@ data class CopyTrading(
|
|||||||
@Column(name = "max_position_value", precision = 20, scale = 8)
|
@Column(name = "max_position_value", precision = 20, scale = 8)
|
||||||
val maxPositionValue: BigDecimal? = null, // 最大仓位金额(USDC),NULL表示不启用
|
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)
|
@Column(name = "keyword_filter_mode", nullable = false, length = 20)
|
||||||
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
val keywordFilterMode: String = "DISABLED", // 关键字过滤模式:DISABLED(不启用)、WHITELIST(白名单)、BLACKLIST(黑名单)
|
||||||
@@ -100,6 +97,9 @@ data class CopyTrading(
|
|||||||
@Column(name = "push_failed_orders", nullable = false)
|
@Column(name = "push_failed_orders", nullable = false)
|
||||||
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
val pushFailedOrders: Boolean = false, // 推送失败订单(默认关闭)
|
||||||
|
|
||||||
|
@Column(name = "push_filtered_orders", nullable = false)
|
||||||
|
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||||
|
|
||||||
@Column(name = "max_market_end_date")
|
@Column(name = "max_market_end_date")
|
||||||
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
val maxMarketEndDate: Long? = null, // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ data class CopyTradingTemplate(
|
|||||||
@Column(name = "max_price", precision = 20, scale = 8)
|
@Column(name = "max_price", precision = 20, scale = 8)
|
||||||
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
val maxPrice: BigDecimal? = null, // 最高价格(可选),NULL表示不限制最高价
|
||||||
|
|
||||||
|
@Column(name = "push_filtered_orders", nullable = false)
|
||||||
|
val pushFilteredOrders: Boolean = false, // 推送已过滤订单(默认关闭)
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.wrbug.polymarketbot.entity
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||||
|
import com.wrbug.polymarketbot.enums.SpreadDirectionConverter
|
||||||
|
import com.wrbug.polymarketbot.enums.SpreadMode
|
||||||
|
import com.wrbug.polymarketbot.enums.SpreadModeConverter
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略实体
|
||||||
|
* 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,
|
||||||
|
|
||||||
|
/** 价差模式: NONE=不校验, FIXED=固定值, AUTO=历史计算 */
|
||||||
|
@Convert(converter = SpreadModeConverter::class)
|
||||||
|
@Column(name = "spread_mode", nullable = false, columnDefinition = "TINYINT")
|
||||||
|
val spreadMode: SpreadMode = SpreadMode.NONE,
|
||||||
|
|
||||||
|
/** 价差数值(FIXED 时必填;AUTO 时可存计算值) */
|
||||||
|
@Column(name = "spread_value", precision = 20, scale = 8)
|
||||||
|
val spreadValue: BigDecimal? = null,
|
||||||
|
|
||||||
|
/** 价差方向: MIN=最小价差(价差>=配置值触发),MAX=最大价差(价差<=配置值触发) */
|
||||||
|
@Convert(converter = SpreadDirectionConverter::class)
|
||||||
|
@Column(name = "spread_direction", nullable = false, columnDefinition = "TINYINT")
|
||||||
|
val spreadDirection: SpreadDirection = SpreadDirection.MIN,
|
||||||
|
|
||||||
|
@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,67 @@
|
|||||||
|
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 = "trigger_type", nullable = false, length = 20)
|
||||||
|
val triggerType: String = "AUTO",
|
||||||
|
|
||||||
|
@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_BALANCE_FETCH_FAILED(4707, "查询账户余额失败", "error.account_balance_fetch_failed"),
|
||||||
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
ACCOUNT_POSITIONS_FETCH_FAILED(4708, "查询仓位列表失败", "error.account_positions_fetch_failed"),
|
||||||
|
|
||||||
|
// 加密价差策略 (4710-4729)
|
||||||
|
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)
|
// 统计相关 (4801-4899)
|
||||||
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
|
STATISTICS_FETCH_FAILED(4801, "获取统计信息失败", "error.statistics_fetch_failed"),
|
||||||
ORDER_LIST_FETCH_FAILED(4802, "查询订单列表失败", "error.order_list_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_PROCESS_FAILED(5901, "处理订单跟踪失败", "error.server.order_tracking_process_failed"),
|
||||||
SERVER_ORDER_TRACKING_BUY_FAILED(5902, "处理买入订单失败", "error.server.order_tracking_buy_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_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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.wrbug.polymarketbot.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 价差方向枚举
|
||||||
|
*/
|
||||||
|
enum class SpreadDirection(val value: Int, val description: String) {
|
||||||
|
/**
|
||||||
|
* 最小价差:价差 >= 配置值时触发,买入价固定 0.99
|
||||||
|
*/
|
||||||
|
MIN(0, "最小价差"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最大价差:价差 <= 配置值时触发,买入价 = 触发价 + 0.02
|
||||||
|
*/
|
||||||
|
MAX(1, "最大价差");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* 从数值解析价差方向
|
||||||
|
*/
|
||||||
|
fun fromValue(value: Int?): SpreadDirection {
|
||||||
|
if (value == null) {
|
||||||
|
return MIN // 默认返回 MIN
|
||||||
|
}
|
||||||
|
return values().find { it.value == value }
|
||||||
|
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地从数值解析价差方向,解析失败返回默认值
|
||||||
|
*/
|
||||||
|
fun fromValueOrDefault(value: Int?, default: SpreadDirection = MIN): SpreadDirection {
|
||||||
|
if (value == null) {
|
||||||
|
return default
|
||||||
|
}
|
||||||
|
return values().find { it.value == value } ?: default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从字符串解析价差方向(兼容旧逻辑)
|
||||||
|
*/
|
||||||
|
fun fromString(value: String?): SpreadDirection {
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
return MIN
|
||||||
|
}
|
||||||
|
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||||
|
?: throw IllegalArgumentException("未知的价差方向: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.wrbug.polymarketbot.enums
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter
|
||||||
|
import jakarta.persistence.Converter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpreadDirection 枚举的 JPA 转换器
|
||||||
|
* 数据库存储为 TINYINT (0 = MIN, 1 = MAX)
|
||||||
|
*/
|
||||||
|
@Converter(autoApply = false)
|
||||||
|
class SpreadDirectionConverter : AttributeConverter<SpreadDirection, Int> {
|
||||||
|
|
||||||
|
override fun convertToDatabaseColumn(attribute: SpreadDirection?): Int {
|
||||||
|
return attribute?.value ?: SpreadDirection.MIN.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToEntityAttribute(dbData: Int?): SpreadDirection {
|
||||||
|
return SpreadDirection.fromValueOrDefault(dbData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.wrbug.polymarketbot.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 价差模式枚举
|
||||||
|
*/
|
||||||
|
enum class SpreadMode(val value: Int, val description: String) {
|
||||||
|
/**
|
||||||
|
* 不校验价差
|
||||||
|
*/
|
||||||
|
NONE(0, "无"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定值:用户输入一个数值
|
||||||
|
*/
|
||||||
|
FIXED(1, "固定"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动:系统按历史 K 线计算建议价差
|
||||||
|
*/
|
||||||
|
AUTO(2, "自动");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* 从数值解析价差模式
|
||||||
|
*/
|
||||||
|
fun fromValue(value: Int?): SpreadMode {
|
||||||
|
if (value == null) {
|
||||||
|
return NONE // 默认返回 NONE
|
||||||
|
}
|
||||||
|
return values().find { it.value == value }
|
||||||
|
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全地从数值解析价差模式,解析失败返回默认值
|
||||||
|
*/
|
||||||
|
fun fromValueOrDefault(value: Int?, default: SpreadMode = NONE): SpreadMode {
|
||||||
|
if (value == null) {
|
||||||
|
return default
|
||||||
|
}
|
||||||
|
return values().find { it.value == value } ?: default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从字符串解析价差模式(兼容旧逻辑)
|
||||||
|
*/
|
||||||
|
fun fromString(value: String?): SpreadMode {
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
return NONE
|
||||||
|
}
|
||||||
|
return values().find { it.name.equals(value, ignoreCase = true) }
|
||||||
|
?: throw IllegalArgumentException("未知的价差模式: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.wrbug.polymarketbot.enums
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter
|
||||||
|
import jakarta.persistence.Converter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SpreadMode 枚举的 JPA 转换器
|
||||||
|
* 数据库存储为 TINYINT (0 = NONE, 1 = FIXED, 2 = AUTO)
|
||||||
|
*/
|
||||||
|
@Converter(autoApply = false)
|
||||||
|
class SpreadModeConverter : AttributeConverter<SpreadMode, Int> {
|
||||||
|
|
||||||
|
override fun convertToDatabaseColumn(attribute: SpreadMode?): Int {
|
||||||
|
return attribute?.value ?: SpreadMode.NONE.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToEntityAttribute(dbData: Int?): SpreadMode {
|
||||||
|
return SpreadMode.fromValueOrDefault(dbData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 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)
|
||||||
|
}
|
||||||
|
|
||||||
+19
-5
@@ -75,14 +75,28 @@ interface CopyOrderTrackingRepository : JpaRepository<CopyOrderTracking, Long> {
|
|||||||
fun countActivePositions(copyTradingId: Long): Int
|
fun countActivePositions(copyTradingId: Long): Int
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查指定市场是否存在活跃仓位
|
* 计算指定跟单配置、市场和方向下的当前持仓总价值 (成本价计算)
|
||||||
|
* 按市场+方向(outcomeIndex)分别统计
|
||||||
*/
|
*/
|
||||||
fun existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(copyTradingId: Long, marketId: String, remainingQuantity: BigDecimal): Boolean
|
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0")
|
||||||
|
fun sumCurrentPositionValueByMarketAndOutcomeIndex(copyTradingId: Long, marketId: String, outcomeIndex: Int): BigDecimal?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算指定跟单配置和市场下的当前持仓总价值 (成本价计算)
|
* 查询指定跟单配置下,创建时间超过指定时间点的未匹配订单(FIFO顺序)
|
||||||
|
* 用于避免刚创建的订单被误判为已卖出
|
||||||
|
*
|
||||||
|
* @param copyTradingId 跟单配置ID
|
||||||
|
* @param marketId 市场ID
|
||||||
|
* @param outcomeIndex 结果索引
|
||||||
|
* @param thresholdTime 时间阈值(毫秒时间戳),只查询创建时间小于该值的订单
|
||||||
|
* @return 未匹配订单列表(按创建时间升序排列)
|
||||||
*/
|
*/
|
||||||
@Query("SELECT SUM(t.remainingQuantity * t.price) FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.remainingQuantity > 0")
|
@Query("SELECT t FROM CopyOrderTracking t WHERE t.copyTradingId = :copyTradingId AND t.marketId = :marketId AND t.outcomeIndex = :outcomeIndex AND t.remainingQuantity > 0 AND t.createdAt < :thresholdTime ORDER BY t.createdAt ASC")
|
||||||
fun sumCurrentPositionValueByMarket(copyTradingId: Long, marketId: String): BigDecimal?
|
fun findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
|
||||||
|
copyTradingId: Long,
|
||||||
|
marketId: String,
|
||||||
|
outcomeIndex: Int,
|
||||||
|
thresholdTime: Long
|
||||||
|
): List<CopyOrderTracking>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
}
|
||||||
+532
-106
@@ -3,10 +3,12 @@ package com.wrbug.polymarketbot.service.accounts
|
|||||||
import com.wrbug.polymarketbot.api.TradeResponse
|
import com.wrbug.polymarketbot.api.TradeResponse
|
||||||
import com.wrbug.polymarketbot.dto.*
|
import com.wrbug.polymarketbot.dto.*
|
||||||
import com.wrbug.polymarketbot.entity.Account
|
import com.wrbug.polymarketbot.entity.Account
|
||||||
|
import com.wrbug.polymarketbot.enums.WalletType
|
||||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import com.wrbug.polymarketbot.util.eq
|
import com.wrbug.polymarketbot.util.eq
|
||||||
|
import com.wrbug.polymarketbot.util.gt
|
||||||
import com.wrbug.polymarketbot.util.JsonUtils
|
import com.wrbug.polymarketbot.util.JsonUtils
|
||||||
import com.wrbug.polymarketbot.util.getEventSlug
|
import com.wrbug.polymarketbot.util.getEventSlug
|
||||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||||
@@ -66,11 +68,6 @@ class AccountService(
|
|||||||
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
return Result.failure(IllegalArgumentException("无效的钱包地址格式"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查地址是否已存在
|
|
||||||
if (accountRepository.existsByWalletAddress(request.walletAddress)) {
|
|
||||||
return Result.failure(IllegalArgumentException("该钱包地址已存在"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 验证私钥和地址的对应关系
|
// 3. 验证私钥和地址的对应关系
|
||||||
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
// 注意:前端已经验证了私钥和地址的对应关系,这里只做格式验证
|
||||||
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
// 如果需要更严格的验证,可以使用以太坊库(如 web3j)进行验证
|
||||||
@@ -104,7 +101,8 @@ class AccountService(
|
|||||||
// 5. 获取代理地址(必须成功,否则导入失败)
|
// 5. 获取代理地址(必须成功,否则导入失败)
|
||||||
// 根据用户选择的钱包类型计算代理地址
|
// 根据用户选择的钱包类型计算代理地址
|
||||||
val proxyAddress = runBlocking {
|
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) {
|
if (proxyResult.isSuccess) {
|
||||||
val address = proxyResult.getOrNull()
|
val address = proxyResult.getOrNull()
|
||||||
if (address != null) {
|
if (address != null) {
|
||||||
@@ -120,25 +118,31 @@ class AccountService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. 按代理地址去重:该代理地址已存在则不允许重复导入
|
||||||
|
if (accountRepository.existsByProxyAddress(proxyAddress)) {
|
||||||
|
return Result.failure(IllegalArgumentException("ACCOUNT_ALREADY_EXISTS"))
|
||||||
|
}
|
||||||
|
|
||||||
// 7. 加密敏感信息
|
// 7. 加密敏感信息
|
||||||
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
val encryptedPrivateKey = cryptoUtils.encrypt(request.privateKey)
|
||||||
val encryptedApiSecret = apiKeyCreds.secret?.let { cryptoUtils.encrypt(it) }
|
val encryptedApiSecret = apiKeyCreds.secret.let { cryptoUtils.encrypt(it) }
|
||||||
val encryptedApiPassphrase = apiKeyCreds.passphrase?.let { cryptoUtils.encrypt(it) }
|
val encryptedApiPassphrase = apiKeyCreds.passphrase.let { cryptoUtils.encrypt(it) }
|
||||||
|
|
||||||
// 8. 生成账户名称(如果未提供,使用钱包地址后四位)
|
// 8. 生成账户名称(如果未提供,使用 SAFE/MAGIC-代理地址后4位)
|
||||||
val accountName = if (request.accountName.isNullOrBlank()) {
|
val accountName = if (request.accountName.isNullOrBlank()) {
|
||||||
val walletAddress = request.walletAddress.trim()
|
val walletTypeEnum = WalletType.fromStringOrDefault(request.walletType, WalletType.MAGIC)
|
||||||
// 取地址后四位(去掉 0x 前缀后取后四位)
|
val typeLabel = walletTypeEnum.name.uppercase()
|
||||||
val addressWithoutPrefix = if (walletAddress.startsWith("0x") || walletAddress.startsWith("0X")) {
|
val proxyWithoutPrefix = if (proxyAddress.startsWith("0x") || proxyAddress.startsWith("0X")) {
|
||||||
walletAddress.substring(2)
|
proxyAddress.substring(2)
|
||||||
} else {
|
} else {
|
||||||
walletAddress
|
proxyAddress
|
||||||
}
|
}
|
||||||
if (addressWithoutPrefix.length >= 4) {
|
val suffix = if (proxyWithoutPrefix.length >= 4) {
|
||||||
addressWithoutPrefix.substring(addressWithoutPrefix.length - 4).uppercase()
|
proxyWithoutPrefix.substring(proxyWithoutPrefix.length - 4).uppercase()
|
||||||
} else {
|
} else {
|
||||||
addressWithoutPrefix.uppercase()
|
proxyWithoutPrefix.uppercase()
|
||||||
}
|
}
|
||||||
|
"$typeLabel-$suffix"
|
||||||
} else {
|
} else {
|
||||||
request.accountName.trim()
|
request.accountName.trim()
|
||||||
}
|
}
|
||||||
@@ -171,6 +175,401 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polymarket 代币批准检查:USDC.e 需授权的 spender 合约地址(Polygon 主网)
|
||||||
|
* 来源:Polymarket/magic-safe-builder-example README §6 Token Approvals
|
||||||
|
* 及 neg-risk-ctf-adapter 仓库 addresses.json (chainId 137)
|
||||||
|
*/
|
||||||
|
private val setupApprovalSpenders = mapOf(
|
||||||
|
"CTF_CONTRACT" to "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", // Conditional Tokens
|
||||||
|
"CTF_EXCHANGE" to "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E", // 普通市场交易所
|
||||||
|
"NEG_RISK_EXCHANGE" to "0xC5d563A36AE78145C45a50134d48A1215220f80a", // 负风险市场交易所
|
||||||
|
"NEG_RISK_ADAPTER" to "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" // 负风险适配器(非 WCOL 地址)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** USDC 精度(6 位小数) */
|
||||||
|
private val usdcDecimals = java.math.BigDecimal("1000000")
|
||||||
|
|
||||||
|
/** ERC20 无限授权额度(type(uint256).max),Polymarket 默认使用无限授权 */
|
||||||
|
private val unlimitedAllowance = BigInteger("115792089237316195423570985008687907853269984665640564039457584007913129639935")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账户设置状态(代理部署、交易启用、代币批准)
|
||||||
|
* @param accountId 账户 ID
|
||||||
|
* @return AccountSetupStatusDto
|
||||||
|
*/
|
||||||
|
suspend fun checkAccountSetupStatus(accountId: Long): Result<AccountSetupStatusDto> {
|
||||||
|
return try {
|
||||||
|
if (accountId <= 0) {
|
||||||
|
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||||
|
}
|
||||||
|
val account = accountRepository.findById(accountId).orElse(null)
|
||||||
|
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||||
|
|
||||||
|
val proxyAddress = account.proxyAddress
|
||||||
|
if (proxyAddress.isBlank()) {
|
||||||
|
return Result.success(
|
||||||
|
AccountSetupStatusDto(
|
||||||
|
proxyDeployed = false,
|
||||||
|
tradingEnabled = account.apiKey != null && account.apiSecret != null && account.apiPassphrase != null,
|
||||||
|
tokensApproved = false,
|
||||||
|
approvalDetails = null,
|
||||||
|
error = "代理地址为空"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 步骤1:代理钱包是否已部署
|
||||||
|
val proxyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||||
|
|
||||||
|
// 步骤2:交易是否已启用(API 凭证是否已配置)
|
||||||
|
val tradingEnabled = account.apiKey != null &&
|
||||||
|
account.apiSecret != null &&
|
||||||
|
account.apiPassphrase != null
|
||||||
|
|
||||||
|
// 步骤3:代币是否已批准(USDC 对各 spender 的 allowance,默认无限授权)
|
||||||
|
val approvalDetails = mutableMapOf<String, String>()
|
||||||
|
var tokensApproved = true
|
||||||
|
for ((name, spender) in setupApprovalSpenders) {
|
||||||
|
val allowanceResult = blockchainService.getUsdcAllowance(proxyAddress, spender)
|
||||||
|
val allowance = allowanceResult.getOrNull() ?: BigInteger.ZERO
|
||||||
|
val displayAmount = if (allowance >= unlimitedAllowance) {
|
||||||
|
"unlimited"
|
||||||
|
} else {
|
||||||
|
java.math.BigDecimal(allowance).divide(usdcDecimals, 6, java.math.RoundingMode.DOWN).toPlainString()
|
||||||
|
}
|
||||||
|
approvalDetails[name] = displayAmount
|
||||||
|
if (allowance <= BigInteger.ZERO) {
|
||||||
|
tokensApproved = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(
|
||||||
|
AccountSetupStatusDto(
|
||||||
|
proxyDeployed = proxyDeployed,
|
||||||
|
tradingEnabled = tradingEnabled,
|
||||||
|
tokensApproved = tokensApproved,
|
||||||
|
approvalDetails = approvalDetails,
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("检查账户设置状态失败: accountId=$accountId, ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 步骤1 跳转 URL(代理部署需在 Polymarket 完成) */
|
||||||
|
private val setupStep1RedirectUrl = "https://polymarket.com/settings/wallet"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行设置步骤(由后端实现或返回跳转)
|
||||||
|
* 步骤1:仅返回跳转 URL,由用户前往 Polymarket 完成部署
|
||||||
|
* 步骤2:创建/派生 API Key 并更新账户
|
||||||
|
* 步骤3:通过代理钱包批量执行 USDC 授权
|
||||||
|
*/
|
||||||
|
suspend fun executeSetupStep(accountId: Long, step: Int): Result<ExecuteSetupStepResponse> {
|
||||||
|
return try {
|
||||||
|
if (accountId <= 0) {
|
||||||
|
return Result.failure(IllegalArgumentException("账户 ID 无效"))
|
||||||
|
}
|
||||||
|
val account = accountRepository.findById(accountId).orElse(null)
|
||||||
|
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||||
|
|
||||||
|
when (step) {
|
||||||
|
1 -> {
|
||||||
|
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.MAGIC)
|
||||||
|
if (walletType == WalletType.MAGIC) {
|
||||||
|
Result.success(
|
||||||
|
ExecuteSetupStepResponse(
|
||||||
|
success = false,
|
||||||
|
redirectUrl = setupStep1RedirectUrl
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val proxyAddress = account.proxyAddress
|
||||||
|
if (proxyAddress.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("代理地址为空"))
|
||||||
|
}
|
||||||
|
val alreadyDeployed = blockchainService.isProxyDeployed(proxyAddress)
|
||||||
|
if (alreadyDeployed) {
|
||||||
|
Result.success(ExecuteSetupStepResponse(success = true))
|
||||||
|
} else {
|
||||||
|
val privateKey = decryptPrivateKey(account)
|
||||||
|
val deployResult = relayClientService.deploySafeViaBuilderRelayer(
|
||||||
|
privateKey = privateKey,
|
||||||
|
proxyAddress = proxyAddress,
|
||||||
|
fromAddress = account.walletAddress
|
||||||
|
)
|
||||||
|
deployResult.fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
Result.success(
|
||||||
|
ExecuteSetupStepResponse(
|
||||||
|
success = true,
|
||||||
|
transactionHash = txHash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("Safe 部署失败: accountId=$accountId, ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
val privateKey = decryptPrivateKey(account)
|
||||||
|
val result = apiKeyService.createOrDeriveApiKey(
|
||||||
|
privateKey = privateKey,
|
||||||
|
walletAddress = account.walletAddress,
|
||||||
|
chainId = 137L
|
||||||
|
)
|
||||||
|
if (result.isFailure) {
|
||||||
|
val e = result.exceptionOrNull()
|
||||||
|
logger.error("启用交易(API Key)失败: accountId=$accountId, ${e?.message}", e)
|
||||||
|
return Result.failure(e ?: IllegalStateException("获取 API Key 失败"))
|
||||||
|
}
|
||||||
|
val creds = result.getOrNull()
|
||||||
|
?: return Result.failure(IllegalStateException("API Key 返回为空"))
|
||||||
|
val encryptedSecret = creds.secret.let { cryptoUtils.encrypt(it) }
|
||||||
|
val encryptedPassphrase = creds.passphrase.let { cryptoUtils.encrypt(it) }
|
||||||
|
val updated = account.copy(
|
||||||
|
apiKey = creds.apiKey,
|
||||||
|
apiSecret = encryptedSecret,
|
||||||
|
apiPassphrase = encryptedPassphrase,
|
||||||
|
updatedAt = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
accountRepository.save(updated)
|
||||||
|
orderPushService.refreshSubscriptions()
|
||||||
|
Result.success(ExecuteSetupStepResponse(success = true))
|
||||||
|
}
|
||||||
|
3 -> {
|
||||||
|
val proxyAddress = account.proxyAddress
|
||||||
|
if (proxyAddress.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("代理地址为空,请先完成步骤1"))
|
||||||
|
}
|
||||||
|
val privateKey = decryptPrivateKey(account)
|
||||||
|
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||||
|
val approveTxs = setupApprovalSpenders.values.map { spender ->
|
||||||
|
relayClientService.createUsdcApproveTx(spender, unlimitedAllowance)
|
||||||
|
}
|
||||||
|
val multiSendTx = relayClientService.createMultiSendTx(approveTxs)
|
||||||
|
val executeResult = relayClientService.execute(
|
||||||
|
privateKey = privateKey,
|
||||||
|
proxyAddress = proxyAddress,
|
||||||
|
safeTx = multiSendTx,
|
||||||
|
walletType = walletType
|
||||||
|
)
|
||||||
|
executeResult.fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
Result.success(
|
||||||
|
ExecuteSetupStepResponse(
|
||||||
|
success = true,
|
||||||
|
transactionHash = txHash
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("代币授权执行失败: accountId=$accountId, ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Result.failure(IllegalArgumentException("无效的步骤: $step,应为 1、2 或 3"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("执行设置步骤失败: accountId=$accountId, step=$step, ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新账户信息
|
* 更新账户信息
|
||||||
*/
|
*/
|
||||||
@@ -278,7 +677,7 @@ class AccountService(
|
|||||||
if (accountId == null) {
|
if (accountId == null) {
|
||||||
return Result.failure(IllegalArgumentException("账户ID不能为空"))
|
return Result.failure(IllegalArgumentException("账户ID不能为空"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val account = accountRepository.findById(accountId).orElse(null)
|
val account = accountRepository.findById(accountId).orElse(null)
|
||||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||||
|
|
||||||
@@ -288,68 +687,19 @@ class AccountService(
|
|||||||
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
|
return Result.failure(IllegalStateException("账户代理地址不存在,无法查询余额。请重新导入账户以获取代理地址"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询 USDC 余额和持仓信息
|
// 使用通用方法查询余额
|
||||||
val balanceResult = runBlocking {
|
val balanceResult = runBlocking {
|
||||||
try {
|
blockchainService.getWalletBalance(account.proxyAddress)
|
||||||
// 查询持仓信息(用于返回持仓列表)
|
|
||||||
// 使用代理地址查询持仓(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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.success(balanceResult)
|
balanceResult.map { walletBalance: WalletBalanceResponse ->
|
||||||
|
AccountBalanceResponse(
|
||||||
|
availableBalance = walletBalance.availableBalance,
|
||||||
|
positionBalance = walletBalance.positionBalance,
|
||||||
|
totalBalance = walletBalance.totalBalance,
|
||||||
|
positions = walletBalance.positions
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("查询账户余额失败", e)
|
logger.error("查询账户余额失败", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
@@ -586,7 +936,38 @@ class AccountService(
|
|||||||
throw RuntimeException("解密私钥失败: ${e.message}", e)
|
throw RuntimeException("解密私钥失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询用:遍历所有账户,对代理地址 WCOL 余额 > 0 的执行解包为 USDC.e。
|
||||||
|
* 由 WcolUnwrapJobService 每 20 秒调用,赎回后无需在赎回流程内等待确认与解包。
|
||||||
|
*/
|
||||||
|
suspend fun runWcolUnwrapForAllAccounts() {
|
||||||
|
val accounts = accountRepository.findAllByOrderByCreatedAtAsc()
|
||||||
|
if (accounts.isEmpty()) return
|
||||||
|
for (account in accounts) {
|
||||||
|
try {
|
||||||
|
val privateKey = decryptPrivateKey(account)
|
||||||
|
val walletType = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||||
|
blockchainService.unwrapWcolForProxy(
|
||||||
|
privateKey = privateKey,
|
||||||
|
proxyAddress = account.proxyAddress,
|
||||||
|
walletType = walletType
|
||||||
|
).fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
if (txHash != null) {
|
||||||
|
logger.info("轮询解包 WCOL: accountId=${account.id}, proxy=${account.proxyAddress.take(10)}..., txHash=$txHash")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.warn("轮询解包 WCOL 失败 accountId=${account.id}: ${e.message}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("轮询解包 WCOL 跳过 accountId=${account.id}: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解密账户 API Secret
|
* 解密账户 API Secret
|
||||||
*/
|
*/
|
||||||
@@ -747,7 +1128,7 @@ class AccountService(
|
|||||||
|
|
||||||
// 3. 验证仓位是否存在并获取原始数量
|
// 3. 验证仓位是否存在并获取原始数量
|
||||||
val positionsResult = getAllPositions()
|
val positionsResult = getAllPositions()
|
||||||
val (position, originalQuantity) = positionsResult.fold(
|
val (_, originalQuantity) = positionsResult.fold(
|
||||||
onSuccess = { positionListResponse ->
|
onSuccess = { positionListResponse ->
|
||||||
val position = positionListResponse.currentPositions.find {
|
val position = positionListResponse.currentPositions.find {
|
||||||
it.accountId == request.accountId &&
|
it.accountId == request.accountId &&
|
||||||
@@ -780,7 +1161,7 @@ class AccountService(
|
|||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
return Result.failure(Exception("查询仓位失败: ${e.message}"))
|
return Result.failure(Exception("查询仓位失败: ${e.message}"))
|
||||||
}
|
}
|
||||||
) ?: return Result.failure(IllegalArgumentException("仓位不存在"))
|
)
|
||||||
|
|
||||||
// 4. 计算实际卖出数量
|
// 4. 计算实际卖出数量
|
||||||
val sellQuantity = if (percentDecimal != null) {
|
val sellQuantity = if (percentDecimal != null) {
|
||||||
@@ -876,7 +1257,7 @@ class AccountService(
|
|||||||
"0"
|
"0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. 创建并签名订单(使用计算后的卖出数量)
|
// 11. 创建并签名订单(使用计算后的卖出数量,按账户钱包类型使用对应 signatureType)
|
||||||
val signedOrder = try {
|
val signedOrder = try {
|
||||||
orderSigningService.createAndSignOrder(
|
orderSigningService.createAndSignOrder(
|
||||||
privateKey = decryptedPrivateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
@@ -885,7 +1266,7 @@ class AccountService(
|
|||||||
side = "SELL",
|
side = "SELL",
|
||||||
price = sellPrice,
|
price = sellPrice,
|
||||||
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
size = sellQuantity.toPlainString(), // 使用计算后的卖出数量
|
||||||
signatureType = 2, // Browser Wallet(与正确订单数据一致)
|
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||||
nonce = "0",
|
nonce = "0",
|
||||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||||
expiration = expiration
|
expiration = expiration
|
||||||
@@ -899,7 +1280,7 @@ class AccountService(
|
|||||||
|
|
||||||
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
|
val newOrderRequest = com.wrbug.polymarketbot.api.NewOrderRequest(
|
||||||
order = signedOrder,
|
order = signedOrder,
|
||||||
owner = account.apiKey!!, // API Key
|
owner = account.apiKey, // API Key
|
||||||
orderType = orderType,
|
orderType = orderType,
|
||||||
deferExec = false
|
deferExec = false
|
||||||
)
|
)
|
||||||
@@ -919,7 +1300,7 @@ class AccountService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val clobApi = retrofitFactory.createClobApi(
|
val clobApi = retrofitFactory.createClobApi(
|
||||||
account.apiKey!!,
|
account.apiKey,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
apiPassphrase,
|
apiPassphrase,
|
||||||
account.walletAddress
|
account.walletAddress
|
||||||
@@ -948,6 +1329,9 @@ class AccountService(
|
|||||||
java.util.Locale("zh", "CN") // 默认简体中文
|
java.util.Locale("zh", "CN") // 默认简体中文
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用当前时间作为订单创建时间
|
||||||
|
val orderTime = System.currentTimeMillis()
|
||||||
|
|
||||||
telegramNotificationService?.sendOrderSuccessNotification(
|
telegramNotificationService?.sendOrderSuccessNotification(
|
||||||
orderId = orderId,
|
orderId = orderId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
@@ -963,7 +1347,8 @@ class AccountService(
|
|||||||
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
|
apiSecret = try { cryptoUtils.decrypt(account.apiSecret!!) } catch (e: Exception) { null },
|
||||||
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
||||||
walletAddressForApi = account.walletAddress,
|
walletAddressForApi = account.walletAddress,
|
||||||
locale = locale
|
locale = locale,
|
||||||
|
orderTime = orderTime // 使用订单创建时间
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
||||||
@@ -1234,13 +1619,6 @@ class AccountService(
|
|||||||
*/
|
*/
|
||||||
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
|
suspend fun redeemPositions(request: PositionRedeemRequest): Result<PositionRedeemResponse> {
|
||||||
return try {
|
return try {
|
||||||
// 检查 Builder API Key 是否已配置
|
|
||||||
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
|
||||||
return Result.failure(
|
|
||||||
IllegalStateException("Builder API Key 未配置,无法执行 Gasless 交易。请前往系统设置页面配置 Builder API Key。")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.positions.isEmpty()) {
|
if (request.positions.isEmpty()) {
|
||||||
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
|
return Result.failure(IllegalArgumentException("赎回仓位列表不能为空"))
|
||||||
}
|
}
|
||||||
@@ -1262,7 +1640,17 @@ class AccountService(
|
|||||||
accounts[accountId] = account
|
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 accountRedeemData = mutableMapOf<Long, MutableList<Pair<AccountPositionDto, BigInteger>>>()
|
||||||
val accountRedeemedInfo =
|
val accountRedeemedInfo =
|
||||||
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
|
mutableMapOf<Long, MutableList<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>>()
|
||||||
@@ -1305,7 +1693,7 @@ class AccountService(
|
|||||||
accountRedeemedInfo[accountId] = accountInfo
|
accountRedeemedInfo[accountId] = accountInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 对每个账户执行赎回
|
// 6. 对每个账户执行赎回(Safe 与 Magic 均支持,Magic 通过 Builder Relayer PROXY Gasless 执行)
|
||||||
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
|
val accountTransactions = mutableListOf<com.wrbug.polymarketbot.dto.AccountRedeemTransaction>()
|
||||||
var totalRedeemedValue = BigDecimal.ZERO
|
var totalRedeemedValue = BigDecimal.ZERO
|
||||||
|
|
||||||
@@ -1316,20 +1704,31 @@ class AccountService(
|
|||||||
// 按市场分组(同一市场的仓位可以批量赎回)
|
// 按市场分组(同一市场的仓位可以批量赎回)
|
||||||
val positionsByMarket = positions.groupBy { it.first.marketId }
|
val positionsByMarket = positions.groupBy { it.first.marketId }
|
||||||
|
|
||||||
// 对每个市场执行赎回
|
// 获取钱包类型
|
||||||
|
val walletTypeEnum = WalletType.fromStringOrDefault(account.walletType, WalletType.SAFE)
|
||||||
|
|
||||||
|
// 解密私钥(只需解密一次)
|
||||||
|
val decryptedPrivateKey = decryptPrivateKey(account)
|
||||||
|
|
||||||
|
// 执行赎回
|
||||||
var lastTxHash: String? = null
|
var lastTxHash: String? = null
|
||||||
for ((marketId, marketPositions) in positionsByMarket) {
|
|
||||||
val indexSets = marketPositions.map { it.second }
|
|
||||||
|
|
||||||
// 解密私钥
|
// Safe 钱包且有多个市场:使用 MultiSend 批量赎回
|
||||||
val decryptedPrivateKey = decryptPrivateKey(account)
|
if (walletTypeEnum == WalletType.SAFE && positionsByMarket.size > 1) {
|
||||||
|
val redeemRequests = mutableListOf<Triple<String, List<BigInteger>, Boolean>>()
|
||||||
|
for ((marketId, marketPositions) in positionsByMarket) {
|
||||||
|
val indexSets = marketPositions.map { it.second }
|
||||||
|
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
|
||||||
|
redeemRequests.add(Triple(marketId, indexSets, isNegRisk))
|
||||||
|
}
|
||||||
|
|
||||||
// 调用区块链服务赎回仓位
|
logger.info("账户 $accountId: 使用 MultiSend 批量赎回 ${redeemRequests.size} 个市场")
|
||||||
val redeemResult = blockchainService.redeemPositions(
|
|
||||||
|
val redeemResult = blockchainService.redeemPositionsBatch(
|
||||||
privateKey = decryptedPrivateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
proxyAddress = account.proxyAddress,
|
proxyAddress = account.proxyAddress,
|
||||||
conditionId = marketId,
|
redeemRequests = redeemRequests,
|
||||||
indexSets = indexSets
|
walletType = walletTypeEnum
|
||||||
)
|
)
|
||||||
|
|
||||||
redeemResult.fold(
|
redeemResult.fold(
|
||||||
@@ -1337,11 +1736,38 @@ class AccountService(
|
|||||||
lastTxHash = txHash
|
lastTxHash = txHash
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
|
logger.error("账户 $accountId MultiSend 批量赎回失败: ${e.message}", e)
|
||||||
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
|
return Result.failure(Exception("赎回失败: 账户 $accountId - ${e.message}"))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
// Magic 钱包或单个市场:逐笔赎回
|
||||||
|
for ((marketId, marketPositions) in positionsByMarket) {
|
||||||
|
val indexSets = marketPositions.map { it.second }
|
||||||
|
val isNegRisk = marketService.getNegRiskByConditionId(marketId) == true
|
||||||
|
|
||||||
|
val redeemResult = blockchainService.redeemPositions(
|
||||||
|
privateKey = decryptedPrivateKey,
|
||||||
|
proxyAddress = account.proxyAddress,
|
||||||
|
conditionId = marketId,
|
||||||
|
indexSets = indexSets,
|
||||||
|
isNegRisk = isNegRisk,
|
||||||
|
walletType = walletTypeEnum
|
||||||
|
)
|
||||||
|
|
||||||
|
redeemResult.fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
lastTxHash = txHash
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("账户 $accountId 市场 $marketId 赎回失败: ${e.message}", e)
|
||||||
|
return Result.failure(Exception("赎回失败: 账户 $accountId 市场 $marketId - ${e.message}"))
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WCOL 解包由 WcolUnwrapJobService 每 20 秒轮询统一处理,赎回流程不再等待确认与解包
|
||||||
|
|
||||||
// 计算该账户的赎回总价值
|
// 计算该账户的赎回总价值
|
||||||
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
|
val accountTotalValue = redeemedInfo.fold(BigDecimal.ZERO) { sum, info ->
|
||||||
@@ -1360,7 +1786,7 @@ class AccountService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 发送赎回推送通知(异步,不阻塞)
|
// 7. 发送赎回推送通知(异步,不阻塞)
|
||||||
notificationScope.launch {
|
notificationScope.launch {
|
||||||
try {
|
try {
|
||||||
// 获取当前语言设置
|
// 获取当前语言设置
|
||||||
|
|||||||
+75
-48
@@ -25,6 +25,7 @@ import com.wrbug.polymarketbot.service.common.MarketPriceService
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓位检查服务
|
* 仓位检查服务
|
||||||
@@ -77,7 +78,10 @@ class PositionCheckService(
|
|||||||
|
|
||||||
// 同步锁,确保订阅任务的启动和停止是线程安全的
|
// 同步锁,确保订阅任务的启动和停止是线程安全的
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
|
// 防止 checkRedeemablePositions 重入:上一轮检查未完成时,新一轮轮询直接跳过
|
||||||
|
private val redeemCheckInProgress = AtomicBoolean(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
|
* 初始化服务(订阅 PositionPollingService 的事件,启动缓存清理任务)
|
||||||
*/
|
*/
|
||||||
@@ -328,18 +332,23 @@ class PositionCheckService(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 逻辑1:处理待赎回仓位
|
* 逻辑1:处理待赎回仓位
|
||||||
https://clob.polymarket.com * 按照以下逻辑处理:
|
* 按照以下逻辑处理:
|
||||||
* 1. 无待赎回仓位:跳过
|
* 1. 无待赎回仓位:跳过
|
||||||
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
|
* 2. (未配置apikey || autoredeem==false) && 有待赎回的仓位:发送通知事件
|
||||||
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
* 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||||
|
* 防重入:上一轮检查未完成时,本轮直接跳过,避免并发赎回。
|
||||||
*/
|
*/
|
||||||
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
|
private suspend fun checkRedeemablePositions(redeemablePositions: List<AccountPositionDto>) {
|
||||||
|
if (!redeemCheckInProgress.compareAndSet(false, true)) {
|
||||||
|
logger.debug("跳过本次待赎回仓位检查:上一次检查尚未完成")
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 1. 无待赎回仓位:跳过
|
// 1. 无待赎回仓位:跳过
|
||||||
if (redeemablePositions.isEmpty()) {
|
if (redeemablePositions.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查系统级别的自动赎回配置
|
// 检查系统级别的自动赎回配置
|
||||||
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
|
val autoRedeemEnabled = systemConfigService.isAutoRedeemEnabled()
|
||||||
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
|
val apiKeyConfigured = relayClientService.isBuilderApiKeyConfigured()
|
||||||
@@ -373,21 +382,24 @@ class PositionCheckService(
|
|||||||
}
|
}
|
||||||
return // 未配置时直接返回,不进行后续处理
|
return // 未配置时直接返回,不进行后续处理
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Builder Relayer 配额冷却期内不再发起赎回(如 API 返回 quota exceeded, resets in N seconds)
|
||||||
|
if (relayClientService.isBuilderRelayerQuotaBlocked()) {
|
||||||
|
val remaining = relayClientService.getBuilderRelayerQuotaBlockedRemainingSeconds()
|
||||||
|
logger.info("Builder Relayer 配额冷却中,跳过本次自动赎回,约 ${remaining} 秒后恢复")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
// 3. (已配置) && 有待赎回的仓位:处理订单逻辑
|
||||||
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
|
// 自动赎回已开启且已配置 API Key,按账户分组进行赎回处理
|
||||||
// 先执行赎回,赎回成功后再查找订单并更新订单状态
|
// 先执行赎回,赎回成功后再查找订单并更新订单状态
|
||||||
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
val positionsByAccount = redeemablePositions.groupBy { it.accountId }
|
||||||
|
|
||||||
for ((accountId, positions) in positionsByAccount) {
|
for ((accountId, positions) in positionsByAccount) {
|
||||||
// 查找该账户下所有启用的跟单配置
|
// 查找该账户下所有启用的跟单配置(仅用于赎回成功后更新跟单订单状态;无跟单配置的账户如加密价差策略账户也会执行赎回)
|
||||||
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
val copyTradings = copyTradingRepository.findByAccountId(accountId)
|
||||||
.filter { it.enabled }
|
.filter { it.enabled }
|
||||||
|
|
||||||
if (copyTradings.isEmpty()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤掉已经处理过的仓位(去重,避免重复赎回)
|
// 过滤掉已经处理过的仓位(去重,避免重复赎回)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val positionsToRedeem = positions.filter { position ->
|
val positionsToRedeem = positions.filter { position ->
|
||||||
@@ -430,27 +442,20 @@ class PositionCheckService(
|
|||||||
processedRedeemablePositions[positionKey] = now
|
processedRedeemablePositions[positionKey] = now
|
||||||
}
|
}
|
||||||
|
|
||||||
// 赎回成功后,再查找订单并更新订单状态
|
// 赎回成功后,按每个跟单配置分别查找未卖出订单并更新状态
|
||||||
|
// 同一账户同一市场可能同时跟多个 Leader,需按 copyTradingId 分别生成自动卖出记录(如 leader1 对应 20 share,leader2 对应 16 share)
|
||||||
for (position in positionsToRedeem) {
|
for (position in positionsToRedeem) {
|
||||||
// 查找相同仓位的未卖出订单(remaining_quantity > 0)
|
if (position.outcomeIndex == null) {
|
||||||
val unmatchedOrders = mutableListOf<CopyOrderTracking>()
|
continue
|
||||||
for (copyTrading in copyTradings) {
|
|
||||||
if (position.outcomeIndex != null) {
|
|
||||||
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
|
||||||
copyTrading.id!!,
|
|
||||||
position.marketId,
|
|
||||||
position.outcomeIndex
|
|
||||||
)
|
|
||||||
unmatchedOrders.addAll(orders)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for (copyTrading in copyTradings) {
|
||||||
// 如果有未卖出订单,更新订单状态
|
val orders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndex(
|
||||||
if (unmatchedOrders.isNotEmpty()) {
|
copyTrading.id!!,
|
||||||
// 从订单中获取 copyTradingId(所有订单应该有相同的 copyTradingId)
|
position.marketId,
|
||||||
val copyTradingId = unmatchedOrders.firstOrNull()?.copyTradingId
|
position.outcomeIndex
|
||||||
if (copyTradingId != null) {
|
)
|
||||||
updateOrdersAsSoldAfterRedeem(unmatchedOrders, position, copyTradingId)
|
if (orders.isNotEmpty()) {
|
||||||
|
updateOrdersAsSoldAfterRedeem(orders, position, copyTrading.id!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,9 +467,11 @@ class PositionCheckService(
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理待赎回仓位异常: ${e.message}", e)
|
logger.error("处理待赎回仓位异常: ${e.message}", e)
|
||||||
|
} finally {
|
||||||
|
redeemCheckInProgress.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 逻辑2:处理未卖出订单
|
* 逻辑2:处理未卖出订单
|
||||||
* 检查所有未卖出的订单,匹配仓位
|
* 检查所有未卖出的订单,匹配仓位
|
||||||
@@ -509,17 +516,21 @@ class PositionCheckService(
|
|||||||
|
|
||||||
if (position == null) {
|
if (position == null) {
|
||||||
// 仓位不存在,使用延迟检测机制
|
// 仓位不存在,使用延迟检测机制
|
||||||
// 先检查订单创建时间,只有超过2分钟的订单才进入延迟检测
|
// 先查询创建时间超过2分钟的未匹配订单(SQL层过滤,避免刚创建的订单被误判)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val ordersToCheck = orders.filter { order ->
|
val thresholdTime = now - 120000 // 2分钟 = 120000毫秒
|
||||||
val orderAge = now - order.createdAt
|
|
||||||
orderAge > 120000 // 2分钟 = 120000毫秒
|
val ordersToCheck = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
|
||||||
}
|
copyTradingId = copyTrading.id!!,
|
||||||
|
marketId = marketId,
|
||||||
|
outcomeIndex = outcomeIndex,
|
||||||
|
thresholdTime = thresholdTime
|
||||||
|
)
|
||||||
|
|
||||||
if (ordersToCheck.isNotEmpty()) {
|
if (ordersToCheck.isNotEmpty()) {
|
||||||
// 有订单创建时间超过2分钟,记录到待检查列表
|
// 有订单创建时间超过2分钟,记录到待检查列表
|
||||||
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
|
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
|
||||||
|
|
||||||
// 如果已经存在记录,更新订单列表(可能订单状态有变化)
|
// 如果已经存在记录,更新订单列表(可能订单状态有变化)
|
||||||
val existingCheck = pendingPositionChecks[checkKey]
|
val existingCheck = pendingPositionChecks[checkKey]
|
||||||
if (existingCheck == null) {
|
if (existingCheck == null) {
|
||||||
@@ -540,42 +551,58 @@ class PositionCheckService(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
|
// 订单创建时间不足2分钟,可能是刚创建的订单,暂时不处理
|
||||||
logger.debug("仓位不存在但订单创建时间不足2分钟,暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, oldestOrderAge=${orders.minOfOrNull { now - it.createdAt }?.let { "${it}ms" } ?: "N/A"}, positionKey=$positionKey")
|
logger.debug("仓位不存在但无符合条件的订单(创建时间不足2分钟),暂不标记为已卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, orderCount=${orders.size}, thresholdTime=$thresholdTime, positionKey=$positionKey")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 有仓位,先检查是否有对应的待检查记录,如果有则删除(仓位已恢复)
|
// 有仓位,先检查是否有对应的待检查记录,如果有则删除(仓位已恢复)
|
||||||
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
|
val checkKey = "${copyTrading.accountId}_${marketId}_${outcomeIndex}_${copyTrading.id}"
|
||||||
val pendingCheck = pendingPositionChecks.remove(checkKey)
|
val pendingCheck = pendingPositionChecks.remove(checkKey)
|
||||||
if (pendingCheck != null) {
|
if (pendingCheck != null) {
|
||||||
logger.info("仓位已恢复,删除待检查记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, elapsedTime=${System.currentTimeMillis() - pendingCheck.firstDetectedTime}ms")
|
logger.info("待检查仓位已恢复,删除待检查记录: marketId=$marketId, outcomeIndex=$outcomeIndex, accountId=${copyTrading.accountId}, copyTradingId=${copyTrading.id}, elapsedTime=${System.currentTimeMillis() - pendingCheck.firstDetectedTime}ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 有仓位,按订单下单顺序(FIFO)更新状态
|
// 有仓位,按订单下单顺序(FIFO)更新状态
|
||||||
|
// 先查询创建时间超过2分钟的未匹配订单(SQL层过滤,避免刚创建的订单被误判)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val thresholdTime = now - 120000 // 2分钟 = 120000毫秒
|
||||||
|
|
||||||
|
val validOrders = copyOrderTrackingRepository.findUnmatchedBuyOrdersByOutcomeIndexOlderThan(
|
||||||
|
copyTradingId = copyTrading.id!!,
|
||||||
|
marketId = marketId,
|
||||||
|
outcomeIndex = outcomeIndex,
|
||||||
|
thresholdTime = thresholdTime
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果没有符合条件的订单,跳过处理
|
||||||
|
if (validOrders.isEmpty()) {
|
||||||
|
logger.debug("仓位存在但无符合条件的订单(创建时间不足2分钟),暂不进行FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, thresholdTime=$thresholdTime")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 计算逻辑:
|
// 计算逻辑:
|
||||||
// 1. 总订单数量 = 所有未卖出订单的剩余数量总和
|
// 1. 总订单数量 = 所有符合条件的未卖出订单的剩余数量总和
|
||||||
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
|
// 2. 已成交数量 = 总订单数量 - 仓位数量(因为还有仓位,说明部分订单已卖出)
|
||||||
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
|
// 3. 如果已成交数量 = 0,说明订单还没有卖出,不修改订单状态
|
||||||
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
|
// 4. 如果已成交数量 > 0,按FIFO顺序匹配订单
|
||||||
val positionQuantity = position.quantity.toSafeBigDecimal()
|
val positionQuantity = position.quantity.toSafeBigDecimal()
|
||||||
|
|
||||||
// 计算总订单数量
|
// 计算总订单数量(只计算符合条件的订单)
|
||||||
val totalOrderQuantity = orders.fold(BigDecimal.ZERO) { sum, order ->
|
val totalOrderQuantity = validOrders.fold(BigDecimal.ZERO) { sum, order ->
|
||||||
sum.add(order.remainingQuantity.toSafeBigDecimal())
|
sum.add(order.remainingQuantity.toSafeBigDecimal())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算已成交数量
|
// 计算已成交数量
|
||||||
val soldQuantity = totalOrderQuantity.subtract(positionQuantity)
|
val soldQuantity = totalOrderQuantity.subtract(positionQuantity)
|
||||||
|
|
||||||
// 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态
|
// 如果已成交数量 <= 0,说明订单还没有卖出,不修改订单状态
|
||||||
if (soldQuantity <= BigDecimal.ZERO) {
|
if (soldQuantity <= BigDecimal.ZERO) {
|
||||||
logger.debug("仓位数量 >= 订单数量总和,订单尚未卖出: marketId=$marketId, outcomeIndex=$outcomeIndex, positionQuantity=$positionQuantity, totalOrderQuantity=$totalOrderQuantity")
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果已成交数量 > 0,按FIFO顺序匹配订单
|
// 如果已成交数量 > 0,按FIFO顺序匹配订单(只匹配符合条件的订单)
|
||||||
try {
|
try {
|
||||||
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
val currentPrice = getCurrentMarketPrice(marketId, outcomeIndex)
|
||||||
updateOrdersAsSoldByFIFO(orders, soldQuantity, currentPrice,
|
updateOrdersAsSoldByFIFO(validOrders, soldQuantity, currentPrice,
|
||||||
copyTrading.id, marketId, outcomeIndex)
|
copyTrading.id, marketId, outcomeIndex)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("无法获取市场价格,跳过FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
logger.warn("无法获取市场价格,跳过FIFO匹配: marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}")
|
||||||
|
|||||||
+16
-18
@@ -21,29 +21,29 @@ import java.util.concurrent.CopyOnWriteArrayList
|
|||||||
class PositionPollingService(
|
class PositionPollingService(
|
||||||
private val accountService: AccountService
|
private val accountService: AccountService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
private val logger = LoggerFactory.getLogger(PositionPollingService::class.java)
|
||||||
|
|
||||||
@Value("\${position.polling.interval:2000}")
|
@Value("\${position.polling.interval:2000}")
|
||||||
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
private var pollingInterval: Long = 2000 // 轮训间隔(毫秒),默认2秒
|
||||||
|
|
||||||
// 订阅者列表(支持多个订阅者)
|
// 订阅者列表(支持多个订阅者)
|
||||||
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
private val subscribers = CopyOnWriteArrayList<(PositionListResponse) -> Unit>()
|
||||||
|
|
||||||
// 最新仓位数据(用于丢弃机制)
|
// 最新仓位数据(用于丢弃机制)
|
||||||
@Volatile
|
@Volatile
|
||||||
private var latestPositions: PositionListResponse? = null
|
private var latestPositions: PositionListResponse? = null
|
||||||
|
|
||||||
// 协程作用域和任务
|
// 协程作用域和任务
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
private var pollingJob: Job? = null
|
private var pollingJob: Job? = null
|
||||||
|
|
||||||
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
// 事件分发协程(使用专门的线程,避免阻塞轮训)
|
||||||
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val eventDispatcherScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
// 同步锁,确保轮询任务的启动和停止是线程安全的
|
||||||
private val lock = Any()
|
private val lock = Any()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化服务(后端启动时直接启动轮训)
|
* 初始化服务(后端启动时直接启动轮训)
|
||||||
*/
|
*/
|
||||||
@@ -52,7 +52,7 @@ class PositionPollingService(
|
|||||||
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
logger.info("PositionPollingService 初始化,启动仓位轮训任务,轮训间隔: ${pollingInterval}ms")
|
||||||
startPolling()
|
startPolling()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理资源
|
* 清理资源
|
||||||
*/
|
*/
|
||||||
@@ -66,7 +66,7 @@ class PositionPollingService(
|
|||||||
scope.cancel()
|
scope.cancel()
|
||||||
eventDispatcherScope.cancel()
|
eventDispatcherScope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅仓位事件
|
* 订阅仓位事件
|
||||||
* @param callback 回调函数,接收最新的仓位数据
|
* @param callback 回调函数,接收最新的仓位数据
|
||||||
@@ -78,7 +78,7 @@ class PositionPollingService(
|
|||||||
latestPositions?.let { callback(it) }
|
latestPositions?.let { callback(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消订阅仓位事件
|
* 取消订阅仓位事件
|
||||||
*/
|
*/
|
||||||
@@ -87,7 +87,7 @@ class PositionPollingService(
|
|||||||
subscribers.remove(callback)
|
subscribers.remove(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动轮训任务
|
* 启动轮训任务
|
||||||
*/
|
*/
|
||||||
@@ -95,7 +95,7 @@ class PositionPollingService(
|
|||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
// 如果已经有轮训任务在运行,先取消
|
// 如果已经有轮训任务在运行,先取消
|
||||||
pollingJob?.cancel()
|
pollingJob?.cancel()
|
||||||
|
|
||||||
// 启动新的轮训任务
|
// 启动新的轮训任务
|
||||||
pollingJob = scope.launch {
|
pollingJob = scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
@@ -109,7 +109,7 @@ class PositionPollingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轮训仓位数据并发布事件
|
* 轮训仓位数据并发布事件
|
||||||
* 使用专门的线程分发事件,避免阻塞轮训
|
* 使用专门的线程分发事件,避免阻塞轮训
|
||||||
@@ -123,7 +123,7 @@ class PositionPollingService(
|
|||||||
if (positions != null) {
|
if (positions != null) {
|
||||||
// 更新最新数据(丢弃旧数据,只保留最新的)
|
// 更新最新数据(丢弃旧数据,只保留最新的)
|
||||||
latestPositions = positions
|
latestPositions = positions
|
||||||
|
|
||||||
// 在专门的线程中分发事件,避免阻塞轮训
|
// 在专门的线程中分发事件,避免阻塞轮训
|
||||||
eventDispatcherScope.launch {
|
eventDispatcherScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -131,7 +131,7 @@ class PositionPollingService(
|
|||||||
val currentSubscribers = synchronized(lock) {
|
val currentSubscribers = synchronized(lock) {
|
||||||
subscribers.toList() // 复制列表,避免并发修改
|
subscribers.toList() // 复制列表,避免并发修改
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSubscribers.forEach { callback ->
|
currentSubscribers.forEach { callback ->
|
||||||
try {
|
try {
|
||||||
callback(positions)
|
callback(positions)
|
||||||
@@ -139,8 +139,6 @@ class PositionPollingService(
|
|||||||
logger.error("通知订阅者失败: ${e.message}", e)
|
logger.error("通知订阅者失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("发布仓位数据事件: currentPositions=${positions.currentPositions.size}, historyPositions=${positions.historyPositions.size}, subscribers=${currentSubscribers.size}")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("分发仓位数据事件失败: ${e.message}", e)
|
logger.error("分发仓位数据事件失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.wrbug.polymarketbot.service.accounts
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WCOL 解包轮询任务
|
||||||
|
* 每 20 秒轮询一次,遍历所有账户的代理地址:若 WCOL 余额 > 0 则解包为 USDC.e。
|
||||||
|
* 同一时间仅允许单次执行;若上次执行未结束则本次忽略(与现有轮询逻辑一致)。
|
||||||
|
* 若未配置 Builder API Key,直接跳过本轮(解包依赖 Relayer Gasless,未配置则无法执行)。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class WcolUnwrapJobService(
|
||||||
|
private val accountService: AccountService,
|
||||||
|
private val relayClientService: RelayClientService
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(WcolUnwrapJobService::class.java)
|
||||||
|
private val scope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
private var unwrapJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每 20 秒触发一次;若未配置 Builder Key 或当前任务仍在执行则跳过本次
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRate = 20_000)
|
||||||
|
fun runWcolUnwrapPolling() {
|
||||||
|
if (!relayClientService.isBuilderApiKeyConfigured()) {
|
||||||
|
logger.debug("Builder API Key 未配置,跳过 WCOL 解包轮询")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (unwrapJob?.isActive == true) {
|
||||||
|
logger.debug("上一轮 WCOL 解包任务仍在执行,跳过本次")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unwrapJob = scope.launch {
|
||||||
|
try {
|
||||||
|
accountService.runWcolUnwrapForAllAccounts()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("WCOL 解包轮询异常: ${e.message}", e)
|
||||||
|
} finally {
|
||||||
|
unwrapJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
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% 基准值 (marketSlugPrefix, interval, period)。
|
||||||
|
* 触发时由调用方按窗口进度计算动态系数(100%→50%)后得到有效最小价差。不在保存策略时计算。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class BinanceKlineAutoSpreadService(
|
||||||
|
private val retrofitFactory: RetrofitFactory
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(BinanceKlineAutoSpreadService::class.java)
|
||||||
|
|
||||||
|
/** 市场 slug 前缀 -> Binance 交易对映射 */
|
||||||
|
private val marketToSymbol = mapOf(
|
||||||
|
"btc-updown" to "BTCUSDC",
|
||||||
|
"eth-updown" to "ETHUSDC",
|
||||||
|
"sol-updown" to "SOLUSDC",
|
||||||
|
"xrp-updown" to "XRPUSDC"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val historyLimit = 20
|
||||||
|
private val minSamplesAfterIqr = 3
|
||||||
|
|
||||||
|
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (baseSpreadUp, baseSpreadDown),100% 基准价差 */
|
||||||
|
private val cache = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||||
|
|
||||||
|
/** 缓存保留时间(秒),超过则清理,防止无界增长 */
|
||||||
|
private val cacheExpireSeconds = 3600L
|
||||||
|
|
||||||
|
/** 从市场 slug 前缀获取 Binance 交易对;支持完整 slug(如 eth-updown-5m)或前缀(如 eth-updown) */
|
||||||
|
private fun getSymbol(marketSlugPrefix: String): String? {
|
||||||
|
val base = marketSlugPrefix.lowercase().removeSuffix("-15m").removeSuffix("-5m")
|
||||||
|
return marketToSymbol[base]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheKey(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
|
||||||
|
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理已过期的价差缓存,避免内存泄漏 */
|
||||||
|
private fun cleanExpiredCache() {
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val expireThreshold = nowSeconds - cacheExpireSeconds
|
||||||
|
val keysToRemove = cache.keys.filter { key ->
|
||||||
|
// key 格式: marketSlugPrefix-intervalSeconds-periodStartUnix
|
||||||
|
val parts = key.split('-')
|
||||||
|
if (parts.size >= 3) {
|
||||||
|
parts.last().toLongOrNull()?.let { it < expireThreshold } ?: false
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach { cache.remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回该周期、该方向的 100% 基准价差,供调用方按窗口进度应用动态系数。 */
|
||||||
|
fun getAutoMinSpreadBase(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long, outcomeIndex: Int): BigDecimal? {
|
||||||
|
val key = cacheKey(marketSlugPrefix, intervalSeconds, periodStartUnix)
|
||||||
|
val (up, down) = cache[key] ?: run {
|
||||||
|
computeAndCache(marketSlugPrefix, intervalSeconds, periodStartUnix) ?: return null
|
||||||
|
}
|
||||||
|
return if (outcomeIndex == 0) up else down
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算并缓存 100% 基准价差(IQR 平均,不乘系数)。预加载与触发时共用此缓存。 */
|
||||||
|
fun computeAndCache(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||||
|
cleanExpiredCache()
|
||||||
|
val symbol = getSymbol(marketSlugPrefix) ?: run {
|
||||||
|
logger.warn("不支持的市场 slug 前缀: $marketSlugPrefix")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val intervalStr = if (intervalSeconds == 300) "5m" else "15m"
|
||||||
|
val endTimeMs = periodStartUnix * 1000L
|
||||||
|
val klines = fetchKlines(symbol, 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(marketSlugPrefix, intervalSeconds, periodStartUnix)] = baseUp to baseDown
|
||||||
|
logger.info(
|
||||||
|
"加密价差策略自动价差已计算并缓存(100%基准): market=$marketSlugPrefix symbol=$symbol interval=${intervalSeconds}s periodStartUnix=$periodStartUnix | " +
|
||||||
|
"Up方向: 样本数=${spreadsUp.size}, baseSpreadUp=${baseUp.toPlainString()} | " +
|
||||||
|
"Down方向: 样本数=${spreadsDown.size}, baseSpreadDown=${baseDown.toPlainString()}"
|
||||||
|
)
|
||||||
|
return baseUp to baseDown
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchKlines(symbol: String, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
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.AtomicReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 币安 K 线 WebSocket:按需订阅加密价差策略使用的币种 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 by lazy {
|
||||||
|
createClient().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (marketSlugPrefix, intervalSeconds, periodStartUnix) -> (open, close) */
|
||||||
|
private val openCloseByPeriod = ConcurrentHashMap<String, Pair<BigDecimal, BigDecimal>>()
|
||||||
|
|
||||||
|
/** 市场 slug 前缀(如 btc-updown)-> Binance 交易对映射 */
|
||||||
|
private val marketToSymbol = mapOf(
|
||||||
|
"btc-updown" to "BTCUSDC",
|
||||||
|
"eth-updown" to "ETHUSDC",
|
||||||
|
"sol-updown" to "SOLUSDC",
|
||||||
|
"xrp-updown" to "XRPUSDC"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 已连接的 WebSocket: wsKey (symbol-interval) -> WebSocket */
|
||||||
|
private val connectedWebSockets = ConcurrentHashMap<String, WebSocket>()
|
||||||
|
/** 当前需要订阅的完整市场集合(如 btc-updown-5m、btc-updown-15m),由加密价差策略刷新时更新 */
|
||||||
|
private val requiredMarketPrefixes = AtomicReference<Set<String>>(emptySet())
|
||||||
|
private val subscriptionLock = Any()
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
|
||||||
|
/** 解析完整市场 slug(如 btc-updown-5m)为 (basePrefix, interval),不支持则返回 null */
|
||||||
|
private fun parseMarketSlug(full: String): Pair<String, String>? {
|
||||||
|
val lower = full.lowercase()
|
||||||
|
return when {
|
||||||
|
lower.endsWith("-5m") -> Pair(lower.removeSuffix("-5m"), "5m")
|
||||||
|
lower.endsWith("-15m") -> Pair(lower.removeSuffix("-15m"), "15m")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从市场 base 前缀(如 btc-updown)获取 Binance 交易对 */
|
||||||
|
private fun getSymbol(basePrefix: String): String? = marketToSymbol[basePrefix]
|
||||||
|
|
||||||
|
private fun key(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): String {
|
||||||
|
return "$marketSlugPrefix-$intervalSeconds-$periodStartUnix"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentOpenClose(marketSlugPrefix: String, intervalSeconds: Int, periodStartUnix: Long): Pair<BigDecimal, BigDecimal>? {
|
||||||
|
return openCloseByPeriod[key(marketSlugPrefix, intervalSeconds, periodStartUnix)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供 API 健康检查使用:各币种各周期的连接状态 */
|
||||||
|
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||||
|
return connectedWebSockets.keys.associateWith { connectedWebSockets[it] != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按需更新订阅:仅订阅策略用到的 (币种, 周期),例如只开 btc 5min 则只建 btc 5min K 线连接。
|
||||||
|
* 由 CryptoTailOrderbookWsService 在刷新订阅时根据启用策略的 marketSlugPrefix 调用。
|
||||||
|
* @param marketPrefixes 当前启用策略用到的完整市场集合,如 ["btc-updown-5m"] 或 ["btc-updown-5m", "eth-updown-15m"];空集合时关闭所有连接
|
||||||
|
*/
|
||||||
|
fun updateSubscriptions(marketPrefixes: Set<String>) {
|
||||||
|
val normalized = marketPrefixes.map { it.lowercase() }.toSet()
|
||||||
|
|
||||||
|
val parsed = normalized.mapNotNull { full ->
|
||||||
|
parseMarketSlug(full)?.let { (base, interval) ->
|
||||||
|
getSymbol(base)?.let { symbol -> Triple(full, symbol, interval) }
|
||||||
|
}
|
||||||
|
}.toSet()
|
||||||
|
val wsKeysNeeded = parsed.map { (_, symbol, interval) -> "$symbol-$interval" }.toSet()
|
||||||
|
|
||||||
|
// 检查是否有需要的 WebSocket 连接缺失(可能因网络问题断开)
|
||||||
|
val hasMissingConnection = wsKeysNeeded.any { it !in connectedWebSockets.keys }
|
||||||
|
|
||||||
|
// 只有当集合相同且所有需要的连接都存在时才跳过
|
||||||
|
if (normalized == requiredMarketPrefixes.get() && !hasMissingConnection) return
|
||||||
|
requiredMarketPrefixes.set(normalized)
|
||||||
|
synchronized(subscriptionLock) {
|
||||||
|
connectedWebSockets.keys.toList().forEach { wsKey ->
|
||||||
|
if (wsKey !in wsKeysNeeded) {
|
||||||
|
connectedWebSockets.remove(wsKey)?.close(1000, "subscription_update")
|
||||||
|
logger.info("币安 K 线 WS 已关闭(无策略使用): $wsKey")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed.forEach { (fullPrefix, symbol, interval) ->
|
||||||
|
connectStream(symbol, interval, fullPrefix) { marketPrefixParam, intervalSec, tMs, openP, closeP ->
|
||||||
|
val periodSec = tMs / 1000
|
||||||
|
openCloseByPeriod[key(marketPrefixParam, intervalSec, periodSec)] = openP to closeP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectStream(
|
||||||
|
symbol: String,
|
||||||
|
interval: String,
|
||||||
|
marketPrefix: String,
|
||||||
|
onKline: (marketPrefix: String, intervalSeconds: Int, openTimeMs: Long, open: BigDecimal, close: BigDecimal) -> Unit
|
||||||
|
) {
|
||||||
|
val streamName = "${symbol.lowercase()}@kline_$interval"
|
||||||
|
val wsKey = "$symbol-$interval"
|
||||||
|
if (connectedWebSockets[wsKey] != null) return
|
||||||
|
|
||||||
|
val url = "$wsBase/ws/$streamName"
|
||||||
|
val intervalSeconds = when (interval) {
|
||||||
|
"5m" -> 300
|
||||||
|
"15m" -> 900
|
||||||
|
else -> 300
|
||||||
|
}
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
client.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
|
connectedWebSockets[wsKey] = webSocket
|
||||||
|
logger.info("币安 K 线 WS 已连接: $streamName")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
parseKlineMessage(text)?.let { (tMs, o, c) ->
|
||||||
|
onKline(marketPrefix, intervalSeconds, tMs, o, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
|
connectedWebSockets.remove(wsKey)
|
||||||
|
logger.warn("币安 K 线 WS 异常 $streamName: ${t.message}")
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
connectedWebSockets.remove(wsKey)
|
||||||
|
if (code != 1000) scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
connectedWebSockets.remove(wsKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseKlineMessage(text: String): 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
|
||||||
|
val current = requiredMarketPrefixes.get()
|
||||||
|
connectedWebSockets.values.forEach { it.close(1000, "reconnect") }
|
||||||
|
connectedWebSockets.clear()
|
||||||
|
logger.info("币安 K 线 WS 尝试重连")
|
||||||
|
// 清空 requiredMarketPrefixes,否则 updateSubscriptions(current) 内会因 normalized == requiredMarketPrefixes.get() 直接 return,不会重新 connectStream
|
||||||
|
requiredMarketPrefixes.set(emptySet())
|
||||||
|
updateSubscriptions(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
connectedWebSockets.values.forEach { it.close(1000, "shutdown") }
|
||||||
|
connectedWebSockets.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
+338
-26
@@ -7,13 +7,18 @@ import com.wrbug.polymarketbot.api.JsonRpcResponse
|
|||||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||||
import com.wrbug.polymarketbot.api.PositionResponse
|
import com.wrbug.polymarketbot.api.PositionResponse
|
||||||
import com.wrbug.polymarketbot.api.ValueResponse
|
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.EthereumUtils
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import com.wrbug.polymarketbot.service.system.RelayClientService
|
import com.wrbug.polymarketbot.service.system.RelayClientService
|
||||||
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
import com.wrbug.polymarketbot.service.system.RpcNodeService
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
@@ -26,8 +31,6 @@ import java.math.BigInteger
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class BlockchainService(
|
class BlockchainService(
|
||||||
@Value("\${polymarket.data-api.base-url:https://data-api.polymarket.com}")
|
|
||||||
private val dataApiBaseUrl: String,
|
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
private val relayClientService: RelayClientService,
|
private val relayClientService: RelayClientService,
|
||||||
private val rpcNodeService: RpcNodeService,
|
private val rpcNodeService: RpcNodeService,
|
||||||
@@ -52,6 +55,9 @@ class BlockchainService(
|
|||||||
|
|
||||||
// ConditionalTokens 合约地址(Polygon 主网)
|
// ConditionalTokens 合约地址(Polygon 主网)
|
||||||
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
|
||||||
|
|
||||||
|
// Neg Risk WrappedCollateral 合约地址(Polygon,解包后得 USDC.e)
|
||||||
|
private val wcolContractAddress = "0x3A3BD7bb9528E159577F7C2e685CC81A765002E2"
|
||||||
|
|
||||||
// 空集合ID(用于计算collectionId)
|
// 空集合ID(用于计算collectionId)
|
||||||
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
private val EMPTY_SET = "0x0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
@@ -61,10 +67,10 @@ class BlockchainService(
|
|||||||
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
private val computeProxyAddressFunctionSignature = "computeProxyAddress(address)"
|
||||||
|
|
||||||
private val dataApi: PolymarketDataApi by lazy {
|
private val dataApi: PolymarketDataApi by lazy {
|
||||||
val baseUrl = if (dataApiBaseUrl.endsWith("/")) {
|
val baseUrl = if (PolymarketConstants.DATA_API_BASE_URL.endsWith("/")) {
|
||||||
dataApiBaseUrl.dropLast(1)
|
PolymarketConstants.DATA_API_BASE_URL.dropLast(1)
|
||||||
} else {
|
} else {
|
||||||
dataApiBaseUrl
|
PolymarketConstants.DATA_API_BASE_URL
|
||||||
}
|
}
|
||||||
val okHttpClient = createClient()
|
val okHttpClient = createClient()
|
||||||
.followRedirects(true)
|
.followRedirects(true)
|
||||||
@@ -92,13 +98,13 @@ class BlockchainService(
|
|||||||
* 2. Safe Proxy(MetaMask 钱包用户)- 通过合约调用获取地址
|
* 2. Safe Proxy(MetaMask 钱包用户)- 通过合约调用获取地址
|
||||||
*
|
*
|
||||||
* @param walletAddress 用户的钱包地址(EOA)
|
* @param walletAddress 用户的钱包地址(EOA)
|
||||||
* @param walletType 钱包类型:"magic"(默认)或 "safe"
|
* @param walletType 钱包类型:MAGIC(默认)或 SAFE
|
||||||
* @return 代理钱包地址
|
* @return 代理钱包地址
|
||||||
*/
|
*/
|
||||||
suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result<String> {
|
suspend fun getProxyAddress(walletAddress: String, walletType: WalletType = WalletType.MAGIC): Result<String> {
|
||||||
return try {
|
return try {
|
||||||
when (walletType.lowercase()) {
|
when (walletType) {
|
||||||
"safe" -> {
|
WalletType.SAFE -> {
|
||||||
// Safe Proxy(MetaMask 用户)
|
// Safe Proxy(MetaMask 用户)
|
||||||
val safeProxyResult = getSafeProxyAddress(walletAddress)
|
val safeProxyResult = getSafeProxyAddress(walletAddress)
|
||||||
if (safeProxyResult.isSuccess) {
|
if (safeProxyResult.isSuccess) {
|
||||||
@@ -109,7 +115,7 @@ class BlockchainService(
|
|||||||
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
|
Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
WalletType.MAGIC -> {
|
||||||
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
|
// Magic Proxy(邮箱/OAuth 登录用户)- 默认
|
||||||
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
|
val magicProxyAddress = calculateMagicProxyAddress(walletAddress)
|
||||||
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
|
logger.debug("使用 Magic Proxy 地址: $magicProxyAddress")
|
||||||
@@ -241,6 +247,62 @@ class BlockchainService(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查代理钱包是否已部署(链上有合约代码)
|
||||||
|
* @param proxyAddress 代理钱包地址
|
||||||
|
* @return 已部署返回 true
|
||||||
|
*/
|
||||||
|
suspend fun isProxyDeployed(proxyAddress: String): Boolean {
|
||||||
|
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isContract(proxyAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询 ERC20 USDC 授权额度 allowance(owner, spender)
|
||||||
|
* @param owner 代币持有者地址(代理钱包地址)
|
||||||
|
* @param spender 被授权方地址(如 CTF Exchange)
|
||||||
|
* @return 授权额度(原始值,USDC 为 6 位小数,需除以 1e6 为显示值)
|
||||||
|
*/
|
||||||
|
suspend fun getUsdcAllowance(owner: String, spender: String): Result<BigInteger> {
|
||||||
|
return try {
|
||||||
|
if (owner.isBlank() || spender.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("owner 或 spender 不能为空"))
|
||||||
|
}
|
||||||
|
val rpcApi = polygonRpcApi
|
||||||
|
// ERC20 allowance(address owner, address spender) 选择器
|
||||||
|
val functionSelector = "0xdd62ed3e"
|
||||||
|
val ownerEncoded = EthereumUtils.encodeAddress(owner)
|
||||||
|
val spenderEncoded = EthereumUtils.encodeAddress(spender)
|
||||||
|
val data = functionSelector + ownerEncoded + spenderEncoded
|
||||||
|
val rpcRequest = JsonRpcRequest(
|
||||||
|
method = "eth_call",
|
||||||
|
params = listOf(
|
||||||
|
mapOf(
|
||||||
|
"to" to usdcContractAddress,
|
||||||
|
"data" to data
|
||||||
|
),
|
||||||
|
"latest"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val response = rpcApi.call(rpcRequest)
|
||||||
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
|
return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}"))
|
||||||
|
}
|
||||||
|
val rpcResponse = response.body()!!
|
||||||
|
if (rpcResponse.error != null) {
|
||||||
|
return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}"))
|
||||||
|
}
|
||||||
|
val hexResult = rpcResponse.result?.asString ?: return Result.failure(Exception("RPC 响应 result 为空"))
|
||||||
|
val allowance = EthereumUtils.decodeUint256(hexResult)
|
||||||
|
Result.success(allowance)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("查询 USDC 授权额度失败: ${e.message}")
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询账户 USDC 余额
|
* 查询账户 USDC 余额
|
||||||
@@ -257,7 +319,6 @@ class BlockchainService(
|
|||||||
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
return Result.failure(IllegalArgumentException("代理地址不能为空"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
// 使用 RPC 查询 USDC 余额(使用代理地址)
|
||||||
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
val balance = queryUsdcBalanceViaRpc(proxyAddress)
|
||||||
Result.success(balance)
|
Result.success(balance)
|
||||||
@@ -266,6 +327,82 @@ class BlockchainService(
|
|||||||
Result.failure(e)
|
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 余额
|
* 通过 RPC 查询 USDC 余额
|
||||||
@@ -504,45 +641,220 @@ class BlockchainService(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 赎回仓位
|
* 赎回仓位
|
||||||
* 通过代理钱包的 execTransaction 调用 ConditionalTokens 合约的 redeemPositions 函数
|
* Safe 账户通过代理 execTransaction 调用,Magic 账户通过 Builder Relayer PROXY(Gasless)执行
|
||||||
*
|
*
|
||||||
* 使用 RelayClientService 实现,完全参考 TypeScript 项目的实现方式
|
|
||||||
*
|
|
||||||
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||||
* @param proxyAddress 代理地址(Gnosis Safe 代理钱包地址)
|
* @param proxyAddress 代理地址(Safe 或 Magic 代理钱包地址)
|
||||||
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
* @param conditionId 市场条件ID(bytes32,必须是 0x 开头的 66 位十六进制字符串)
|
||||||
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex,例如 [1] 表示 outcome 0,[2] 表示 outcome 1)
|
* @param indexSets 要赎回的索引集合列表(每个元素是 2^outcomeIndex)
|
||||||
|
* @param isNegRisk 是否为 Neg Risk 市场(true 时使用 WrappedCollateral 作为抵押品)
|
||||||
|
* @param walletType 钱包类型:MAGIC 或 SAFE,用于选择执行路径
|
||||||
* @return 交易哈希
|
* @return 交易哈希
|
||||||
*/
|
*/
|
||||||
suspend fun redeemPositions(
|
suspend fun redeemPositions(
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
proxyAddress: String,
|
proxyAddress: String,
|
||||||
conditionId: String,
|
conditionId: String,
|
||||||
indexSets: List<BigInteger>
|
indexSets: List<BigInteger>,
|
||||||
|
isNegRisk: Boolean = false,
|
||||||
|
walletType: WalletType = WalletType.SAFE
|
||||||
): Result<String> {
|
): Result<String> {
|
||||||
return try {
|
return try {
|
||||||
// 验证参数
|
|
||||||
if (indexSets.isEmpty()) {
|
if (indexSets.isEmpty()) {
|
||||||
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
|
return Result.failure(IllegalArgumentException("indexSets 不能为空"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||||
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
|
return Result.failure(IllegalArgumentException("conditionId 格式错误,必须是 0x 开头的 66 位十六进制字符串"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||||
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 RelayClientService 创建赎回交易并执行
|
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
|
||||||
val redeemTx = relayClientService.createRedeemTx(conditionId, indexSets)
|
relayClientService.execute(privateKey, proxyAddress, redeemTx, walletType)
|
||||||
relayClientService.execute(privateKey, proxyAddress, redeemTx)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("赎回仓位失败: ${e.message}", e)
|
logger.error("赎回仓位失败: ${e.message}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量赎回多个市场的仓位(使用 MultiSend 合并为一笔交易)
|
||||||
|
* 仅支持 Safe 钱包类型,Magic 钱包不支持 MultiSend
|
||||||
|
*
|
||||||
|
* @param privateKey 私钥(原始钱包的私钥,用于签名交易)
|
||||||
|
* @param proxyAddress 代理地址(Safe 代理钱包地址)
|
||||||
|
* @param redeemRequests 赎回请求列表,每个元素是 (conditionId, indexSets, isNegRisk)
|
||||||
|
* @param walletType 钱包类型:仅支持 SAFE
|
||||||
|
* @return 交易哈希
|
||||||
|
*/
|
||||||
|
suspend fun redeemPositionsBatch(
|
||||||
|
privateKey: String,
|
||||||
|
proxyAddress: String,
|
||||||
|
redeemRequests: List<Triple<String, List<BigInteger>, Boolean>>,
|
||||||
|
walletType: WalletType = WalletType.SAFE
|
||||||
|
): Result<String> {
|
||||||
|
return try {
|
||||||
|
if (redeemRequests.isEmpty()) {
|
||||||
|
return Result.failure(IllegalArgumentException("redeemRequests 不能为空"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Magic 钱包不支持 MultiSend
|
||||||
|
if (walletType == WalletType.MAGIC) {
|
||||||
|
return Result.failure(IllegalArgumentException("Magic 钱包不支持 MultiSend 批量赎回,请使用逐笔赎回"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyAddress.isBlank() || !proxyAddress.startsWith("0x") || proxyAddress.length != 42) {
|
||||||
|
return Result.failure(IllegalArgumentException("proxyAddress 格式错误,必须是有效的以太坊地址"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有 conditionId 格式
|
||||||
|
for ((conditionId, _, _) in redeemRequests) {
|
||||||
|
if (conditionId.isBlank() || !conditionId.startsWith("0x") || conditionId.length != 66) {
|
||||||
|
return Result.failure(IllegalArgumentException("conditionId 格式错误: $conditionId"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建每个市场的赎回交易(Neg Risk 市场使用 WrappedCollateral)
|
||||||
|
val redeemTxs = redeemRequests.map { (conditionId, indexSets, isNegRisk) ->
|
||||||
|
if (indexSets.isEmpty()) {
|
||||||
|
throw IllegalArgumentException("indexSets 不能为空: $conditionId")
|
||||||
|
}
|
||||||
|
relayClientService.createRedeemTx(conditionId, indexSets, isNegRisk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 MultiSend 合并所有交易
|
||||||
|
val multiSendTx = relayClientService.createMultiSendTx(redeemTxs)
|
||||||
|
|
||||||
|
logger.info("批量赎回: 合并 ${redeemRequests.size} 个市场为一笔交易")
|
||||||
|
|
||||||
|
relayClientService.execute(privateKey, proxyAddress, multiSendTx, walletType)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("批量赎回仓位失败: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询等待交易上链并确认成功
|
||||||
|
* @param txHash 交易 hash(0x 开头)
|
||||||
|
* @param maxWaitMs 最大等待毫秒数
|
||||||
|
* @param pollIntervalMs 轮询间隔毫秒数
|
||||||
|
* @return 成功返回 Unit,超时或 revert 返回 Result.failure
|
||||||
|
*/
|
||||||
|
suspend fun waitForTransactionConfirmed(
|
||||||
|
txHash: String,
|
||||||
|
maxWaitMs: Long = 120_000,
|
||||||
|
pollIntervalMs: Long = 3_000
|
||||||
|
): Result<Unit> {
|
||||||
|
val rpcApi = polygonRpcApi
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
while (System.currentTimeMillis() - start < maxWaitMs) {
|
||||||
|
val req = JsonRpcRequest(method = "eth_getTransactionReceipt", params = listOf(txHash))
|
||||||
|
val response = rpcApi.call(req)
|
||||||
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
|
delay(pollIntervalMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val body = response.body()!!
|
||||||
|
if (body.error != null) {
|
||||||
|
delay(pollIntervalMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val result = body.result
|
||||||
|
if (result == null || result.isJsonNull) {
|
||||||
|
delay(pollIntervalMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val status = result.asJsonObject?.get("status")?.asString
|
||||||
|
if (status == null) {
|
||||||
|
delay(pollIntervalMs)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return when (status) {
|
||||||
|
"0x1" -> Result.success(Unit)
|
||||||
|
"0x0" -> Result.failure(Exception("交易已上链但执行失败 (revert)"))
|
||||||
|
else -> Result.failure(Exception("交易状态异常: $status"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result.failure(Exception("等待交易确认超时 (${maxWaitMs}ms)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询代理地址的 WCOL(Wrapped Collateral)余额(raw,6 位小数)
|
||||||
|
*/
|
||||||
|
suspend fun getWcolBalance(proxyAddress: String): Result<BigInteger> {
|
||||||
|
val rpcApi = polygonRpcApi
|
||||||
|
val functionSelector = "0x70a08231" // balanceOf(address)
|
||||||
|
val paddedAddress = proxyAddress.removePrefix("0x").lowercase().padStart(64, '0')
|
||||||
|
val data = functionSelector + paddedAddress
|
||||||
|
val rpcRequest = JsonRpcRequest(
|
||||||
|
method = "eth_call",
|
||||||
|
params = listOf(
|
||||||
|
mapOf(
|
||||||
|
"to" to wcolContractAddress,
|
||||||
|
"data" to data
|
||||||
|
),
|
||||||
|
"latest"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val response = rpcApi.call(rpcRequest)
|
||||||
|
if (!response.isSuccessful || response.body() == null) {
|
||||||
|
return Result.failure(Exception("查询 WCOL 余额失败: ${response.code()} ${response.message()}"))
|
||||||
|
}
|
||||||
|
val rpcResponse = response.body()!!
|
||||||
|
if (rpcResponse.error != null) {
|
||||||
|
return Result.failure(Exception("查询 WCOL 余额失败: ${rpcResponse.error.message}"))
|
||||||
|
}
|
||||||
|
val hexBalance = rpcResponse.result?.asString ?: return Result.failure(Exception("WCOL 余额结果为空"))
|
||||||
|
val balance = EthereumUtils.decodeUint256(hexBalance)
|
||||||
|
return Result.success(balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将代理钱包内的 WCOL 解包为 USDC.e(解包后转入代理地址)
|
||||||
|
* 赎回 Neg Risk 仓位后到账为 WCOL,调用此方法可转为 USDC.e 以便显示/使用。
|
||||||
|
*
|
||||||
|
* Safe 与 Magic 使用同一套逻辑:同一 [createUnwrapWcolTx] + [RelayClientService.execute];
|
||||||
|
* Safe 走 execTransaction,Magic 走 PROXY 编码,最终均为代理合约调用 WCOL.unwrap(proxyAddress, amount),USDC.e 转入 proxyAddress。
|
||||||
|
*
|
||||||
|
* @param privateKey 主钱包私钥
|
||||||
|
* @param proxyAddress 代理地址(Safe 或 Magic 代理)
|
||||||
|
* @param walletType 钱包类型(SAFE / MAGIC),用于选择 Relayer 执行路径
|
||||||
|
* @return 成功返回交易 hash,余额为 0 返回 null,失败返回 Result.failure
|
||||||
|
*/
|
||||||
|
suspend fun unwrapWcolForProxy(
|
||||||
|
privateKey: String,
|
||||||
|
proxyAddress: String,
|
||||||
|
walletType: WalletType
|
||||||
|
): Result<String?> {
|
||||||
|
return try {
|
||||||
|
val balanceResult = getWcolBalance(proxyAddress)
|
||||||
|
val balance = balanceResult.getOrElse {
|
||||||
|
logger.warn("查询 WCOL 余额失败,跳过解包: ${it.message}")
|
||||||
|
return Result.success(null)
|
||||||
|
}
|
||||||
|
if (balance == BigInteger.ZERO) {
|
||||||
|
return Result.success(null)
|
||||||
|
}
|
||||||
|
val unwrapTx = relayClientService.createUnwrapWcolTx(proxyAddress, balance)
|
||||||
|
val executeResult = relayClientService.execute(privateKey, proxyAddress, unwrapTx, walletType)
|
||||||
|
executeResult.fold(
|
||||||
|
onSuccess = { txHash ->
|
||||||
|
logger.info("WCOL 解包成功: proxy=${proxyAddress.take(10)}..., txHash=$txHash")
|
||||||
|
Result.success(txHash)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
logger.error("WCOL 解包失败: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("WCOL 解包异常: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
* 获取代理钱包的 nonce(用于构建 Safe 交易)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -89,8 +89,6 @@ class MarketPollingService(
|
|||||||
*/
|
*/
|
||||||
private suspend fun checkAndUpdateMissingMarkets() {
|
private suspend fun checkAndUpdateMissingMarkets() {
|
||||||
try {
|
try {
|
||||||
logger.debug("开始检查缺失的市场信息...")
|
|
||||||
|
|
||||||
// 1. 获取所有买入订单的市场ID(去重)
|
// 1. 获取所有买入订单的市场ID(去重)
|
||||||
val allOrders = copyOrderTrackingRepository.findAll()
|
val allOrders = copyOrderTrackingRepository.findAll()
|
||||||
val marketIds = allOrders.map { it.marketId }.distinct()
|
val marketIds = allOrders.map { it.marketId }.distinct()
|
||||||
@@ -99,9 +97,6 @@ class MarketPollingService(
|
|||||||
logger.debug("没有找到任何订单,跳过市场信息检查")
|
logger.debug("没有找到任何订单,跳过市场信息检查")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("找到 ${marketIds.size} 个不同的市场ID")
|
|
||||||
|
|
||||||
// 2. 检查哪些市场信息在数据库中缺失
|
// 2. 检查哪些市场信息在数据库中缺失
|
||||||
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
val existingMarkets = marketService.marketRepository.findByMarketIdIn(marketIds)
|
||||||
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
val existingMarketIds = existingMarkets.map { it.marketId }.toSet()
|
||||||
@@ -113,7 +108,6 @@ class MarketPollingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (validMissingMarketIds.isEmpty()) {
|
if (validMissingMarketIds.isEmpty()) {
|
||||||
logger.debug("所有市场信息都已存在,无需更新")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-3
@@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.BigInteger
|
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)
|
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
|
* - payout == 0(输了)→ 返回 0.0
|
||||||
* 如果市场未结算或查询失败,返回 null
|
* 如果市场未结算或查询失败,返回 null
|
||||||
*
|
*
|
||||||
|
* 使用缓存优化:已结算的市场结果会被缓存,避免重复 RPC 调用
|
||||||
|
*
|
||||||
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted)
|
* @return Pair<BigDecimal?, Boolean> 第一个值是价格(如果已结算),第二个值表示是否发生了 RPC 错误(execution reverted)
|
||||||
*/
|
*/
|
||||||
private suspend fun getPriceFromChainCondition(marketId: String, outcomeIndex: Int): Pair<BigDecimal?, Boolean> {
|
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 {
|
return try {
|
||||||
val chainResult = blockchainService.getCondition(marketId)
|
val chainResult = blockchainService.getCondition(marketId)
|
||||||
chainResult.fold(
|
chainResult.fold(
|
||||||
@@ -96,11 +124,17 @@ class MarketPriceService(
|
|||||||
when {
|
when {
|
||||||
payout > BigInteger.ZERO -> {
|
payout > BigInteger.ZERO -> {
|
||||||
logger.info("从链上查询到市场已结算,该 outcome 赢了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
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 -> {
|
payout == BigInteger.ZERO -> {
|
||||||
logger.info("从链上查询到市场已结算,该 outcome 输了: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
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 -> {
|
else -> {
|
||||||
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
logger.warn("从链上查询到异常的 payout 值: marketId=$marketId, outcomeIndex=$outcomeIndex, payout=$payout")
|
||||||
@@ -109,7 +143,7 @@ class MarketPriceService(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
logger.debug("从链上查询到市场尚未结算: marketId=$marketId, payouts=${payouts.size}")
|
||||||
Pair(null, false)
|
Pair(null, false) // 未结算的市场不缓存
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
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.repository.MarketRepository
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.getEventSlug
|
import com.wrbug.polymarketbot.util.getEventSlug
|
||||||
|
import com.wrbug.polymarketbot.util.parseStringArray
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
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
|
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
|
||||||
|
)
|
||||||
|
|||||||
+23
-33
@@ -3,12 +3,12 @@ package com.wrbug.polymarketbot.service.common
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
import com.wrbug.polymarketbot.api.ApiKeyResponse
|
||||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
import com.wrbug.polymarketbot.util.PolymarketL1AuthInterceptor
|
||||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
import com.wrbug.polymarketbot.util.createClient
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
@@ -19,8 +19,6 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class PolymarketApiKeyService(
|
class PolymarketApiKeyService(
|
||||||
@Value("\${polymarket.clob.base-url}")
|
|
||||||
private val clobBaseUrl: String,
|
|
||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -100,6 +98,24 @@ class PolymarketApiKeyService(
|
|||||||
creds.passphrase.isNotBlank()
|
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
|
* 创建新的 API Key
|
||||||
*/
|
*/
|
||||||
@@ -109,20 +125,7 @@ class PolymarketApiKeyService(
|
|||||||
chainId: Long
|
chainId: Long
|
||||||
): Result<ApiKeyCreds> {
|
): Result<ApiKeyCreds> {
|
||||||
return try {
|
return try {
|
||||||
// 获取服务器时间(可选,用于更准确的时间戳)
|
val serverTime = fetchServerTimeOrNull()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建带 L1 认证的 API 客户端
|
// 创建带 L1 认证的 API 客户端
|
||||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||||
|
|
||||||
@@ -160,20 +163,7 @@ class PolymarketApiKeyService(
|
|||||||
chainId: Long
|
chainId: Long
|
||||||
): Result<ApiKeyCreds> {
|
): Result<ApiKeyCreds> {
|
||||||
return try {
|
return try {
|
||||||
// 获取服务器时间(可选)
|
val serverTime = fetchServerTimeOrNull()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建带 L1 认证的 API 客户端
|
// 创建带 L1 认证的 API 客户端
|
||||||
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
val api = createL1AuthenticatedApi(privateKey, walletAddress, chainId, serverTime)
|
||||||
|
|
||||||
@@ -224,7 +214,7 @@ class PolymarketApiKeyService(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
@@ -238,7 +228,7 @@ class PolymarketApiKeyService(
|
|||||||
val okHttpClient = createClient().build()
|
val okHttpClient = createClient().build()
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(clobBaseUrl)
|
.baseUrl(PolymarketConstants.CLOB_BASE_URL)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
+87
-10
@@ -1,11 +1,13 @@
|
|||||||
package com.wrbug.polymarketbot.service.common
|
package com.wrbug.polymarketbot.service.common
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
|
||||||
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
import com.wrbug.polymarketbot.dto.OrderPushMessage
|
||||||
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
import com.wrbug.polymarketbot.dto.PositionPushMessage
|
||||||
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
import com.wrbug.polymarketbot.dto.WebSocketMessage as WsMessage
|
||||||
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
import com.wrbug.polymarketbot.dto.WebSocketMessageType
|
||||||
import com.wrbug.polymarketbot.service.accounts.PositionPushService
|
import com.wrbug.polymarketbot.service.accounts.PositionPushService
|
||||||
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
import com.wrbug.polymarketbot.service.copytrading.orders.OrderPushService
|
||||||
|
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -38,28 +40,47 @@ class WebSocketSubscriptionService(
|
|||||||
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
|
// 存储 order 频道的订阅回调:sessionId -> callback(用于取消订阅)
|
||||||
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
|
private val orderChannelCallbacks = ConcurrentHashMap<String, (OrderPushMessage) -> Unit>()
|
||||||
|
|
||||||
|
// 存储加密价差策略监控频道的订阅回调:sessionId -> (strategyId -> callback)
|
||||||
|
private val monitorChannelCallbacks = ConcurrentHashMap<String, MutableMap<Long, (CryptoTailMonitorPushData) -> Unit>>()
|
||||||
|
|
||||||
|
// 加密价差策略监控服务(延迟注入,避免循环依赖)
|
||||||
|
private var cryptoTailMonitorService: CryptoTailMonitorService? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置加密价差策略监控服务(由 Spring 在初始化后调用)
|
||||||
|
*/
|
||||||
|
fun setCryptoTailMonitorService(service: CryptoTailMonitorService) {
|
||||||
|
cryptoTailMonitorService = service
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册会话
|
* 注册会话
|
||||||
*/
|
*/
|
||||||
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
|
fun registerSession(sessionId: String, callback: (WsMessage) -> Unit) {
|
||||||
sessionCallbacks[sessionId] = callback
|
sessionCallbacks[sessionId] = callback
|
||||||
sessionSubscriptions[sessionId] = mutableSetOf()
|
sessionSubscriptions[sessionId] = mutableSetOf()
|
||||||
|
monitorChannelCallbacks[sessionId] = mutableMapOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注销会话
|
* 注销会话
|
||||||
*/
|
*/
|
||||||
fun unregisterSession(sessionId: String) {
|
fun unregisterSession(sessionId: String) {
|
||||||
|
|
||||||
// 取消所有订阅
|
// 取消所有订阅
|
||||||
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
|
val channels = sessionSubscriptions.remove(sessionId) ?: emptySet()
|
||||||
channels.forEach { channel ->
|
channels.forEach { channel ->
|
||||||
unsubscribe(sessionId, channel)
|
unsubscribe(sessionId, channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理 order 频道的回调
|
// 清理 order 频道的回调
|
||||||
orderChannelCallbacks.remove(sessionId)
|
orderChannelCallbacks.remove(sessionId)
|
||||||
|
|
||||||
|
// 清理加密价差策略监控频道的回调
|
||||||
|
val monitorCallbacks = monitorChannelCallbacks.remove(sessionId)
|
||||||
|
monitorCallbacks?.keys?.forEach { strategyId ->
|
||||||
|
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
|
||||||
|
}
|
||||||
|
|
||||||
sessionCallbacks.remove(sessionId)
|
sessionCallbacks.remove(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +104,8 @@ class WebSocketSubscriptionService(
|
|||||||
sendSubscribeAck(sessionId, channel, true)
|
sendSubscribeAck(sessionId, channel, true)
|
||||||
|
|
||||||
// 根据频道类型启动推送服务
|
// 根据频道类型启动推送服务
|
||||||
when (channel) {
|
when {
|
||||||
"position" -> {
|
channel == "position" -> {
|
||||||
positionPushService.subscribe(sessionId) { message ->
|
positionPushService.subscribe(sessionId) { message ->
|
||||||
pushData(sessionId, channel, message)
|
pushData(sessionId, channel, message)
|
||||||
}
|
}
|
||||||
@@ -97,7 +118,7 @@ class WebSocketSubscriptionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"order" -> {
|
channel == "order" -> {
|
||||||
// 订单推送:自动订阅所有启用的账户
|
// 订单推送:自动订阅所有启用的账户
|
||||||
val callback: (OrderPushMessage) -> Unit = { message ->
|
val callback: (OrderPushMessage) -> Unit = { message ->
|
||||||
pushData(sessionId, channel, message)
|
pushData(sessionId, channel, message)
|
||||||
@@ -105,6 +126,20 @@ class WebSocketSubscriptionService(
|
|||||||
orderChannelCallbacks[sessionId] = callback
|
orderChannelCallbacks[sessionId] = callback
|
||||||
orderPushService.subscribeAllEnabled(callback)
|
orderPushService.subscribeAllEnabled(callback)
|
||||||
}
|
}
|
||||||
|
channel.startsWith("crypto_tail_monitor_") -> {
|
||||||
|
// 加密价差策略监控频道
|
||||||
|
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
|
||||||
|
if (strategyId != null && cryptoTailMonitorService != null) {
|
||||||
|
val callback: (CryptoTailMonitorPushData) -> Unit = { message ->
|
||||||
|
pushData(sessionId, channel, message)
|
||||||
|
}
|
||||||
|
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
|
||||||
|
cryptoTailMonitorService!!.subscribe(sessionId, strategyId, callback)
|
||||||
|
} else {
|
||||||
|
logger.warn("无效的加密价差策略监控频道或服务未初始化: $channel")
|
||||||
|
sendSubscribeAck(sessionId, channel, false, "无效的策略ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("未知的频道: $channel")
|
logger.warn("未知的频道: $channel")
|
||||||
sendSubscribeAck(sessionId, channel, false, "未知的频道")
|
sendSubscribeAck(sessionId, channel, false, "未知的频道")
|
||||||
@@ -122,15 +157,58 @@ class WebSocketSubscriptionService(
|
|||||||
channelSubscriptions[channel]?.remove(sessionId)
|
channelSubscriptions[channel]?.remove(sessionId)
|
||||||
|
|
||||||
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
|
// 取消推送服务的订阅(推送服务内部会处理是否停止轮询)
|
||||||
when (channel) {
|
when {
|
||||||
"position" -> positionPushService.unsubscribe(sessionId)
|
channel == "position" -> positionPushService.unsubscribe(sessionId)
|
||||||
"order" -> {
|
channel == "order" -> {
|
||||||
// 取消订阅所有账户的订单推送
|
// 取消订阅所有账户的订单推送
|
||||||
val callback = orderChannelCallbacks.remove(sessionId)
|
val callback = orderChannelCallbacks.remove(sessionId)
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
orderPushService.unsubscribeAll(callback)
|
orderPushService.unsubscribeAll(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
channel.startsWith("crypto_tail_monitor_") -> {
|
||||||
|
// 取消加密价差策略监控订阅
|
||||||
|
val strategyId = channel.removePrefix("crypto_tail_monitor_").toLongOrNull()
|
||||||
|
if (strategyId != null) {
|
||||||
|
monitorChannelCallbacks[sessionId]?.remove(strategyId)
|
||||||
|
cryptoTailMonitorService?.unsubscribe(sessionId, strategyId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册加密价差策略监控回调(由 CryptoTailMonitorService 调用)
|
||||||
|
*/
|
||||||
|
fun registerMonitorCallback(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) {
|
||||||
|
monitorChannelCallbacks.getOrPut(sessionId) { mutableMapOf() }[strategyId] = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销加密价差策略监控回调(由 CryptoTailMonitorService 调用)
|
||||||
|
*/
|
||||||
|
fun unregisterMonitorCallback(sessionId: String, strategyId: Long) {
|
||||||
|
monitorChannelCallbacks[sessionId]?.remove(strategyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送加密价差策略监控数据(由 CryptoTailMonitorService 调用)
|
||||||
|
*/
|
||||||
|
fun pushMonitorData(strategyId: Long, data: CryptoTailMonitorPushData) {
|
||||||
|
val channel = "crypto_tail_monitor_$strategyId"
|
||||||
|
val sessionIds = channelSubscriptions[channel] ?: return
|
||||||
|
|
||||||
|
for (sessionId in sessionIds) {
|
||||||
|
val callback = sessionCallbacks[sessionId]
|
||||||
|
if (callback != null) {
|
||||||
|
val message = WsMessage(
|
||||||
|
type = WebSocketMessageType.DATA.value,
|
||||||
|
channel = channel,
|
||||||
|
payload = data,
|
||||||
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
callback(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,4 +246,3 @@ class WebSocketSubscriptionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
-58
@@ -7,6 +7,7 @@ import com.wrbug.polymarketbot.util.lt
|
|||||||
import com.wrbug.polymarketbot.util.multi
|
import com.wrbug.polymarketbot.util.multi
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import com.wrbug.polymarketbot.util.JsonUtils
|
import com.wrbug.polymarketbot.util.JsonUtils
|
||||||
|
import com.wrbug.polymarketbot.util.DateUtils
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||||
@@ -45,7 +46,8 @@ class CopyTradingFilterService(
|
|||||||
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
copyOrderAmount: BigDecimal? = null, // 跟单金额(USDC),用于仓位检查
|
||||||
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
marketId: String? = null, // 市场ID,用于仓位检查(按市场过滤仓位)
|
||||||
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
marketTitle: String? = null, // 市场标题,用于关键字过滤
|
||||||
marketEndDate: Long? = null // 市场截止时间,用于市场截止时间检查
|
marketEndDate: Long? = null, // 市场截止时间,用于市场截止时间检查
|
||||||
|
outcomeIndex: Int? = null // 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
// 1. 关键字过滤检查(如果配置了关键字过滤)
|
||||||
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
if (copyTrading.keywordFilterMode != null && copyTrading.keywordFilterMode != "DISABLED") {
|
||||||
@@ -71,12 +73,20 @@ class CopyTradingFilterService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 检查是否需要获取订单簿
|
// 3. 检查是否需要获取订单簿或需要执行仓位检查
|
||||||
// 只有在配置了需要订单簿的过滤条件时才获取
|
// 只有在配置了需要订单簿的过滤条件时才获取订单簿
|
||||||
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
val needOrderbook = copyTrading.maxSpread != null || copyTrading.minOrderDepth != null
|
||||||
|
|
||||||
|
// 3.5. 如果不需要订单簿,则跳过订单簿相关的检查,但仍然需要检查仓位限制
|
||||||
if (!needOrderbook) {
|
if (!needOrderbook) {
|
||||||
// 不需要订单簿,直接通过
|
// 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||||
|
if (copyOrderAmount != null && marketId != null) {
|
||||||
|
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||||
|
if (!positionCheck.isPassed) {
|
||||||
|
return positionCheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 通过所有检查
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +118,7 @@ class CopyTradingFilterService(
|
|||||||
|
|
||||||
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
// 7. 仓位检查(如果配置了最大仓位限制且提供了跟单金额和市场ID)
|
||||||
if (copyOrderAmount != null && marketId != null) {
|
if (copyOrderAmount != null && marketId != null) {
|
||||||
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId)
|
val positionCheck = checkPositionLimits(copyTrading, copyOrderAmount, marketId, outcomeIndex)
|
||||||
if (!positionCheck.isPassed) {
|
if (!positionCheck.isPassed) {
|
||||||
return positionCheck
|
return positionCheck
|
||||||
}
|
}
|
||||||
@@ -193,12 +203,16 @@ class CopyTradingFilterService(
|
|||||||
|
|
||||||
// 检查最低价格
|
// 检查最低价格
|
||||||
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
|
if (copyTrading.minPrice != null && tradePrice.lt(copyTrading.minPrice)) {
|
||||||
return FilterResult.priceRangeFailed("价格低于最低限制: $tradePrice < ${copyTrading.minPrice}")
|
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
|
||||||
|
val minPriceStr = copyTrading.minPrice.stripTrailingZeros().toPlainString()
|
||||||
|
return FilterResult.priceRangeFailed("价格低于最低限制: $priceStr < $minPriceStr")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最高价格
|
// 检查最高价格
|
||||||
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
|
if (copyTrading.maxPrice != null && tradePrice.gt(copyTrading.maxPrice)) {
|
||||||
return FilterResult.priceRangeFailed("价格高于最高限制: $tradePrice > ${copyTrading.maxPrice}")
|
val priceStr = tradePrice.stripTrailingZeros().toPlainString()
|
||||||
|
val maxPriceStr = copyTrading.maxPrice.stripTrailingZeros().toPlainString()
|
||||||
|
return FilterResult.priceRangeFailed("价格高于最高限制: $priceStr > $maxPriceStr")
|
||||||
}
|
}
|
||||||
|
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
@@ -236,7 +250,9 @@ class CopyTradingFilterService(
|
|||||||
val spread = bestAsk.subtract(bestBid)
|
val spread = bestAsk.subtract(bestBid)
|
||||||
|
|
||||||
if (spread.gt(copyTrading.maxSpread)) {
|
if (spread.gt(copyTrading.maxSpread)) {
|
||||||
return FilterResult.spreadFailed("价差过大: $spread > ${copyTrading.maxSpread}", orderbook)
|
val spreadStr = spread.stripTrailingZeros().toPlainString()
|
||||||
|
val maxSpreadStr = copyTrading.maxSpread.stripTrailingZeros().toPlainString()
|
||||||
|
return FilterResult.spreadFailed("价差过大: $spreadStr > $maxSpreadStr", orderbook)
|
||||||
}
|
}
|
||||||
|
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
@@ -276,94 +292,86 @@ class CopyTradingFilterService(
|
|||||||
val totalDepth = bidsDepth.add(asksDepth)
|
val totalDepth = bidsDepth.add(asksDepth)
|
||||||
|
|
||||||
if (totalDepth.lt(copyTrading.minOrderDepth)) {
|
if (totalDepth.lt(copyTrading.minOrderDepth)) {
|
||||||
return FilterResult.orderDepthFailed("订单深度不足: $totalDepth < ${copyTrading.minOrderDepth}", orderbook)
|
val totalDepthStr = totalDepth.stripTrailingZeros().toPlainString()
|
||||||
|
val minDepthStr = copyTrading.minOrderDepth.stripTrailingZeros().toPlainString()
|
||||||
|
return FilterResult.orderDepthFailed("订单深度不足: $totalDepthStr < $minDepthStr", orderbook)
|
||||||
}
|
}
|
||||||
|
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查仓位限制(按市场检查)
|
* 检查仓位限制(按市场+方向检查)
|
||||||
* @param copyTrading 跟单配置
|
* @param copyTrading 跟单配置
|
||||||
* @param copyOrderAmount 跟单金额(USDC)
|
* @param copyOrderAmount 跟单金额(USDC)
|
||||||
* @param marketId 市场ID,用于过滤该市场的仓位
|
* @param marketId 市场ID,用于过滤该市场的仓位
|
||||||
|
* @param outcomeIndex 方向索引(0, 1, 2, ...),用于按市场+方向检查仓位
|
||||||
* @return 过滤结果
|
* @return 过滤结果
|
||||||
*/
|
*/
|
||||||
private suspend fun checkPositionLimits(
|
private suspend fun checkPositionLimits(
|
||||||
copyTrading: CopyTrading,
|
copyTrading: CopyTrading,
|
||||||
copyOrderAmount: BigDecimal,
|
copyOrderAmount: BigDecimal,
|
||||||
marketId: String
|
marketId: String,
|
||||||
|
outcomeIndex: Int?
|
||||||
): FilterResult {
|
): FilterResult {
|
||||||
// 如果未配置仓位限制,直接通过
|
// 如果未配置仓位限制,直接通过
|
||||||
if (copyTrading.maxPositionValue == null && copyTrading.maxPositionCount == null) {
|
if (copyTrading.maxPositionValue == null) {
|
||||||
return FilterResult.passed()
|
return FilterResult.passed()
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取账户的所有仓位信息
|
// 获取账户的所有仓位信息
|
||||||
val positionsResult = accountService.getAllPositions()
|
val positionsResult = accountService.getAllPositions()
|
||||||
if (positionsResult.isFailure) {
|
if (positionsResult.isFailure) {
|
||||||
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, error=${positionsResult.exceptionOrNull()?.message}")
|
logger.warn("获取仓位信息失败,跳过仓位检查: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${positionsResult.exceptionOrNull()?.message}")
|
||||||
// 如果获取仓位失败,为了安全起见,不通过检查
|
// 如果获取仓位失败,为了安全起见,不通过检查
|
||||||
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
return FilterResult.maxPositionValueFailed("获取仓位信息失败,无法进行仓位检查")
|
||||||
}
|
}
|
||||||
|
|
||||||
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
val positions = positionsResult.getOrNull() ?: return FilterResult.maxPositionValueFailed("仓位信息为空")
|
||||||
|
|
||||||
// 过滤出当前账户且该市场的仓位
|
// 过滤出当前账户且该市场的仓位
|
||||||
val marketPositions = positions.currentPositions.filter {
|
val marketPositions = positions.currentPositions.filter {
|
||||||
it.accountId == copyTrading.accountId && it.marketId == marketId
|
it.accountId == copyTrading.accountId && it.marketId == marketId
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最大仓位金额(如果配置了)
|
// 检查最大仓位金额(如果配置了)
|
||||||
if (copyTrading.maxPositionValue != null) {
|
if (copyTrading.maxPositionValue != null && outcomeIndex != null) {
|
||||||
// 比较数据库成本价(本地订单记录)和外部持仓市值(可能来自其他终端的操作),取最大值
|
// 按市场+方向(outcomeIndex)分别计算数据库成本价
|
||||||
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarket(copyTrading.id!!, marketId) ?: BigDecimal.ZERO
|
val dbValue = copyOrderTrackingRepository.sumCurrentPositionValueByMarketAndOutcomeIndex(
|
||||||
val extValue = marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
copyTrading.id!!, marketId, outcomeIndex
|
||||||
|
) ?: BigDecimal.ZERO
|
||||||
|
|
||||||
|
// 外部持仓也需要按方向过滤,但由于外部持仓可能没有 outcomeIndex 信息,这里保守处理:
|
||||||
|
// 如果外部持仓存在,取该市场的所有外部持仓市值(与数据库取最大值)
|
||||||
|
val extValue = if (marketPositions.isNotEmpty()) {
|
||||||
|
marketPositions.sumOf { it.currentValue.toSafeBigDecimal() }
|
||||||
|
} else {
|
||||||
|
BigDecimal.ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取数据库值和外部持仓值的最大值
|
||||||
val currentPositionValue = dbValue.max(extValue)
|
val currentPositionValue = dbValue.max(extValue)
|
||||||
|
|
||||||
// 检查:该市场的当前仓位 + 跟单金额 <= 最大仓位金额
|
// 检查:该市场该方向的当前仓位 + 跟单金额 <= 最大仓位金额
|
||||||
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
val totalValueAfterOrder = currentPositionValue.add(copyOrderAmount)
|
||||||
|
|
||||||
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
if (totalValueAfterOrder.gt(copyTrading.maxPositionValue)) {
|
||||||
|
val currentValueStr = currentPositionValue.stripTrailingZeros().toPlainString()
|
||||||
|
val dbValueStr = dbValue.stripTrailingZeros().toPlainString()
|
||||||
|
val extValueStr = extValue.stripTrailingZeros().toPlainString()
|
||||||
|
val orderAmountStr = copyOrderAmount.stripTrailingZeros().toPlainString()
|
||||||
|
val totalValueStr = totalValueAfterOrder.stripTrailingZeros().toPlainString()
|
||||||
|
val maxValueStr = copyTrading.maxPositionValue.stripTrailingZeros().toPlainString()
|
||||||
return FilterResult.maxPositionValueFailed(
|
return FilterResult.maxPositionValueFailed(
|
||||||
"超过最大仓位金额限制: 当前该市场仓位(取最大值)=${currentPositionValue} USDC (DB=${dbValue}, Ext=${extValue}), 跟单金额=${copyOrderAmount} USDC, 总计=${totalValueAfterOrder} USDC > 最大限制=${copyTrading.maxPositionValue} USDC"
|
"超过最大仓位金额限制: 市场=$marketId, 方向=$outcomeIndex, 当前仓位(取最大值)=${currentValueStr} USDC (DB=${dbValueStr}, Ext=${extValueStr}), 跟单金额=${orderAmountStr} USDC, 总计=${totalValueStr} USDC > 最大限制=${maxValueStr} USDC"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查最大仓位数量(如果配置了)
|
|
||||||
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 = copyOrderTrackingRepository.existsByCopyTradingIdAndMarketIdAndRemainingQuantityGreaterThan(
|
|
||||||
copyTrading.id, marketId, BigDecimal.ZERO
|
|
||||||
)
|
|
||||||
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()
|
return FilterResult.passed()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, error=${e.message}", e)
|
logger.error("仓位检查异常: accountId=${copyTrading.accountId}, marketId=$marketId, outcomeIndex=$outcomeIndex, error=${e.message}", e)
|
||||||
// 如果检查异常,为了安全起见,不通过检查
|
// 如果检查异常,为了安全起见,不通过检查
|
||||||
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
return FilterResult.maxPositionValueFailed("仓位检查异常: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -394,8 +402,10 @@ class CopyTradingFilterService(
|
|||||||
val remainingTime = marketEndDate - currentTime
|
val remainingTime = marketEndDate - currentTime
|
||||||
|
|
||||||
if (remainingTime > copyTrading.maxMarketEndDate) {
|
if (remainingTime > copyTrading.maxMarketEndDate) {
|
||||||
|
val remainingTimeFormatted = DateUtils.formatDuration(remainingTime)
|
||||||
|
val maxLimitFormatted = DateUtils.formatDuration(copyTrading.maxMarketEndDate)
|
||||||
return FilterResult.marketEndDateFailed(
|
return FilterResult.marketEndDateFailed(
|
||||||
"市场截止时间超出限制: 剩余时间=${remainingTime}ms (${remainingTime / (1000 * 60 * 60)}小时) > 最大限制=${copyTrading.maxMarketEndDate}ms (${copyTrading.maxMarketEndDate / (1000 * 60 * 60)}小时)"
|
"市场截止时间超出限制: 剩余时间=${remainingTimeFormatted} > 最大限制=${maxLimitFormatted}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+91
-19
@@ -10,9 +10,12 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
|||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
|
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
|
import com.wrbug.polymarketbot.util.IllegalBigDecimal
|
||||||
import com.wrbug.polymarketbot.util.JsonUtils
|
import com.wrbug.polymarketbot.util.JsonUtils
|
||||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.ApplicationContext
|
||||||
|
import org.springframework.context.ApplicationContextAware
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -29,10 +32,24 @@ class CopyTradingService(
|
|||||||
private val monitorService: CopyTradingMonitorService,
|
private val monitorService: CopyTradingMonitorService,
|
||||||
private val jsonUtils: JsonUtils,
|
private val jsonUtils: JsonUtils,
|
||||||
private val gson: Gson
|
private val gson: Gson
|
||||||
) {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyTradingService::class.java)
|
||||||
|
|
||||||
|
private var applicationContext: ApplicationContext? = null
|
||||||
|
|
||||||
|
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||||
|
this.applicationContext = applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取代理对象,用于解决 @Transactional 自调用问题
|
||||||
|
*/
|
||||||
|
private fun getSelf(): CopyTradingService {
|
||||||
|
return applicationContext?.getBean(CopyTradingService::class.java)
|
||||||
|
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建跟单配置
|
* 创建跟单配置
|
||||||
* 支持两种方式:
|
* 支持两种方式:
|
||||||
@@ -83,10 +100,10 @@ class CopyTradingService(
|
|||||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||||
maxPositionCount = request.maxPositionCount,
|
|
||||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||||
keywords = convertKeywordsToJson(request.keywords),
|
keywords = convertKeywordsToJson(request.keywords),
|
||||||
maxMarketEndDate = request.maxMarketEndDate
|
maxMarketEndDate = request.maxMarketEndDate,
|
||||||
|
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 手动输入(所有字段必须提供)
|
// 手动输入(所有字段必须提供)
|
||||||
@@ -114,10 +131,10 @@ class CopyTradingService(
|
|||||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal(),
|
||||||
maxPositionCount = request.maxPositionCount,
|
|
||||||
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
keywordFilterMode = request.keywordFilterMode ?: "DISABLED",
|
||||||
keywords = convertKeywordsToJson(request.keywords),
|
keywords = convertKeywordsToJson(request.keywords),
|
||||||
maxMarketEndDate = request.maxMarketEndDate
|
maxMarketEndDate = request.maxMarketEndDate,
|
||||||
|
pushFilteredOrders = request.pushFilteredOrders ?: false // 手动输入时使用请求中的值,默认为 false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,12 +162,12 @@ class CopyTradingService(
|
|||||||
minPrice = config.minPrice,
|
minPrice = config.minPrice,
|
||||||
maxPrice = config.maxPrice,
|
maxPrice = config.maxPrice,
|
||||||
maxPositionValue = config.maxPositionValue,
|
maxPositionValue = config.maxPositionValue,
|
||||||
maxPositionCount = config.maxPositionCount,
|
|
||||||
keywordFilterMode = config.keywordFilterMode,
|
keywordFilterMode = config.keywordFilterMode,
|
||||||
keywords = config.keywords,
|
keywords = config.keywords,
|
||||||
configName = configName,
|
configName = configName,
|
||||||
pushFailedOrders = request.pushFailedOrders ?: false,
|
pushFailedOrders = request.pushFailedOrders ?: false,
|
||||||
maxMarketEndDate = config.maxMarketEndDate
|
maxMarketEndDate = config.maxMarketEndDate,
|
||||||
|
pushFilteredOrders = config.pushFilteredOrders
|
||||||
)
|
)
|
||||||
|
|
||||||
val saved = copyTradingRepository.save(copyTrading)
|
val saved = copyTradingRepository.save(copyTrading)
|
||||||
@@ -211,12 +228,57 @@ class CopyTradingService(
|
|||||||
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
|
websocketReconnectInterval = request.websocketReconnectInterval ?: copyTrading.websocketReconnectInterval,
|
||||||
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
|
websocketMaxRetries = request.websocketMaxRetries ?: copyTrading.websocketMaxRetries,
|
||||||
supportSell = request.supportSell ?: copyTrading.supportSell,
|
supportSell = request.supportSell ?: copyTrading.supportSell,
|
||||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: copyTrading.minOrderDepth,
|
// 处理可选字段:空字符串表示要清空(设置为 null),null 表示不更新,转换失败保留旧值
|
||||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: copyTrading.maxSpread,
|
minOrderDepth = if (request.minOrderDepth != null) {
|
||||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: copyTrading.minPrice,
|
if (request.minOrderDepth.isBlank()) {
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: copyTrading.maxPrice,
|
null
|
||||||
maxPositionValue = request.maxPositionValue?.toSafeBigDecimal() ?: copyTrading.maxPositionValue,
|
} else {
|
||||||
maxPositionCount = request.maxPositionCount ?: copyTrading.maxPositionCount,
|
val converted = request.minOrderDepth.toSafeBigDecimal()
|
||||||
|
if (converted == IllegalBigDecimal) copyTrading.minOrderDepth else converted
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copyTrading.minOrderDepth
|
||||||
|
},
|
||||||
|
maxSpread = if (request.maxSpread != null) {
|
||||||
|
if (request.maxSpread.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val converted = request.maxSpread.toSafeBigDecimal()
|
||||||
|
if (converted == IllegalBigDecimal) copyTrading.maxSpread else converted
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copyTrading.maxSpread
|
||||||
|
},
|
||||||
|
minPrice = if (request.minPrice != null) {
|
||||||
|
if (request.minPrice.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val converted = request.minPrice.toSafeBigDecimal()
|
||||||
|
if (converted == IllegalBigDecimal) copyTrading.minPrice else converted
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copyTrading.minPrice
|
||||||
|
},
|
||||||
|
maxPrice = if (request.maxPrice != null) {
|
||||||
|
if (request.maxPrice.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val converted = request.maxPrice.toSafeBigDecimal()
|
||||||
|
if (converted == IllegalBigDecimal) copyTrading.maxPrice else converted
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copyTrading.maxPrice
|
||||||
|
},
|
||||||
|
maxPositionValue = if (request.maxPositionValue != null) {
|
||||||
|
if (request.maxPositionValue.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val converted = request.maxPositionValue.toSafeBigDecimal()
|
||||||
|
if (converted == IllegalBigDecimal) copyTrading.maxPositionValue else converted
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copyTrading.maxPositionValue
|
||||||
|
},
|
||||||
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
|
keywordFilterMode = request.keywordFilterMode ?: copyTrading.keywordFilterMode,
|
||||||
keywords = if (request.keywords != null) {
|
keywords = if (request.keywords != null) {
|
||||||
convertKeywordsToJson(request.keywords)
|
convertKeywordsToJson(request.keywords)
|
||||||
@@ -227,7 +289,17 @@ class CopyTradingService(
|
|||||||
},
|
},
|
||||||
configName = configName,
|
configName = configName,
|
||||||
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
|
pushFailedOrders = request.pushFailedOrders ?: copyTrading.pushFailedOrders,
|
||||||
maxMarketEndDate = request.maxMarketEndDate ?: copyTrading.maxMarketEndDate,
|
pushFilteredOrders = request.pushFilteredOrders ?: copyTrading.pushFilteredOrders,
|
||||||
|
// 处理 maxMarketEndDate:-1 表示要清空(设置为 null),null 表示不更新
|
||||||
|
maxMarketEndDate = if (request.maxMarketEndDate != null) {
|
||||||
|
if (request.maxMarketEndDate == -1L) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
request.maxMarketEndDate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copyTrading.maxMarketEndDate
|
||||||
|
},
|
||||||
updatedAt = System.currentTimeMillis()
|
updatedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,7 +334,7 @@ class CopyTradingService(
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
fun updateCopyTradingStatus(request: CopyTradingUpdateStatusRequest): Result<CopyTradingDto> {
|
||||||
return updateCopyTrading(
|
return getSelf().updateCopyTrading(
|
||||||
CopyTradingUpdateRequest(
|
CopyTradingUpdateRequest(
|
||||||
copyTradingId = request.copyTradingId,
|
copyTradingId = request.copyTradingId,
|
||||||
enabled = request.enabled
|
enabled = request.enabled
|
||||||
@@ -435,11 +507,11 @@ class CopyTradingService(
|
|||||||
minPrice = copyTrading.minPrice?.toPlainString(),
|
minPrice = copyTrading.minPrice?.toPlainString(),
|
||||||
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
maxPrice = copyTrading.maxPrice?.toPlainString(),
|
||||||
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
maxPositionValue = copyTrading.maxPositionValue?.toPlainString(),
|
||||||
maxPositionCount = copyTrading.maxPositionCount,
|
|
||||||
keywordFilterMode = copyTrading.keywordFilterMode,
|
keywordFilterMode = copyTrading.keywordFilterMode,
|
||||||
keywords = convertJsonToKeywords(copyTrading.keywords),
|
keywords = convertJsonToKeywords(copyTrading.keywords),
|
||||||
configName = copyTrading.configName,
|
configName = copyTrading.configName,
|
||||||
pushFailedOrders = copyTrading.pushFailedOrders,
|
pushFailedOrders = copyTrading.pushFailedOrders,
|
||||||
|
pushFilteredOrders = copyTrading.pushFilteredOrders,
|
||||||
maxMarketEndDate = copyTrading.maxMarketEndDate,
|
maxMarketEndDate = copyTrading.maxMarketEndDate,
|
||||||
createdAt = copyTrading.createdAt,
|
createdAt = copyTrading.createdAt,
|
||||||
updatedAt = copyTrading.updatedAt
|
updatedAt = copyTrading.updatedAt
|
||||||
@@ -499,9 +571,9 @@ class CopyTradingService(
|
|||||||
val minPrice: BigDecimal?,
|
val minPrice: BigDecimal?,
|
||||||
val maxPrice: BigDecimal?,
|
val maxPrice: BigDecimal?,
|
||||||
val maxPositionValue: BigDecimal?,
|
val maxPositionValue: BigDecimal?,
|
||||||
val maxPositionCount: Int?,
|
|
||||||
val keywordFilterMode: String,
|
val keywordFilterMode: String,
|
||||||
val keywords: String?, // JSON 字符串
|
val keywords: String?, // JSON 字符串
|
||||||
val maxMarketEndDate: Long? // 市场截止时间限制(毫秒时间戳)
|
val maxMarketEndDate: Long?, // 市场截止时间限制(毫秒时间戳)
|
||||||
|
val pushFilteredOrders: Boolean // 推送已过滤订单(默认关闭)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-8
@@ -20,8 +20,6 @@ enum class FilterStatus {
|
|||||||
FAILED_ORDER_DEPTH,
|
FAILED_ORDER_DEPTH,
|
||||||
/** 失败:超过最大仓位金额 */
|
/** 失败:超过最大仓位金额 */
|
||||||
FAILED_MAX_POSITION_VALUE,
|
FAILED_MAX_POSITION_VALUE,
|
||||||
/** 失败:超过最大仓位数量 */
|
|
||||||
FAILED_MAX_POSITION_COUNT,
|
|
||||||
/** 失败:关键字过滤 */
|
/** 失败:关键字过滤 */
|
||||||
FAILED_KEYWORD_FILTER,
|
FAILED_KEYWORD_FILTER,
|
||||||
/** 失败:市场截止时间超出限制 */
|
/** 失败:市场截止时间超出限制 */
|
||||||
@@ -87,12 +85,6 @@ data class FilterResult(
|
|||||||
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
|
status = FilterStatus.FAILED_MAX_POSITION_VALUE,
|
||||||
reason = reason
|
reason = reason
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 超过最大仓位数量 */
|
|
||||||
fun maxPositionCountFailed(reason: String) = FilterResult(
|
|
||||||
status = FilterStatus.FAILED_MAX_POSITION_COUNT,
|
|
||||||
reason = reason
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 关键字过滤失败 */
|
/** 关键字过滤失败 */
|
||||||
fun keywordFilterFailed(reason: String) = FilterResult(
|
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.dto.*
|
||||||
import com.wrbug.polymarketbot.entity.Leader
|
import com.wrbug.polymarketbot.entity.Leader
|
||||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||||
|
import com.wrbug.polymarketbot.repository.BacktestTaskRepository
|
||||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
|
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||||
import com.wrbug.polymarketbot.util.CategoryValidator
|
import com.wrbug.polymarketbot.util.CategoryValidator
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leader 管理服务
|
* Leader 管理服务
|
||||||
@@ -17,9 +20,11 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
class LeaderService(
|
class LeaderService(
|
||||||
private val leaderRepository: LeaderRepository,
|
private val leaderRepository: LeaderRepository,
|
||||||
private val accountRepository: AccountRepository,
|
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)
|
private val logger = LoggerFactory.getLogger(LeaderService::class.java)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,7 +159,8 @@ class LeaderService(
|
|||||||
|
|
||||||
val leaderDtos = leaders.map { leader ->
|
val leaderDtos = leaders.map { leader ->
|
||||||
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
|
val copyTradingCount = copyTradingRepository.countByLeaderId(leader.id!!)
|
||||||
toDto(leader, copyTradingCount)
|
val backtestCount = backtestTaskRepository.findByLeaderId(leader.id).size.toLong()
|
||||||
|
toDto(leader, copyTradingCount, backtestCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.success(
|
Result.success(
|
||||||
@@ -176,19 +182,54 @@ class LeaderService(
|
|||||||
return try {
|
return try {
|
||||||
val leader = leaderRepository.findById(leaderId).orElse(null)
|
val leader = leaderRepository.findById(leaderId).orElse(null)
|
||||||
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
?: return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||||
|
|
||||||
val copyTradingCount = copyTradingRepository.countByLeaderId(leaderId)
|
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) {
|
} catch (e: Exception) {
|
||||||
logger.error("查询 Leader 详情失败", e)
|
logger.error("查询 Leader 详情失败", e)
|
||||||
Result.failure(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
|
* 转换为 DTO
|
||||||
*/
|
*/
|
||||||
private fun toDto(leader: Leader, copyTradingCount: Long = 0): LeaderDto {
|
private fun toDto(leader: Leader, copyTradingCount: Long = 0, backtestCount: Long = 0): LeaderDto {
|
||||||
return LeaderDto(
|
return LeaderDto(
|
||||||
id = leader.id!!,
|
id = leader.id!!,
|
||||||
leaderAddress = leader.leaderAddress,
|
leaderAddress = leader.leaderAddress,
|
||||||
@@ -197,6 +238,7 @@ class LeaderService(
|
|||||||
remark = leader.remark,
|
remark = leader.remark,
|
||||||
website = leader.website,
|
website = leader.website,
|
||||||
copyTradingCount = copyTradingCount,
|
copyTradingCount = copyTradingCount,
|
||||||
|
backtestCount = backtestCount,
|
||||||
createdAt = leader.createdAt,
|
createdAt = leader.createdAt,
|
||||||
updatedAt = leader.updatedAt
|
updatedAt = leader.updatedAt
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -114,10 +114,10 @@ class AccountOnChainMonitorService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val receiptRpcResponse = receiptResponse.body()!!
|
val receiptRpcResponse = receiptResponse.body()!!
|
||||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result.isJsonNull) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Gson 解析 receipt JSON
|
// 使用 Gson 解析 receipt JSON
|
||||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -9,8 +9,8 @@ import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
|||||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -28,8 +28,7 @@ class CopyTradingWebSocketService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyTradingWebSocketService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.websocket.url:wss://ws-live-data.polymarket.com}")
|
private val websocketUrl: String = PolymarketConstants.USER_WS_URL
|
||||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
// 存储每个Leader的WebSocket客户端:leaderId -> WebSocketClient
|
||||||
@@ -214,7 +213,7 @@ class CopyTradingWebSocketService(
|
|||||||
// 处理交易
|
// 处理交易
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
copyOrderTrackingService.processTrade(leaderId, trade, "websocket")
|
copyOrderTrackingService.processTrade(leaderId, trade, "activity-ws")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
logger.error("处理交易失败: leaderId=$leaderId, tradeId=${trade.id}", e)
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-27
@@ -1,5 +1,8 @@
|
|||||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine
|
||||||
|
import com.google.gson.JsonNull
|
||||||
import com.wrbug.polymarketbot.api.*
|
import com.wrbug.polymarketbot.api.*
|
||||||
import com.wrbug.polymarketbot.entity.Leader
|
import com.wrbug.polymarketbot.entity.Leader
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
@@ -11,6 +14,7 @@ import okhttp3.OkHttpClient
|
|||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 链上 WebSocket 监听服务
|
* 链上 WebSocket 监听服务
|
||||||
@@ -23,12 +27,17 @@ class OnChainWsService(
|
|||||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||||
private val leaderRepository: LeaderRepository
|
private val leaderRepository: LeaderRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
private val logger = LoggerFactory.getLogger(OnChainWsService::class.java)
|
||||||
|
|
||||||
// 存储需要监听的Leader:leaderId -> Leader
|
// 存储需要监听的Leader:leaderId -> Leader
|
||||||
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
private val monitoredLeaders = ConcurrentHashMap<Long, Leader>()
|
||||||
|
|
||||||
|
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||||
|
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||||
|
.maximumSize(100)
|
||||||
|
.build()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动链上 WebSocket 监听
|
* 启动链上 WebSocket 监听
|
||||||
* 通过统一服务订阅所有 Leader
|
* 通过统一服务订阅所有 Leader
|
||||||
@@ -40,14 +49,14 @@ class OnChainWsService(
|
|||||||
stop()
|
stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 Leader 列表
|
// 更新 Leader 列表
|
||||||
monitoredLeaders.clear()
|
monitoredLeaders.clear()
|
||||||
leaders.forEach { leader ->
|
leaders.forEach { leader ->
|
||||||
addLeader(leader)
|
addLeader(leader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加Leader监听
|
* 添加Leader监听
|
||||||
* 通过统一服务订阅该 Leader 的地址
|
* 通过统一服务订阅该 Leader 的地址
|
||||||
@@ -57,17 +66,17 @@ class OnChainWsService(
|
|||||||
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
logger.warn("Leader ID为空,跳过: ${leader.leaderAddress}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val leaderId = leader.id!!
|
val leaderId = leader.id!!
|
||||||
|
|
||||||
// 如果已经在监听列表中,不重复添加
|
// 如果已经在监听列表中,不重复添加
|
||||||
if (monitoredLeaders.containsKey(leaderId)) {
|
if (monitoredLeaders.containsKey(leaderId)) {
|
||||||
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
logger.debug("Leader 已在监听列表中: ${leader.leaderName} (${leader.leaderAddress})")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
monitoredLeaders[leaderId] = leader
|
monitoredLeaders[leaderId] = leader
|
||||||
|
|
||||||
// 通过统一服务订阅
|
// 通过统一服务订阅
|
||||||
val subscriptionId = "LEADER_$leaderId"
|
val subscriptionId = "LEADER_$leaderId"
|
||||||
unifiedOnChainWsService.subscribe(
|
unifiedOnChainWsService.subscribe(
|
||||||
@@ -79,40 +88,53 @@ class OnChainWsService(
|
|||||||
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
handleLeaderTransaction(leaderId, txHash, httpClient, rpcApi)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
logger.info("添加 Leader 监听: ${leader.leaderName} (${leader.leaderAddress})")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 Leader 的交易
|
* 处理 Leader 的交易
|
||||||
*/
|
*/
|
||||||
private suspend fun handleLeaderTransaction(leaderId: Long, txHash: String, httpClient: OkHttpClient, rpcApi: EthereumRpcApi) {
|
private suspend fun handleLeaderTransaction(
|
||||||
|
leaderId: Long,
|
||||||
|
txHash: String,
|
||||||
|
httpClient: OkHttpClient,
|
||||||
|
rpcApi: EthereumRpcApi
|
||||||
|
) {
|
||||||
val leader = monitoredLeaders[leaderId] ?: return
|
val leader = monitoredLeaders[leaderId] ?: return
|
||||||
|
|
||||||
|
// 根据 txHash 去重(使用原子操作避免竞态条件)
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
|
||||||
|
if (existingTimestamp != null) {
|
||||||
|
logger.debug("交易已处理过,跳过: leaderId=$leaderId, txHash=$txHash, firstProcessedAt=$existingTimestamp")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
logger.debug("开始处理 Leader 交易: leaderId=$leaderId, txHash=$txHash, leaderAddress=${leader.leaderAddress}")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取交易 receipt
|
// 获取交易 receipt
|
||||||
val receiptRequest = JsonRpcRequest(
|
val receiptRequest = JsonRpcRequest(
|
||||||
method = "eth_getTransactionReceipt",
|
method = "eth_getTransactionReceipt",
|
||||||
params = listOf(txHash)
|
params = listOf(txHash)
|
||||||
)
|
)
|
||||||
|
|
||||||
val receiptResponse = rpcApi.call(receiptRequest)
|
val receiptResponse = rpcApi.call(receiptRequest)
|
||||||
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
if (!receiptResponse.isSuccessful || receiptResponse.body() == null) {
|
||||||
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
logger.warn("获取交易 receipt 失败: leaderId=$leaderId, txHash=$txHash, code=${receiptResponse.code()}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val receiptRpcResponse = receiptResponse.body()!!
|
val receiptRpcResponse = receiptResponse.body()!!
|
||||||
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null) {
|
if (receiptRpcResponse.error != null || receiptRpcResponse.result == null || receiptRpcResponse.result is JsonNull) {
|
||||||
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
|
logger.warn("交易 receipt 错误: leaderId=$leaderId, txHash=$txHash, error=${receiptRpcResponse.error}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 Gson 解析 receipt JSON
|
// 使用 Gson 解析 receipt JSON
|
||||||
val receiptJson = receiptRpcResponse.result.asJsonObject
|
val receiptJson = receiptRpcResponse.result.asJsonObject
|
||||||
|
|
||||||
// 获取区块号和时间戳
|
// 获取区块号和时间戳
|
||||||
val blockNumber = receiptJson.get("blockNumber")?.asString
|
val blockNumber = receiptJson.get("blockNumber")?.asString
|
||||||
val blockTimestamp = if (blockNumber != null) {
|
val blockTimestamp = if (blockNumber != null) {
|
||||||
@@ -120,7 +142,7 @@ class OnChainWsService(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 receipt 中的 Transfer 日志
|
// 解析 receipt 中的 Transfer 日志
|
||||||
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
val logs = receiptJson.getAsJsonArray("logs") ?: run {
|
||||||
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
logger.warn("交易 receipt 中没有日志: leaderId=$leaderId, txHash=$txHash")
|
||||||
@@ -128,7 +150,7 @@ class OnChainWsService(
|
|||||||
}
|
}
|
||||||
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
val (erc20Transfers, erc1155Transfers) = OnChainWsUtils.parseReceiptTransfers(logs)
|
||||||
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
logger.debug("解析交易日志: leaderId=$leaderId, txHash=$txHash, erc20Transfers=${erc20Transfers.size}, erc1155Transfers=${erc1155Transfers.size}")
|
||||||
|
|
||||||
// 解析交易信息
|
// 解析交易信息
|
||||||
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
val trade = OnChainWsUtils.parseTradeFromTransfers(
|
||||||
txHash = txHash,
|
txHash = txHash,
|
||||||
@@ -138,7 +160,7 @@ class OnChainWsService(
|
|||||||
erc1155Transfers = erc1155Transfers,
|
erc1155Transfers = erc1155Transfers,
|
||||||
retrofitFactory = retrofitFactory
|
retrofitFactory = retrofitFactory
|
||||||
)
|
)
|
||||||
|
|
||||||
if (trade != null) {
|
if (trade != null) {
|
||||||
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
logger.info("成功解析交易: leaderId=$leaderId, txHash=$txHash, side=${trade.side}, market=${trade.market}, size=${trade.size}")
|
||||||
// 调用 processTrade 处理交易
|
// 调用 processTrade 处理交易
|
||||||
@@ -154,21 +176,21 @@ class OnChainWsService(
|
|||||||
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
logger.error("处理 Leader 交易失败: leaderId=$leaderId, txHash=$txHash, ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除Leader监听
|
* 移除Leader监听
|
||||||
* 取消该 Leader 的订阅
|
* 取消该 Leader 的订阅
|
||||||
*/
|
*/
|
||||||
fun removeLeader(leaderId: Long) {
|
fun removeLeader(leaderId: Long) {
|
||||||
monitoredLeaders.remove(leaderId)
|
monitoredLeaders.remove(leaderId)
|
||||||
|
|
||||||
// 通过统一服务取消订阅
|
// 通过统一服务取消订阅
|
||||||
val subscriptionId = "LEADER_$leaderId"
|
val subscriptionId = "LEADER_$leaderId"
|
||||||
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
unifiedOnChainWsService.unsubscribe(subscriptionId)
|
||||||
|
|
||||||
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
logger.info("移除 Leader 监听: leaderId=$leaderId")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 停止监听
|
* 停止监听
|
||||||
*/
|
*/
|
||||||
@@ -180,7 +202,7 @@ class OnChainWsService(
|
|||||||
}
|
}
|
||||||
monitoredLeaders.clear()
|
monitoredLeaders.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
stop()
|
stop()
|
||||||
|
|||||||
+4
-3
@@ -220,10 +220,10 @@ object OnChainWsUtils {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试通过 Gamma API 查询市场信息(通过 tokenId)
|
// 尝试通过 Gamma API 查询市场信息(通过 tokenId);失败时仍保留链上 tokenId 供后续按 tokenId 补查市场
|
||||||
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
|
val marketInfo = fetchMarketByTokenId(asset.toString(), retrofitFactory)
|
||||||
|
|
||||||
// 创建 TradeResponse
|
// 创建 TradeResponse:tokenId 始终写入链上解析得到的 asset(与 CLOB 一致),便于 Gamma 失败时在 processBuyTrade 中按 tokenId 再查
|
||||||
return TradeResponse(
|
return TradeResponse(
|
||||||
id = txHash,
|
id = txHash,
|
||||||
market = marketInfo?.conditionId ?: "",
|
market = marketInfo?.conditionId ?: "",
|
||||||
@@ -233,7 +233,8 @@ object OnChainWsUtils {
|
|||||||
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
timestamp = (timestamp ?: System.currentTimeMillis() / 1000).toString(),
|
||||||
user = walletAddress,
|
user = walletAddress,
|
||||||
outcomeIndex = marketInfo?.outcomeIndex,
|
outcomeIndex = marketInfo?.outcomeIndex,
|
||||||
outcome = marketInfo?.outcome
|
outcome = marketInfo?.outcome,
|
||||||
|
tokenId = asset.toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+184
-18
@@ -1,5 +1,7 @@
|
|||||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine
|
||||||
import com.wrbug.polymarketbot.api.TradeResponse
|
import com.wrbug.polymarketbot.api.TradeResponse
|
||||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
||||||
@@ -7,18 +9,19 @@ import com.wrbug.polymarketbot.entity.Leader
|
|||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||||
import com.wrbug.polymarketbot.util.fromJson
|
import com.wrbug.polymarketbot.util.fromJson
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||||
import jakarta.annotation.PreDestroy
|
import jakarta.annotation.PreDestroy
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polymarket Activity WebSocket 监听服务
|
* Polymarket Activity WebSocket 监听服务
|
||||||
* 通过订阅全局 activity 交易流,客户端过滤 Leader 地址,实现实时交易检测
|
* 通过订阅全局 activity 交易流(trades + orders_matched),客户端过滤 Leader 地址,实现实时交易检测
|
||||||
* 延迟 < 100ms,适合快速跟单场景
|
* 延迟 < 100ms,适合快速跟单场景
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -29,8 +32,7 @@ class PolymarketActivityWsService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.websocket.activity.url:wss://ws-live-data.polymarket.com}")
|
private val websocketUrl: String = PolymarketConstants.ACTIVITY_WS_URL
|
||||||
private var websocketUrl: String = "wss://ws-live-data.polymarket.com"
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
@@ -40,10 +42,30 @@ class PolymarketActivityWsService(
|
|||||||
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
// 要监听的 Leader 地址集合(小写地址 -> leaderId)
|
||||||
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
|
private val monitoredAddresses = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
|
// 存储已处理的交易哈希,用于去重(LRU 缓存,保留最近 100 条)
|
||||||
|
// 因为同时订阅 trades 和 orders_matched,同一个交易可能被推送两次
|
||||||
|
private val processedTxHashes: Cache<String, Long> = Caffeine.newBuilder()
|
||||||
|
.maximumSize(100)
|
||||||
|
.expireAfterWrite(10, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
|
||||||
// 是否已订阅
|
// 是否已订阅
|
||||||
@Volatile
|
@Volatile
|
||||||
private var isSubscribed = false
|
private var isSubscribed = false
|
||||||
|
|
||||||
|
// 最后一次收到 activity 消息的时间(毫秒时间戳)
|
||||||
|
@Volatile
|
||||||
|
private var lastActivityTime: Long = 0
|
||||||
|
|
||||||
|
// Activity 消息超时检测任务
|
||||||
|
private var activityTimeoutJob: Job? = null
|
||||||
|
|
||||||
|
// 性能统计
|
||||||
|
private var totalMessagesProcessed = 0L
|
||||||
|
private var addressMatchMessages = 0L
|
||||||
|
private var jsonParseMessages = 0L
|
||||||
|
private var duplicateTxHashMessages = 0L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动监听
|
* 启动监听
|
||||||
*/
|
*/
|
||||||
@@ -62,7 +84,7 @@ class PolymarketActivityWsService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("启动 Activity WebSocket 监听,监控 ${monitoredAddresses.size} 个 Leader 地址")
|
logger.info("启动 Activity WebSocket 监听(trades + orders_matched),监控 ${monitoredAddresses.size} 个 Leader 地址")
|
||||||
connectAndSubscribe()
|
connectAndSubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +181,7 @@ class PolymarketActivityWsService(
|
|||||||
* 订阅全局 activity
|
* 订阅全局 activity
|
||||||
* 根据 @polymarket/real-time-data-client 的协议格式
|
* 根据 @polymarket/real-time-data-client 的协议格式
|
||||||
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
* 使用 "action": "subscribe" 而不是 "type": "subscribe"
|
||||||
|
* 同时订阅 trades 和 orders_matched 两种类型
|
||||||
*/
|
*/
|
||||||
private fun subscribeAllActivity() {
|
private fun subscribeAllActivity() {
|
||||||
val client = wsClient
|
val client = wsClient
|
||||||
@@ -170,6 +193,7 @@ class PolymarketActivityWsService(
|
|||||||
try {
|
try {
|
||||||
// 根据 real-time-data-client 的协议格式
|
// 根据 real-time-data-client 的协议格式
|
||||||
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
// 订阅消息应包含 "action": "subscribe" 和 "subscriptions" 数组
|
||||||
|
// 同时订阅 trades 和 orders_matched 两种类型
|
||||||
val subscribeMessage = """
|
val subscribeMessage = """
|
||||||
{
|
{
|
||||||
"action": "subscribe",
|
"action": "subscribe",
|
||||||
@@ -177,6 +201,10 @@ class PolymarketActivityWsService(
|
|||||||
{
|
{
|
||||||
"topic": "activity",
|
"topic": "activity",
|
||||||
"type": "trades"
|
"type": "trades"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"topic": "activity",
|
||||||
|
"type": "orders_matched"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -184,46 +212,154 @@ class PolymarketActivityWsService(
|
|||||||
|
|
||||||
client.sendMessage(subscribeMessage)
|
client.sendMessage(subscribeMessage)
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
logger.info("Activity WebSocket 订阅成功(全局交易流)")
|
// 重置最后一次收到 activity 消息的时间
|
||||||
|
lastActivityTime = System.currentTimeMillis()
|
||||||
|
// 启动 Activity 消息超时检测
|
||||||
|
// startActivityTimeoutCheck()
|
||||||
|
logger.info("Activity WebSocket 订阅成功(全局交易流: trades + orders_matched)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("订阅 Activity WebSocket 失败", e)
|
logger.error("订阅 Activity WebSocket 失败", e)
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 Activity 消息超时检测
|
||||||
|
* 每30秒检查一次,如果超过30秒没有收到activity消息,则重连
|
||||||
|
*/
|
||||||
|
private fun startActivityTimeoutCheck() {
|
||||||
|
// 先停止之前的检测任务
|
||||||
|
stopActivityTimeoutCheck()
|
||||||
|
|
||||||
|
activityTimeoutJob = scope.launch {
|
||||||
|
while (isActive && isSubscribed) {
|
||||||
|
delay(30000) // 每30秒检查一次
|
||||||
|
|
||||||
|
// 如果已经取消订阅,停止检测
|
||||||
|
if (!isSubscribed) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 lastActivityTime 为 0,说明还没有收到过消息,跳过本次检测
|
||||||
|
if (lastActivityTime == 0L) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val timeSinceLastActivity = currentTime - lastActivityTime
|
||||||
|
|
||||||
|
// 如果超过30秒没有收到activity消息,触发重连
|
||||||
|
if (timeSinceLastActivity >= 30000) {
|
||||||
|
logger.warn("超过30秒未收到 Activity 消息,触发重连。距离上次消息: ${timeSinceLastActivity}ms")
|
||||||
|
// 关闭当前连接并重连
|
||||||
|
wsClient?.closeConnection()
|
||||||
|
wsClient = null
|
||||||
|
isSubscribed = false
|
||||||
|
// 重新连接
|
||||||
|
connectAndSubscribe()
|
||||||
|
break // 重连后会重新启动检测任务
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止 Activity 消息超时检测
|
||||||
|
*/
|
||||||
|
private fun stopActivityTimeoutCheck() {
|
||||||
|
activityTimeoutJob?.cancel()
|
||||||
|
activityTimeoutJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查消息是否包含监听的 Leader 地址
|
||||||
|
* 快速过滤,避免不必要的 JSON 解析
|
||||||
|
* 只需要检查 "proxyWallet":"0x..." 或 "trader":{"address":"0x..."} 格式
|
||||||
|
*/
|
||||||
|
private fun containsMonitoredAddress(message: String): Boolean {
|
||||||
|
// 快速检查:如果消息很短,不可能包含地址
|
||||||
|
if (message.length < 50) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历所有监听的地址
|
||||||
|
for ((address, leaderId) in monitoredAddresses) {
|
||||||
|
// 检查 proxyWallet:格式为 "proxyWallet":"0x..."
|
||||||
|
if (message.contains("\"proxyWallet\":\"$address\"", ignoreCase = true)) {
|
||||||
|
addressMatchMessages++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 trader.address:格式为 "trader":{"address":"0x..."}
|
||||||
|
if (message.contains("\"trader\"", ignoreCase = true) &&
|
||||||
|
message.contains("\"address\":\"$address\"", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
addressMatchMessages++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理消息
|
* 处理消息
|
||||||
*/
|
*/
|
||||||
private fun handleMessage(message: String) {
|
private fun handleMessage(message: String) {
|
||||||
try {
|
try {
|
||||||
|
totalMessagesProcessed++
|
||||||
|
|
||||||
// 处理 PONG 响应
|
// 处理 PONG 响应
|
||||||
if (message.trim() == "PONG" || message.trim() == "pong") {
|
if (message.trim() == "PONG" || message.trim() == "pong") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用扩展函数解析消息
|
// 快速预检查:检查是否包含监听地址
|
||||||
|
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
|
||||||
|
if (!containsMonitoredAddress(message)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.info("发现leader交易:${message}")
|
||||||
|
// 使用扩展函数解析消息(只对包含监听地址的消息)
|
||||||
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
||||||
// 不是有效的 JSON 或格式不匹配,跳过
|
// 不是有效的 JSON 或格式不匹配,跳过
|
||||||
logger.warn("无法解析为 ActivityTradeMessage,可能不是 activity 消息: ${message.take(200)}")
|
logger.warn("无法解析为 ActivityTradeMessage: ${message.take(200)}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是 activity trade 消息
|
jsonParseMessages++
|
||||||
if (tradeMessage.topic != "activity" || tradeMessage.type != "trades") {
|
|
||||||
// 不是我们关心的消息,直接返回
|
// 检查是否是 activity 消息(trades 或 orders_matched)
|
||||||
|
if (tradeMessage.topic != "activity" ||
|
||||||
|
(tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新最后一次收到 activity 消息的时间(即使不是我们监听的 Leader 的交易)
|
||||||
|
lastActivityTime = System.currentTimeMillis()
|
||||||
|
|
||||||
val payload = tradeMessage.payload
|
val payload = tradeMessage.payload
|
||||||
|
|
||||||
|
// 根据 txHash 去重(使用原子操作避免竞态条件)
|
||||||
|
val txHash = payload.transactionHash
|
||||||
|
if (txHash != null && txHash.isNotBlank()) {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val existingTimestamp = processedTxHashes.asMap().putIfAbsent(txHash, currentTime)
|
||||||
|
if (existingTimestamp != null) {
|
||||||
|
duplicateTxHashMessages++
|
||||||
|
logger.debug("交易已处理过,跳过: txHash=$txHash, firstProcessedAt=$existingTimestamp, type=${tradeMessage.type}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 提取交易者地址
|
// 提取交易者地址
|
||||||
val traderAddress = extractTraderAddress(payload) ?: run {
|
val traderAddress = extractTraderAddress(payload) ?: run {
|
||||||
// 没有交易者地址,跳过
|
// 没有交易者地址,跳过
|
||||||
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
logger.warn("Activity Trade 消息中没有交易者地址: trader=${payload.trader}, proxyWallet=${payload.proxyWallet}, asset=${payload.asset}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是我们监听的 Leader
|
// 二次验证:确认地址匹配
|
||||||
val normalizedAddress = traderAddress.lowercase()
|
val normalizedAddress = traderAddress.lowercase()
|
||||||
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
val leaderId = monitoredAddresses[normalizedAddress] ?: run {
|
||||||
return
|
return
|
||||||
@@ -327,6 +463,7 @@ class PolymarketActivityWsService(
|
|||||||
// 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID
|
// 使用 transactionHash 作为 trade ID,如果没有则生成 fallback ID
|
||||||
val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}"
|
val tradeId = payload.transactionHash ?: "${leaderId}_${System.currentTimeMillis()}_${asset.take(10)}"
|
||||||
|
|
||||||
|
// asset 即 CLOB 的 tokenId,必须写入 TradeResponse,跟单下单时用此 tokenId 请求订单簿/下单,否则会用 conditionId+outcomeIndex 链上重算,可能得到与 CLOB 不一致的 tokenId
|
||||||
TradeResponse(
|
TradeResponse(
|
||||||
id = tradeId,
|
id = tradeId,
|
||||||
market = conditionId,
|
market = conditionId,
|
||||||
@@ -336,7 +473,8 @@ class PolymarketActivityWsService(
|
|||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
user = null, // Activity WS 中不需要
|
user = null, // Activity WS 中不需要
|
||||||
outcomeIndex = outcomeIndex,
|
outcomeIndex = outcomeIndex,
|
||||||
outcome = outcome
|
outcome = outcome,
|
||||||
|
tokenId = asset
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("解析 Activity Trade 失败: ${e.message}", e)
|
logger.error("解析 Activity Trade 失败: ${e.message}", e)
|
||||||
@@ -383,10 +521,13 @@ class PolymarketActivityWsService(
|
|||||||
*/
|
*/
|
||||||
fun stop() {
|
fun stop() {
|
||||||
logger.info("停止 Activity WebSocket 监听")
|
logger.info("停止 Activity WebSocket 监听")
|
||||||
|
stopActivityTimeoutCheck()
|
||||||
wsClient?.closeConnection()
|
wsClient?.closeConnection()
|
||||||
wsClient = null
|
wsClient = null
|
||||||
isSubscribed = false
|
isSubscribed = false
|
||||||
monitoredAddresses.clear()
|
monitoredAddresses.clear()
|
||||||
|
processedTxHashes.invalidateAll() // 清空去重缓存
|
||||||
|
lastActivityTime = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -403,8 +544,33 @@ class PolymarketActivityWsService(
|
|||||||
return monitoredAddresses.size
|
return monitoredAddresses.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取性能统计信息
|
||||||
|
*/
|
||||||
|
fun getPerformanceStats(): Map<String, Any> {
|
||||||
|
val jsonParseRate = if (totalMessagesProcessed > 0) {
|
||||||
|
(jsonParseMessages.toDouble() / totalMessagesProcessed * 100).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"totalMessages" to totalMessagesProcessed,
|
||||||
|
"addressMatches" to addressMatchMessages,
|
||||||
|
"jsonParses" to jsonParseMessages,
|
||||||
|
"duplicateTxHashes" to duplicateTxHashMessages,
|
||||||
|
"jsonParseRate" to "$jsonParseRate%",
|
||||||
|
"filteringEfficiency" to if (totalMessagesProcessed > 0) {
|
||||||
|
((1.0 - jsonParseMessages.toDouble() / totalMessagesProcessed) * 100).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
|
logger.info("Activity WS 性能统计: ${getPerformanceStats()}")
|
||||||
stop()
|
stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -129,12 +129,20 @@ class UnifiedOnChainWsService(
|
|||||||
}
|
}
|
||||||
addressConnections.clear()
|
addressConnections.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接状态
|
||||||
|
* @return Map<address, isConnected>
|
||||||
|
*/
|
||||||
|
fun getConnectionStatuses(): Map<String, Boolean> {
|
||||||
|
return addressConnections.mapValues { (_, connection) -> connection.isConnected() }
|
||||||
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
fun init() {
|
fun init() {
|
||||||
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
logger.info("统一链上 WebSocket 服务已初始化 (独立连接模式)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
stop()
|
stop()
|
||||||
@@ -211,6 +219,10 @@ class UnifiedOnChainWsService(
|
|||||||
return subscriptions.isEmpty()
|
return subscriptions.isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isConnected(): Boolean {
|
||||||
|
return isConnected
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun startConnectionLoop() {
|
private suspend fun startConnectionLoop() {
|
||||||
while (scope.isActive) {
|
while (scope.isActive) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-2
@@ -18,6 +18,7 @@ import com.wrbug.polymarketbot.util.CryptoUtils
|
|||||||
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
import com.wrbug.polymarketbot.repository.CopyOrderTrackingRepository
|
||||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
import com.wrbug.polymarketbot.service.common.MarketService
|
import com.wrbug.polymarketbot.service.common.MarketService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -41,8 +42,7 @@ class OrderPushService(
|
|||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
private val logger = LoggerFactory.getLogger(OrderPushService::class.java)
|
||||||
|
|
||||||
@Value("\${polymarket.rtds.ws-url}")
|
private val polymarketWsUrl: String = PolymarketConstants.RTDS_WS_URL
|
||||||
private lateinit var polymarketWsUrl: String
|
|
||||||
|
|
||||||
// 存储账户 ID 和对应的 WebSocket 连接
|
// 存储账户 ID 和对应的 WebSocket 连接
|
||||||
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
|
private val accountConnections = ConcurrentHashMap<Long, PolymarketWebSocketClient>()
|
||||||
|
|||||||
+152
-75
@@ -8,6 +8,7 @@ import org.web3j.crypto.Credentials
|
|||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订单签名服务
|
* 订单签名服务
|
||||||
@@ -19,11 +20,31 @@ import java.math.RoundingMode
|
|||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class OrderSigningService {
|
class OrderSigningService {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(OrderSigningService::class.java)
|
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"
|
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
|
private val CHAIN_ID = 137L
|
||||||
|
|
||||||
// USDC 有 6 位小数
|
// USDC 有 6 位小数
|
||||||
@@ -36,13 +57,11 @@ class OrderSigningService {
|
|||||||
size = 2,
|
size = 2,
|
||||||
amount = 4
|
amount = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// 金额精度限制(根据 Polymarket API 要求)
|
// 价格有效范围(Polymarket API 要求)
|
||||||
// makerAmount (USDC) 最多 2 位小数
|
private val MIN_PRICE = BigDecimal("0.01")
|
||||||
// takerAmount (shares) 最多 4 位小数
|
private val MAX_PRICE = BigDecimal("0.99")
|
||||||
private val MAKER_AMOUNT_DECIMALS = 2 // USDC 金额精度
|
|
||||||
private val TAKER_AMOUNT_DECIMALS = 4 // shares 数量精度
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订单金额计算结果
|
* 订单金额计算结果
|
||||||
*/
|
*/
|
||||||
@@ -62,7 +81,9 @@ class OrderSigningService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算订单金额(makerAmount 和 takerAmount)
|
* 计算订单金额(makerAmount 和 takerAmount)
|
||||||
*
|
*
|
||||||
|
* 参考 clob-client/src/order-builder/helpers.ts 的 getOrderRawAmounts 函数
|
||||||
|
*
|
||||||
* @param side BUY 或 SELL
|
* @param side BUY 或 SELL
|
||||||
* @param size 数量(shares)
|
* @param size 数量(shares)
|
||||||
* @param price 价格(0-1 之间)
|
* @param price 价格(0-1 之间)
|
||||||
@@ -77,49 +98,59 @@ class OrderSigningService {
|
|||||||
): OrderAmounts {
|
): OrderAmounts {
|
||||||
val sizeDecimal = size.toSafeBigDecimal()
|
val sizeDecimal = size.toSafeBigDecimal()
|
||||||
val priceDecimal = price.toSafeBigDecimal()
|
val priceDecimal = price.toSafeBigDecimal()
|
||||||
|
|
||||||
|
// 对价格进行 roundNormal 处理(与 clob-client 保持一致)
|
||||||
|
var rawPrice = roundNormal(priceDecimal, roundConfig.price)
|
||||||
|
|
||||||
|
// 验证价格范围,如果超出则调整到最接近的有效值
|
||||||
|
// Polymarket API 要求: 0.01 <= price <= 0.99
|
||||||
|
if (rawPrice > MAX_PRICE) {
|
||||||
|
logger.warn("价格超出最大限制,已调整: $priceDecimal -> $MAX_PRICE")
|
||||||
|
rawPrice = MAX_PRICE
|
||||||
|
} else if (rawPrice < MIN_PRICE) {
|
||||||
|
logger.warn("价格低于最小限制,已调整: $priceDecimal -> $MIN_PRICE")
|
||||||
|
rawPrice = MIN_PRICE
|
||||||
|
}
|
||||||
|
|
||||||
if (side.uppercase() == "BUY") {
|
if (side.uppercase() == "BUY") {
|
||||||
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
|
// BUY: makerAmount = price * size (USDC), takerAmount = size (shares)
|
||||||
// makerAmount 是 USDC 金额,最多 2 位小数
|
// 参考 clob-client/src/order-builder/helpers.ts 第 73-89 行
|
||||||
// takerAmount 是 shares 数量,最多 4 位小数
|
// 注意:Polymarket API 要求市场买入订单的 makerAmount 最多 2 位小数,takerAmount 最多 4 位小数
|
||||||
val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
// takerAmount (shares) 使用 4 位小数
|
||||||
|
val rawTakerAmt = roundDown(sizeDecimal, 4)
|
||||||
// makerAmount = price * size,使用原始价格计算(与SDK保持一致)
|
|
||||||
// 先使用原始价格计算,然后再进行舍入,确保精度一致
|
var rawMakerAmt = rawTakerAmt.multiply(rawPrice)
|
||||||
var rawMakerAmt = rawTakerAmt.multiply(priceDecimal)
|
// makerAmount (USDC) 使用 2 位小数
|
||||||
|
if (decimalPlaces(rawMakerAmt) > 2) {
|
||||||
// 确保 makerAmount 精度(USDC,最多 2 位小数)
|
rawMakerAmt = roundUp(rawMakerAmt, 2 + 4)
|
||||||
rawMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
|
if (decimalPlaces(rawMakerAmt) > 2) {
|
||||||
|
rawMakerAmt = roundDown(rawMakerAmt, 2)
|
||||||
// 确保 takerAmount 精度(shares,最多 4 位小数)
|
}
|
||||||
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
|
}
|
||||||
|
|
||||||
// 转换为 wei(6 位小数)
|
// 转换为 wei(6 位小数)
|
||||||
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||||
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||||
|
|
||||||
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
||||||
} else {
|
} else {
|
||||||
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
|
// SELL: makerAmount = size (shares), takerAmount = price * size (USDC)
|
||||||
// 根据 Polymarket API 要求:
|
// 参考 clob-client/src/order-builder/helpers.ts 第 90-105 行
|
||||||
// - makerAmount (shares) 最多 2 位小数
|
|
||||||
// - takerAmount (USDC) 最多 4 位小数
|
|
||||||
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size)
|
||||||
|
|
||||||
// takerAmount = price * size,使用原始价格计算(不使用舍入后的价格)
|
var rawTakerAmt = rawMakerAmt.multiply(rawPrice)
|
||||||
// SDK期望使用原始价格进行计算,以保留足够的精度
|
// 如果 takerAmount 的小数位数超过 roundConfig.amount,进行特殊舍入处理
|
||||||
// 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464
|
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
|
||||||
val rawTakerAmt = rawMakerAmt.multiply(priceDecimal)
|
rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4)
|
||||||
|
if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
|
||||||
// 确保 makerAmount 精度(shares,最多 2 位小数,符合 API 要求)
|
rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount)
|
||||||
val finalMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS)
|
}
|
||||||
|
}
|
||||||
// 确保 takerAmount 精度(USDC,最多 4 位小数,符合 API 要求)
|
|
||||||
val finalTakerAmt = roundDown(rawTakerAmt, TAKER_AMOUNT_DECIMALS)
|
|
||||||
|
|
||||||
// 转换为 wei(6 位小数)
|
// 转换为 wei(6 位小数)
|
||||||
val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
val makerAmount = parseUnits(rawMakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||||
val takerAmount = parseUnits(finalTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
val takerAmount = parseUnits(rawTakerAmt, COLLATERAL_TOKEN_DECIMALS)
|
||||||
|
|
||||||
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
return OrderAmounts(makerAmount.toString(), takerAmount.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +168,7 @@ class OrderSigningService {
|
|||||||
* @param nonce nonce(默认 "0")
|
* @param nonce nonce(默认 "0")
|
||||||
* @param feeRateBps 费率基点(默认 "0")
|
* @param feeRateBps 费率基点(默认 "0")
|
||||||
* @param expiration 过期时间戳(秒,0 表示永不过期)
|
* @param expiration 过期时间戳(秒,0 表示永不过期)
|
||||||
|
* @param exchangeContract 签约用 exchange 合约地址;null 时用标准 CTF Exchange,neg risk 市场需传 Neg Risk Exchange
|
||||||
* @return 签名的订单对象
|
* @return 签名的订单对象
|
||||||
*/
|
*/
|
||||||
fun createAndSignOrder(
|
fun createAndSignOrder(
|
||||||
@@ -149,7 +181,8 @@ class OrderSigningService {
|
|||||||
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
|
signatureType: Int = 2, // 默认使用 Browser Wallet(与正确订单数据一致)
|
||||||
nonce: String = "0",
|
nonce: String = "0",
|
||||||
feeRateBps: String = "0",
|
feeRateBps: String = "0",
|
||||||
expiration: String = "0"
|
expiration: String = "0",
|
||||||
|
exchangeContract: String? = null
|
||||||
): SignedOrderObject {
|
): SignedOrderObject {
|
||||||
try {
|
try {
|
||||||
// 1. 从私钥获取签名地址
|
// 1. 从私钥获取签名地址
|
||||||
@@ -182,10 +215,11 @@ class OrderSigningService {
|
|||||||
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
|
logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps")
|
||||||
logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID")
|
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(
|
val signature = signOrder(
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
exchangeContract = EXCHANGE_CONTRACT,
|
exchangeContract = contract,
|
||||||
chainId = CHAIN_ID,
|
chainId = CHAIN_ID,
|
||||||
salt = salt,
|
salt = salt,
|
||||||
maker = makerAddressLower,
|
maker = makerAddressLower,
|
||||||
@@ -247,20 +281,20 @@ class OrderSigningService {
|
|||||||
signatureType: Int
|
signatureType: Int
|
||||||
): String {
|
): String {
|
||||||
try {
|
try {
|
||||||
// 1. 从私钥创建 BigInteger
|
// 1. 私钥与密钥对
|
||||||
val cleanPrivateKey = privateKey.removePrefix("0x")
|
val cleanPrivateKey = privateKey.removePrefix("0x")
|
||||||
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
val privateKeyBigInt = BigInteger(cleanPrivateKey, 16)
|
||||||
val ecKeyPair = org.web3j.crypto.ECKeyPair.create(privateKeyBigInt)
|
val credentials = Credentials.create(privateKeyBigInt.toString(16))
|
||||||
|
val ecKeyPair = credentials.ecKeyPair
|
||||||
// 2. 编码域分隔符
|
|
||||||
|
// 2. 编码域分隔符(verifyingContract 显式小写,与 EIP-712 约定一致)
|
||||||
val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain(
|
val domainSeparator = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeDomain(
|
||||||
chainId = chainId,
|
chainId = chainId,
|
||||||
verifyingContract = exchangeContract
|
verifyingContract = exchangeContract.lowercase()
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. 编码订单消息哈希
|
// 3. 编码订单消息哈希
|
||||||
// signatureType 参数:1 = POLY_PROXY (代理钱包), 2 = POLY_GNOSIS_SAFE, 0 = EOA
|
// signatureType:1 = POLY_PROXY (Magic), 2 = POLY_GNOSIS_SAFE (Safe), 0 = EOA
|
||||||
// 使用传入的 signatureType 参数,而不是硬编码
|
|
||||||
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
|
val orderHash = com.wrbug.polymarketbot.util.Eip712Encoder.encodeExchangeOrder(
|
||||||
salt = salt,
|
salt = salt,
|
||||||
maker = maker,
|
maker = maker,
|
||||||
@@ -273,29 +307,25 @@ class OrderSigningService {
|
|||||||
nonce = nonce,
|
nonce = nonce,
|
||||||
feeRateBps = feeRateBps,
|
feeRateBps = feeRateBps,
|
||||||
side = side,
|
side = side,
|
||||||
signatureType = signatureType // 使用传入的参数
|
signatureType = signatureType
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. 计算完整的结构化数据哈希
|
// 4. 计算完整 EIP-712 结构化数据哈希
|
||||||
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
|
val structuredHash = com.wrbug.polymarketbot.util.Eip712Encoder.hashStructuredData(
|
||||||
domainSeparator = domainSeparator,
|
domainSeparator = domainSeparator,
|
||||||
messageHash = orderHash
|
messageHash = orderHash
|
||||||
)
|
)
|
||||||
|
|
||||||
// 5. 使用私钥签名
|
// 5. 使用私钥签名(needToHash=false,对 32 字节 hash 直接签名)
|
||||||
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
|
val signature = org.web3j.crypto.Sign.signMessage(structuredHash, ecKeyPair, false)
|
||||||
|
|
||||||
// 6. 组合签名(r + s + v)
|
// 6. 组合 r + s + v
|
||||||
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
|
val rHex = org.web3j.utils.Numeric.toHexString(signature.r).removePrefix("0x").padStart(64, '0')
|
||||||
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
|
val sHex = org.web3j.utils.Numeric.toHexString(signature.s).removePrefix("0x").padStart(64, '0')
|
||||||
val vBytes = signature.v as ByteArray
|
val vBytes = signature.v
|
||||||
val vInt = if (vBytes.isNotEmpty()) {
|
val vInt = if (vBytes.isNotEmpty()) vBytes[0].toInt() and 0xff else 0
|
||||||
vBytes[0].toInt() and 0xff
|
val vHex = "%02x".format(vInt)
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
val vHex = String.format("%02x", vInt)
|
|
||||||
|
|
||||||
return "0x$rHex$sHex$vHex"
|
return "0x$rHex$sHex$vHex"
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("订单签名失败", e)
|
logger.error("订单签名失败", e)
|
||||||
@@ -303,12 +333,17 @@ class OrderSigningService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 并发安全:确保同一毫秒内多次调用生成唯一 salt,避免 FIXED 模式预签双单等场景的 salt 碰撞 */
|
||||||
|
private val saltSequence = AtomicLong(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 salt(使用时间戳,毫秒)
|
* 生成 salt(时间戳 + 自增序列,保证并发下唯一)
|
||||||
* 与 TypeScript SDK 保持一致,使用时间戳作为 salt
|
* 兼容 Polymarket:salt 为 Long,时间戳主位 + 序列次位,与 TypeScript SDK 语义兼容
|
||||||
*/
|
*/
|
||||||
private fun generateSalt(): Long {
|
private fun generateSalt(): Long {
|
||||||
return System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
val seq = saltSequence.incrementAndGet() and 0x3FF
|
||||||
|
return now * 1000 + seq
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -324,23 +359,65 @@ class OrderSigningService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 正常舍入(四舍五入)
|
* 正常舍入(四舍五入)
|
||||||
|
* 参考 clob-client/src/utilities.ts 的 roundNormal 函数
|
||||||
|
* 只有当小数位数超过 decimals 时才进行舍入
|
||||||
|
*
|
||||||
|
* @param value 要舍入的数值
|
||||||
|
* @param decimals 目标小数位数
|
||||||
|
* @return 舍入后的数值
|
||||||
*/
|
*/
|
||||||
private fun roundNormal(value: BigDecimal, decimals: Int): BigDecimal {
|
private fun roundNormal(value: BigDecimal, decimals: Int): BigDecimal {
|
||||||
|
if (decimalPlaces(value) <= decimals) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
return value.setScale(decimals, RoundingMode.HALF_UP)
|
return value.setScale(decimals, RoundingMode.HALF_UP)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 向下舍入
|
* 向下舍入
|
||||||
|
* 参考 clob-client/src/utilities.ts 的 roundDown 函数
|
||||||
|
* 只有当小数位数超过 decimals 时才进行舍入
|
||||||
|
*
|
||||||
|
* @param value 要舍入的数值
|
||||||
|
* @param decimals 目标小数位数
|
||||||
|
* @return 舍入后的数值
|
||||||
*/
|
*/
|
||||||
private fun roundDown(value: BigDecimal, decimals: Int): BigDecimal {
|
private fun roundDown(value: BigDecimal, decimals: Int): BigDecimal {
|
||||||
|
if (decimalPlaces(value) <= decimals) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
return value.setScale(decimals, RoundingMode.DOWN)
|
return value.setScale(decimals, RoundingMode.DOWN)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 向上舍入
|
* 向上舍入
|
||||||
|
* 参考 clob-client/src/utilities.ts 的 roundUp 函数
|
||||||
|
* 只有当小数位数超过 decimals 时才进行舍入
|
||||||
|
*
|
||||||
|
* @param value 要舍入的数值
|
||||||
|
* @param decimals 目标小数位数
|
||||||
|
* @return 舍入后的数值
|
||||||
*/
|
*/
|
||||||
private fun roundUp(value: BigDecimal, decimals: Int): BigDecimal {
|
private fun roundUp(value: BigDecimal, decimals: Int): BigDecimal {
|
||||||
|
if (decimalPlaces(value) <= decimals) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
return value.setScale(decimals, RoundingMode.UP)
|
return value.setScale(decimals, RoundingMode.UP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 BigDecimal 的小数位数
|
||||||
|
* 参考 clob-client/src/utilities.ts 的 decimalPlaces 函数
|
||||||
|
*
|
||||||
|
* @param value 要计算的数值
|
||||||
|
* @return 小数位数
|
||||||
|
*/
|
||||||
|
private fun decimalPlaces(value: BigDecimal): Int {
|
||||||
|
if (value.scale() <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// 去除尾部的零,获取真实的小数位数
|
||||||
|
return value.stripTrailingZeros().scale()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+152
-76
@@ -23,6 +23,8 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
|||||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||||
|
import org.springframework.context.ApplicationContext
|
||||||
|
import org.springframework.context.ApplicationContextAware
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -51,12 +53,26 @@ open class CopyOrderTrackingService(
|
|||||||
private val cryptoUtils: CryptoUtils,
|
private val cryptoUtils: CryptoUtils,
|
||||||
private val marketService: MarketService, // 市场信息服务
|
private val marketService: MarketService, // 市场信息服务
|
||||||
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
private val telegramNotificationService: TelegramNotificationService? = null // 可选,避免循环依赖
|
||||||
) {
|
) : ApplicationContextAware {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
private val logger = LoggerFactory.getLogger(CopyOrderTrackingService::class.java)
|
||||||
|
|
||||||
// 协程作用域(用于异步发送通知)
|
// 协程作用域(用于异步发送通知)
|
||||||
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
private var applicationContext: ApplicationContext? = null
|
||||||
|
|
||||||
|
override fun setApplicationContext(applicationContext: ApplicationContext) {
|
||||||
|
this.applicationContext = applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取代理对象,用于解决 @Transactional 自调用问题
|
||||||
|
*/
|
||||||
|
private fun getSelf(): CopyOrderTrackingService {
|
||||||
|
return applicationContext?.getBean(CopyOrderTrackingService::class.java)
|
||||||
|
?: throw IllegalStateException("ApplicationContext not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
// 使用 Mutex 保证线程安全(按交易ID锁定)
|
||||||
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
private val tradeMutexMap = ConcurrentHashMap<String, Mutex>()
|
||||||
@@ -138,10 +154,11 @@ open class CopyOrderTrackingService(
|
|||||||
return@withLock Result.success(Unit)
|
return@withLock Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 处理交易逻辑
|
// 2. 处理交易逻辑(通过代理对象调用,确保 @Transactional 生效)
|
||||||
|
val self = getSelf()
|
||||||
val result = when (trade.side.uppercase()) {
|
val result = when (trade.side.uppercase()) {
|
||||||
"BUY" -> processBuyTrade(leaderId, trade)
|
"BUY" -> self.processBuyTrade(leaderId, trade, source)
|
||||||
"SELL" -> processSellTrade(leaderId, trade)
|
"SELL" -> self.processSellTrade(leaderId, trade)
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("未知的交易方向: ${trade.side}")
|
logger.warn("未知的交易方向: ${trade.side}")
|
||||||
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
Result.failure(IllegalArgumentException("未知的交易方向: ${trade.side}"))
|
||||||
@@ -168,6 +185,7 @@ open class CopyOrderTrackingService(
|
|||||||
processedAt = System.currentTimeMillis()
|
processedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
processedTradeRepository.save(processed)
|
processedTradeRepository.save(processed)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
// 检查是否是唯一键冲突异常(理论上不会发生,但保留作为兜底)
|
||||||
if (isUniqueConstraintViolation(e)) {
|
if (isUniqueConstraintViolation(e)) {
|
||||||
@@ -213,7 +231,7 @@ open class CopyOrderTrackingService(
|
|||||||
* 创建跟单买入订单并记录到跟踪表
|
* 创建跟单买入订单并记录到跟踪表
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse): Result<Unit> {
|
suspend fun processBuyTrade(leaderId: Long, trade: TradeResponse, source: String): Result<Unit> {
|
||||||
return try {
|
return try {
|
||||||
// 1. 查找所有启用且支持该Leader的跟单关系
|
// 1. 查找所有启用且支持该Leader的跟单关系
|
||||||
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
val copyTradings = copyTradingRepository.findByLeaderIdAndEnabledTrue(leaderId)
|
||||||
@@ -240,19 +258,36 @@ open class CopyOrderTrackingService(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接使用outcomeIndex获取tokenId(支持多元市场)
|
// 获取 tokenId:优先使用链上解析得到的 tokenId(与 Gamma clobTokenIds 一致),否则用 conditionId+outcomeIndex 链上重算
|
||||||
if (trade.outcomeIndex == null) {
|
val tokenId = if (!trade.tokenId.isNullOrBlank()) {
|
||||||
logger.warn("交易缺少outcomeIndex,无法确定tokenId: tradeId=${trade.id}, market=${trade.market}")
|
trade.tokenId
|
||||||
continue
|
} 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)
|
// 当链上解析时 Gamma 失败导致 market/outcomeIndex 为空时,按 tokenId 补查市场信息
|
||||||
val tokenIdResult = blockchainService.getTokenId(trade.market, trade.outcomeIndex)
|
var effectiveMarketId = trade.market
|
||||||
if (tokenIdResult.isFailure) {
|
var effectiveOutcomeIndex = trade.outcomeIndex
|
||||||
logger.error("获取tokenId失败: market=${trade.market}, outcomeIndex=${trade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
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
|
continue
|
||||||
}
|
}
|
||||||
val tokenId = tokenIdResult.getOrNull() ?: continue
|
|
||||||
|
|
||||||
// 先计算跟单金额(用于仓位检查)
|
// 先计算跟单金额(用于仓位检查)
|
||||||
// 注意:这里先计算金额,即使后续被过滤也会记录
|
// 注意:这里先计算金额,即使后续被过滤也会记录
|
||||||
@@ -275,7 +310,7 @@ open class CopyOrderTrackingService(
|
|||||||
|
|
||||||
if (needMarketInfo) {
|
if (needMarketInfo) {
|
||||||
try {
|
try {
|
||||||
val market = marketService.getMarket(trade.market)
|
val market = marketService.getMarket(effectiveMarketId)
|
||||||
marketTitle = market?.title
|
marketTitle = market?.title
|
||||||
marketEndDate = market?.endDate
|
marketEndDate = market?.endDate
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -285,7 +320,7 @@ open class CopyOrderTrackingService(
|
|||||||
|
|
||||||
// 过滤条件检查(在计算订单参数之前)
|
// 过滤条件检查(在计算订单参数之前)
|
||||||
// 传入 Leader 交易价格,用于价格区间检查
|
// 传入 Leader 交易价格,用于价格区间检查
|
||||||
// 传入跟单金额和市场ID,用于仓位检查(按市场检查仓位)
|
// 传入跟单金额和市场ID,用于仓位检查(按市场+方向检查仓位)
|
||||||
// 传入市场标题,用于关键字过滤
|
// 传入市场标题,用于关键字过滤
|
||||||
// 传入市场截止时间,用于市场截止时间检查
|
// 传入市场截止时间,用于市场截止时间检查
|
||||||
// 订单簿只请求一次,返回给后续逻辑使用
|
// 订单簿只请求一次,返回给后续逻辑使用
|
||||||
@@ -294,9 +329,10 @@ open class CopyOrderTrackingService(
|
|||||||
tokenId,
|
tokenId,
|
||||||
tradePrice = tradePrice,
|
tradePrice = tradePrice,
|
||||||
copyOrderAmount = copyOrderAmount,
|
copyOrderAmount = copyOrderAmount,
|
||||||
marketId = trade.market,
|
marketId = effectiveMarketId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketEndDate = marketEndDate
|
marketEndDate = marketEndDate,
|
||||||
|
outcomeIndex = effectiveOutcomeIndex
|
||||||
)
|
)
|
||||||
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
val orderbook = filterResult.orderbook // 获取订单簿(如果需要)
|
||||||
if (!filterResult.isPassed) {
|
if (!filterResult.isPassed) {
|
||||||
@@ -306,8 +342,8 @@ open class CopyOrderTrackingService(
|
|||||||
notificationScope.launch {
|
notificationScope.launch {
|
||||||
try {
|
try {
|
||||||
// 获取市场信息(标题和slug)
|
// 获取市场信息(标题和slug)
|
||||||
val market = marketService.getMarket(trade.market)
|
val market = marketService.getMarket(effectiveMarketId)
|
||||||
val marketTitle = market?.title ?: trade.market
|
val marketTitle = market?.title ?: effectiveMarketId
|
||||||
val marketSlug = market?.slug // 显示用的 slug
|
val marketSlug = market?.slug // 显示用的 slug
|
||||||
|
|
||||||
// 从过滤结果中提取 filterType
|
// 从过滤结果中提取 filterType
|
||||||
@@ -327,11 +363,11 @@ open class CopyOrderTrackingService(
|
|||||||
accountId = copyTrading.accountId,
|
accountId = copyTrading.accountId,
|
||||||
leaderId = copyTrading.leaderId,
|
leaderId = copyTrading.leaderId,
|
||||||
leaderTradeId = trade.id,
|
leaderTradeId = trade.id,
|
||||||
marketId = trade.market,
|
marketId = effectiveMarketId,
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketSlug = marketSlug,
|
marketSlug = marketSlug,
|
||||||
side = "BUY",
|
side = "BUY",
|
||||||
outcomeIndex = trade.outcomeIndex,
|
outcomeIndex = effectiveOutcomeIndex,
|
||||||
outcome = trade.outcome,
|
outcome = trade.outcome,
|
||||||
price = trade.price.toSafeBigDecimal(),
|
price = trade.price.toSafeBigDecimal(),
|
||||||
size = trade.size.toSafeBigDecimal(),
|
size = trade.size.toSafeBigDecimal(),
|
||||||
@@ -347,27 +383,29 @@ open class CopyOrderTrackingService(
|
|||||||
logger.error("保存被过滤订单失败: ${e.message}", e)
|
logger.error("保存被过滤订单失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送 Telegram 通知
|
// 发送 Telegram 通知(仅在 pushFilteredOrders 为 true 时发送)
|
||||||
val locale = try {
|
if (copyTrading.pushFilteredOrders) {
|
||||||
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
val locale = try {
|
||||||
} catch (e: Exception) {
|
org.springframework.context.i18n.LocaleContextHolder.getLocale()
|
||||||
java.util.Locale("zh", "CN") // 默认简体中文
|
} catch (e: Exception) {
|
||||||
}
|
java.util.Locale("zh", "CN") // 默认简体中文
|
||||||
|
}
|
||||||
|
|
||||||
telegramNotificationService?.sendOrderFilteredNotification(
|
telegramNotificationService?.sendOrderFilteredNotification(
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketId = trade.market,
|
marketId = effectiveMarketId,
|
||||||
marketSlug = marketSlug,
|
marketSlug = marketSlug,
|
||||||
side = "BUY",
|
side = "BUY",
|
||||||
outcome = trade.outcome,
|
outcome = trade.outcome,
|
||||||
price = trade.price,
|
price = trade.price,
|
||||||
size = trade.size,
|
size = trade.size,
|
||||||
filterReason = filterResult.reason,
|
filterReason = filterResult.reason,
|
||||||
filterType = filterType,
|
filterType = filterType,
|
||||||
accountName = account.accountName,
|
accountName = account.accountName,
|
||||||
walletAddress = account.walletAddress,
|
walletAddress = account.walletAddress,
|
||||||
locale = locale
|
locale = locale
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("处理被过滤订单通知失败: ${e.message}", e)
|
logger.error("处理被过滤订单通知失败: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -535,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")
|
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创建订单(带重试机制)
|
// 调用API创建订单(带重试机制)
|
||||||
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
|
// 重试策略:最多重试 MAX_RETRY_ATTEMPTS 次,每次重试前等待 RETRY_DELAY_MS 毫秒
|
||||||
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
|
// 每次重试都会重新生成salt并重新签名,确保签名唯一性
|
||||||
@@ -542,6 +585,8 @@ open class CopyOrderTrackingService(
|
|||||||
clobApi = clobApi,
|
clobApi = clobApi,
|
||||||
privateKey = decryptedPrivateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
makerAddress = account.proxyAddress,
|
makerAddress = account.proxyAddress,
|
||||||
|
walletAddress = account.walletAddress,
|
||||||
|
exchangeContract = exchangeContract,
|
||||||
tokenId = tokenId,
|
tokenId = tokenId,
|
||||||
side = "BUY",
|
side = "BUY",
|
||||||
price = buyPrice.toString(),
|
price = buyPrice.toString(),
|
||||||
@@ -549,7 +594,8 @@ open class CopyOrderTrackingService(
|
|||||||
owner = account.apiKey,
|
owner = account.apiKey,
|
||||||
copyTradingId = copyTrading.id!!,
|
copyTradingId = copyTrading.id!!,
|
||||||
tradeId = trade.id,
|
tradeId = trade.id,
|
||||||
feeRateBps = feeRateBps
|
feeRateBps = feeRateBps,
|
||||||
|
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 处理订单创建失败
|
// 处理订单创建失败
|
||||||
@@ -563,8 +609,8 @@ open class CopyOrderTrackingService(
|
|||||||
notificationScope.launch {
|
notificationScope.launch {
|
||||||
try {
|
try {
|
||||||
// 获取市场信息(标题和slug)
|
// 获取市场信息(标题和slug)
|
||||||
val market = marketService.getMarket(trade.market)
|
val market = marketService.getMarket(effectiveMarketId)
|
||||||
val marketTitle = market?.title ?: trade.market
|
val marketTitle = market?.title ?: effectiveMarketId
|
||||||
val marketSlug = market?.eventSlug // 跳转用的 slug
|
val marketSlug = market?.eventSlug // 跳转用的 slug
|
||||||
|
|
||||||
// 获取当前语言设置(从 LocaleContextHolder)
|
// 获取当前语言设置(从 LocaleContextHolder)
|
||||||
@@ -576,7 +622,7 @@ open class CopyOrderTrackingService(
|
|||||||
|
|
||||||
telegramNotificationService?.sendOrderFailureNotification(
|
telegramNotificationService?.sendOrderFailureNotification(
|
||||||
marketTitle = marketTitle,
|
marketTitle = marketTitle,
|
||||||
marketId = trade.market,
|
marketId = effectiveMarketId,
|
||||||
marketSlug = marketSlug,
|
marketSlug = marketSlug,
|
||||||
side = "BUY",
|
side = "BUY",
|
||||||
outcome = null, // 失败时可能没有 outcome
|
outcome = null, // 失败时可能没有 outcome
|
||||||
@@ -610,9 +656,9 @@ open class CopyOrderTrackingService(
|
|||||||
copyTradingId = copyTrading.id,
|
copyTradingId = copyTrading.id,
|
||||||
accountId = copyTrading.accountId,
|
accountId = copyTrading.accountId,
|
||||||
leaderId = copyTrading.leaderId,
|
leaderId = copyTrading.leaderId,
|
||||||
marketId = trade.market,
|
marketId = effectiveMarketId,
|
||||||
side = trade.outcomeIndex.toString(), // 使用outcomeIndex作为side(兼容旧数据)
|
side = effectiveOutcomeIndex?.toString() ?: "", // 使用outcomeIndex作为side(兼容旧数据)
|
||||||
outcomeIndex = trade.outcomeIndex, // 新增字段
|
outcomeIndex = effectiveOutcomeIndex, // 新增字段
|
||||||
buyOrderId = realOrderId, // 使用真实订单ID
|
buyOrderId = realOrderId, // 使用真实订单ID
|
||||||
leaderBuyTradeId = trade.id,
|
leaderBuyTradeId = trade.id,
|
||||||
leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例)
|
leaderBuyQuantity = trade.size.toSafeBigDecimal(), // 存储 Leader 买入数量(用于固定金额模式计算卖出比例)
|
||||||
@@ -620,7 +666,8 @@ open class CopyOrderTrackingService(
|
|||||||
price = buyPrice, // 使用下单价格,临时值
|
price = buyPrice, // 使用下单价格,临时值
|
||||||
remainingQuantity = finalBuyQuantity,
|
remainingQuantity = finalBuyQuantity,
|
||||||
status = "filled",
|
status = "filled",
|
||||||
notificationSent = false // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
notificationSent = false, // 标记为未发送通知,等待轮询任务获取实际数据后发送
|
||||||
|
source = source // 订单来源
|
||||||
)
|
)
|
||||||
|
|
||||||
copyOrderTrackingRepository.save(tracking)
|
copyOrderTrackingRepository.save(tracking)
|
||||||
@@ -683,8 +730,8 @@ open class CopyOrderTrackingService(
|
|||||||
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
private fun calculateBuyQuantity(trade: TradeResponse, copyTrading: CopyTrading): BigDecimal {
|
||||||
return when (copyTrading.copyMode) {
|
return when (copyTrading.copyMode) {
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
// 比例模式:Leader 数量 × (比例 / 100)
|
// 比例模式:Leader 数量 × 比例倍数(copyRatio 已经是倍数值,如 1.3 表示 130%)
|
||||||
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
trade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
"FIXED" -> {
|
"FIXED" -> {
|
||||||
@@ -716,7 +763,7 @@ open class CopyOrderTrackingService(
|
|||||||
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
val leader = leaderRepository.findById(copyTrading.leaderId).orElse(null)
|
||||||
?: run {
|
?: run {
|
||||||
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
logger.warn("Leader 不存在,使用默认比例: leaderId=${copyTrading.leaderId}")
|
||||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
// 创建不需要认证的 CLOB API 客户端(用于查询公开的交易数据)
|
||||||
@@ -787,7 +834,7 @@ open class CopyOrderTrackingService(
|
|||||||
// 如果无法计算总比例(查询失败),使用默认比例
|
// 如果无法计算总比例(查询失败),使用默认比例
|
||||||
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
if (totalLeaderQuantity.lte(BigDecimal.ZERO)) {
|
||||||
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
logger.warn("无法计算总比例(Leader 买入数量为 0),使用默认比例: copyTradingId=${copyTrading.id}")
|
||||||
return leaderSellQuantity.multi(copyTrading.copyRatio.div(100))
|
return leaderSellQuantity.multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
// 计算实际比例:跟单买入数量 / Leader 买入数量
|
||||||
@@ -863,13 +910,13 @@ open class CopyOrderTrackingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
"RATIO" -> {
|
"RATIO" -> {
|
||||||
// 比例模式:直接使用配置的 copyRatio (需要除以100)
|
// 比例模式:直接使用配置的 copyRatio(已经是倍数值,如 1.3 表示 130%)
|
||||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
logger.warn("不支持的 copyMode: ${copyTrading.copyMode},使用默认比例模式")
|
||||||
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio.div(100))
|
leaderSellTrade.size.toSafeBigDecimal().multi(copyTrading.copyRatio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,13 +928,21 @@ open class CopyOrderTrackingService(
|
|||||||
finalNeedMatch = BigDecimal.ONE
|
finalNeedMatch = BigDecimal.ONE
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 获取tokenId(直接使用outcomeIndex,支持多元市场)
|
// 4. 获取 tokenId:优先使用链上解析得到的 tokenId,否则用 conditionId+outcomeIndex 链上重算
|
||||||
val tokenIdResult = blockchainService.getTokenId(leaderSellTrade.market, leaderSellTrade.outcomeIndex)
|
val tokenId = if (!leaderSellTrade.tokenId.isNullOrBlank()) {
|
||||||
if (tokenIdResult.isFailure) {
|
leaderSellTrade.tokenId
|
||||||
logger.error("获取tokenId失败: market=${leaderSellTrade.market}, outcomeIndex=${leaderSellTrade.outcomeIndex}, error=${tokenIdResult.exceptionOrNull()?.message}")
|
} else {
|
||||||
return
|
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%计算)
|
// 5. 计算卖出价格(优先使用订单簿 bestBid,失败则使用 Leader 价格,固定按90%计算)
|
||||||
// 注意:需要先计算卖出价格,因为后续创建 matchDetails 需要使用实际卖出价格
|
// 注意:需要先计算卖出价格,因为后续创建 matchDetails 需要使用实际卖出价格
|
||||||
@@ -972,7 +1027,12 @@ open class CopyOrderTrackingService(
|
|||||||
"0"
|
"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 {
|
val signedOrder = try {
|
||||||
orderSigningService.createAndSignOrder(
|
orderSigningService.createAndSignOrder(
|
||||||
privateKey = decryptedPrivateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
@@ -981,17 +1041,18 @@ open class CopyOrderTrackingService(
|
|||||||
side = "SELL",
|
side = "SELL",
|
||||||
price = sellPrice.toString(),
|
price = sellPrice.toString(),
|
||||||
size = totalMatched.toString(),
|
size = totalMatched.toString(),
|
||||||
signatureType = 2, // Browser Wallet
|
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType),
|
||||||
nonce = "0",
|
nonce = "0",
|
||||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
feeRateBps = feeRateBps, // 使用动态获取的费率
|
||||||
expiration = "0"
|
expiration = "0",
|
||||||
|
exchangeContract = exchangeContractSell
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("创建并签名卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}", e)
|
logger.error("创建并签名卖出订单失败: copyTradingId=${copyTrading.id}, tradeId=${leaderSellTrade.id}", e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. 构建订单请求
|
// 11. 构建订单请求
|
||||||
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
||||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||||
val orderRequest = NewOrderRequest(
|
val orderRequest = NewOrderRequest(
|
||||||
@@ -1001,7 +1062,7 @@ open class CopyOrderTrackingService(
|
|||||||
deferExec = false
|
deferExec = false
|
||||||
)
|
)
|
||||||
|
|
||||||
// 11. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
// 12. 创建带认证的CLOB API客户端(使用解密后的凭证)
|
||||||
val clobApi = retrofitFactory.createClobApi(
|
val clobApi = retrofitFactory.createClobApi(
|
||||||
account.apiKey,
|
account.apiKey,
|
||||||
apiSecret,
|
apiSecret,
|
||||||
@@ -1009,12 +1070,13 @@ open class CopyOrderTrackingService(
|
|||||||
account.walletAddress
|
account.walletAddress
|
||||||
)
|
)
|
||||||
|
|
||||||
// 12. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
// 13. 调用API创建卖出订单(带重试机制,重试时会重新生成salt并重新签名)
|
||||||
|
|
||||||
val createOrderResult = createOrderWithRetry(
|
val createOrderResult = createOrderWithRetry(
|
||||||
clobApi = clobApi,
|
clobApi = clobApi,
|
||||||
privateKey = decryptedPrivateKey,
|
privateKey = decryptedPrivateKey,
|
||||||
makerAddress = account.proxyAddress,
|
makerAddress = account.proxyAddress,
|
||||||
|
walletAddress = account.walletAddress,
|
||||||
|
exchangeContract = exchangeContractSell,
|
||||||
tokenId = tokenId,
|
tokenId = tokenId,
|
||||||
side = "SELL",
|
side = "SELL",
|
||||||
price = sellPrice.toString(),
|
price = sellPrice.toString(),
|
||||||
@@ -1022,7 +1084,8 @@ open class CopyOrderTrackingService(
|
|||||||
owner = account.apiKey,
|
owner = account.apiKey,
|
||||||
copyTradingId = copyTrading.id,
|
copyTradingId = copyTrading.id,
|
||||||
tradeId = leaderSellTrade.id,
|
tradeId = leaderSellTrade.id,
|
||||||
feeRateBps = feeRateBps
|
feeRateBps = feeRateBps,
|
||||||
|
signatureType = orderSigningService.getSignatureTypeForWalletType(account.walletType)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (createOrderResult.isFailure) {
|
if (createOrderResult.isFailure) {
|
||||||
@@ -1106,7 +1169,9 @@ open class CopyOrderTrackingService(
|
|||||||
*
|
*
|
||||||
* @param clobApi CLOB API 客户端
|
* @param clobApi CLOB API 客户端
|
||||||
* @param privateKey 私钥(用于签名)
|
* @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 tokenId Token ID
|
||||||
* @param side 订单方向(BUY/SELL)
|
* @param side 订单方向(BUY/SELL)
|
||||||
* @param price 价格
|
* @param price 价格
|
||||||
@@ -1115,12 +1180,15 @@ open class CopyOrderTrackingService(
|
|||||||
* @param copyTradingId 跟单配置ID(用于日志)
|
* @param copyTradingId 跟单配置ID(用于日志)
|
||||||
* @param tradeId Leader 交易ID(用于日志)
|
* @param tradeId Leader 交易ID(用于日志)
|
||||||
* @param feeRateBps 费率基点(从API动态获取)
|
* @param feeRateBps 费率基点(从API动态获取)
|
||||||
|
* @param signatureType 签名类型(1=Magic, 2=Safe)
|
||||||
* @return 成功返回订单ID,失败返回异常
|
* @return 成功返回订单ID,失败返回异常
|
||||||
*/
|
*/
|
||||||
private suspend fun createOrderWithRetry(
|
private suspend fun createOrderWithRetry(
|
||||||
clobApi: PolymarketClobApi,
|
clobApi: PolymarketClobApi,
|
||||||
privateKey: String,
|
privateKey: String,
|
||||||
makerAddress: String,
|
makerAddress: String,
|
||||||
|
walletAddress: String,
|
||||||
|
exchangeContract: String,
|
||||||
tokenId: String,
|
tokenId: String,
|
||||||
side: String,
|
side: String,
|
||||||
price: String,
|
price: String,
|
||||||
@@ -1128,7 +1196,8 @@ open class CopyOrderTrackingService(
|
|||||||
owner: String,
|
owner: String,
|
||||||
copyTradingId: Long,
|
copyTradingId: Long,
|
||||||
tradeId: String,
|
tradeId: String,
|
||||||
feeRateBps: String
|
feeRateBps: String,
|
||||||
|
signatureType: Int
|
||||||
): Result<String> {
|
): Result<String> {
|
||||||
var lastError: Exception? = null
|
var lastError: Exception? = null
|
||||||
|
|
||||||
@@ -1143,12 +1212,20 @@ open class CopyOrderTrackingService(
|
|||||||
side = side,
|
side = side,
|
||||||
price = price,
|
price = price,
|
||||||
size = size,
|
size = size,
|
||||||
signatureType = 2, // Browser Wallet
|
signatureType = signatureType,
|
||||||
nonce = "0",
|
nonce = "0",
|
||||||
feeRateBps = feeRateBps, // 使用动态获取的费率
|
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),允许部分成交,未成交部分立即取消
|
// 跟单订单使用 FAK (Fill-And-Kill),允许部分成交,未成交部分立即取消
|
||||||
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
// 这样可以快速响应 Leader 的交易,避免订单长期挂单导致价格不匹配
|
||||||
@@ -1409,7 +1486,6 @@ open class CopyOrderTrackingService(
|
|||||||
FilterStatus.FAILED_SPREAD -> "SPREAD"
|
FilterStatus.FAILED_SPREAD -> "SPREAD"
|
||||||
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
|
FilterStatus.FAILED_ORDER_DEPTH -> "ORDER_DEPTH"
|
||||||
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
|
FilterStatus.FAILED_MAX_POSITION_VALUE -> "MAX_POSITION_VALUE"
|
||||||
FilterStatus.FAILED_MAX_POSITION_COUNT -> "MAX_POSITION_COUNT"
|
|
||||||
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
|
FilterStatus.FAILED_KEYWORD_FILTER -> "KEYWORD_FILTER"
|
||||||
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
|
FilterStatus.FAILED_MARKET_END_DATE -> "MARKET_END_DATE"
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-243
@@ -31,8 +31,6 @@ class CopyTradingStatisticsService(
|
|||||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||||
private val accountRepository: AccountRepository,
|
private val accountRepository: AccountRepository,
|
||||||
private val leaderRepository: LeaderRepository,
|
private val leaderRepository: LeaderRepository,
|
||||||
private val accountService: AccountService,
|
|
||||||
private val blockchainService: BlockchainService,
|
|
||||||
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
|
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -63,19 +61,12 @@ class CopyTradingStatisticsService(
|
|||||||
// 6. 计算统计信息
|
// 6. 计算统计信息
|
||||||
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
|
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
|
||||||
|
|
||||||
// 7. 获取链上实际持仓(用于准确计算未实现盈亏,考虑手动卖出的情况)
|
// 7. 不再计算未实现盈亏和持仓价值(优化性能)
|
||||||
val actualPositions = getActualPositions(account)
|
// 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
|
||||||
|
val unrealizedPnl = "0"
|
||||||
|
val positionValue = "0"
|
||||||
|
|
||||||
// 8. 获取当前市场价格(用于计算未实现盈亏)
|
// 8. 构建响应(总盈亏 = 已实现盈亏)
|
||||||
val currentPrice = getCurrentMarketPrice(buyOrders)
|
|
||||||
|
|
||||||
// 9. 计算未实现盈亏(使用链上实际持仓,而不是 remainingQuantity)
|
|
||||||
val unrealizedPnl = calculateUnrealizedPnl(buyOrders, currentPrice, actualPositions)
|
|
||||||
|
|
||||||
// 10. 计算持仓价值(使用链上实际持仓和当前价格)
|
|
||||||
val positionValue = calculatePositionValue(buyOrders, currentPrice, actualPositions)
|
|
||||||
|
|
||||||
// 11. 构建响应
|
|
||||||
val response = CopyTradingStatisticsResponse(
|
val response = CopyTradingStatisticsResponse(
|
||||||
copyTradingId = copyTradingId,
|
copyTradingId = copyTradingId,
|
||||||
accountId = copyTrading.accountId,
|
accountId = copyTrading.accountId,
|
||||||
@@ -94,8 +85,8 @@ class CopyTradingStatisticsService(
|
|||||||
currentPositionValue = positionValue,
|
currentPositionValue = positionValue,
|
||||||
totalRealizedPnl = statistics.totalRealizedPnl,
|
totalRealizedPnl = statistics.totalRealizedPnl,
|
||||||
totalUnrealizedPnl = unrealizedPnl,
|
totalUnrealizedPnl = unrealizedPnl,
|
||||||
totalPnl = (statistics.totalRealizedPnl.toSafeBigDecimal().add(unrealizedPnl.toSafeBigDecimal())).toString(),
|
totalPnl = statistics.totalRealizedPnl,
|
||||||
totalPnlPercent = calculatePnlPercent(statistics.totalBuyAmount, statistics.totalRealizedPnl, unrealizedPnl)
|
totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl)
|
||||||
)
|
)
|
||||||
|
|
||||||
Result.success(response)
|
Result.success(response)
|
||||||
@@ -143,12 +134,21 @@ class CopyTradingStatisticsService(
|
|||||||
private fun getBuyOrderList(request: OrderTrackingRequest): Pair<List<BuyOrderInfo>, Long> {
|
private fun getBuyOrderList(request: OrderTrackingRequest): Pair<List<BuyOrderInfo>, Long> {
|
||||||
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
||||||
|
|
||||||
|
// 批量获取市场信息(用于筛选)
|
||||||
|
val allMarketIds = orders.map { it.marketId }.distinct()
|
||||||
|
val markets = marketService.getMarkets(allMarketIds)
|
||||||
|
|
||||||
// 筛选
|
// 筛选
|
||||||
if (!request.marketId.isNullOrBlank()) {
|
if (!request.marketId.isNullOrBlank()) {
|
||||||
orders = orders.filter { it.marketId == request.marketId }
|
// marketId 支持模糊匹配
|
||||||
|
orders = orders.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
if (!request.side.isNullOrBlank()) {
|
if (!request.marketTitle.isNullOrBlank()) {
|
||||||
orders = orders.filter { it.side == request.side }
|
// marketTitle 关键字筛选
|
||||||
|
orders = orders.filter { order ->
|
||||||
|
val market = markets[order.marketId]
|
||||||
|
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!request.status.isNullOrBlank()) {
|
if (!request.status.isNullOrBlank()) {
|
||||||
orders = orders.filter { it.status == request.status }
|
orders = orders.filter { it.status == request.status }
|
||||||
@@ -166,10 +166,6 @@ class CopyTradingStatisticsService(
|
|||||||
val end = minOf(start + limit, orders.size)
|
val end = minOf(start + limit, orders.size)
|
||||||
val pagedOrders = if (start < orders.size) orders.subList(start, end) else emptyList()
|
val pagedOrders = if (start < orders.size) orders.subList(start, end) else emptyList()
|
||||||
|
|
||||||
// 批量获取市场信息
|
|
||||||
val marketIds = pagedOrders.map { it.marketId }.distinct()
|
|
||||||
val markets = marketService.getMarkets(marketIds)
|
|
||||||
|
|
||||||
// 转换为DTO
|
// 转换为DTO
|
||||||
val list = pagedOrders.map { order ->
|
val list = pagedOrders.map { order ->
|
||||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
@@ -202,12 +198,21 @@ class CopyTradingStatisticsService(
|
|||||||
private fun getSellOrderList(request: OrderTrackingRequest): Pair<List<SellOrderInfo>, Long> {
|
private fun getSellOrderList(request: OrderTrackingRequest): Pair<List<SellOrderInfo>, Long> {
|
||||||
var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
var records = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
||||||
|
|
||||||
|
// 批量获取市场信息(用于筛选)
|
||||||
|
val allMarketIds = records.map { it.marketId }.distinct()
|
||||||
|
val markets = marketService.getMarkets(allMarketIds)
|
||||||
|
|
||||||
// 筛选
|
// 筛选
|
||||||
if (!request.marketId.isNullOrBlank()) {
|
if (!request.marketId.isNullOrBlank()) {
|
||||||
records = records.filter { it.marketId == request.marketId }
|
// marketId 支持模糊匹配
|
||||||
|
records = records.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
if (!request.side.isNullOrBlank()) {
|
if (!request.marketTitle.isNullOrBlank()) {
|
||||||
records = records.filter { it.side == request.side }
|
// marketTitle 关键字筛选
|
||||||
|
records = records.filter { record ->
|
||||||
|
val market = markets[record.marketId]
|
||||||
|
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val total = records.size.toLong()
|
val total = records.size.toLong()
|
||||||
@@ -222,10 +227,6 @@ class CopyTradingStatisticsService(
|
|||||||
val end = minOf(start + limit, records.size)
|
val end = minOf(start + limit, records.size)
|
||||||
val pagedRecords = if (start < records.size) records.subList(start, end) else emptyList()
|
val pagedRecords = if (start < records.size) records.subList(start, end) else emptyList()
|
||||||
|
|
||||||
// 批量获取市场信息
|
|
||||||
val marketIds = pagedRecords.map { it.marketId }.distinct()
|
|
||||||
val markets = marketService.getMarkets(marketIds)
|
|
||||||
|
|
||||||
// 转换为DTO
|
// 转换为DTO
|
||||||
val list = pagedRecords.map { record ->
|
val list = pagedRecords.map { record ->
|
||||||
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
|
val amount = record.totalMatchedQuantity.toSafeBigDecimal().multi(record.sellPrice)
|
||||||
@@ -256,6 +257,14 @@ class CopyTradingStatisticsService(
|
|||||||
private fun getMatchedOrderList(request: OrderTrackingRequest): Pair<List<MatchedOrderInfo>, Long> {
|
private fun getMatchedOrderList(request: OrderTrackingRequest): Pair<List<MatchedOrderInfo>, Long> {
|
||||||
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(request.copyTradingId)
|
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(request.copyTradingId)
|
||||||
|
|
||||||
|
// 获取所有相关的卖出记录(用于筛选)
|
||||||
|
val matchRecordIds = matchDetails.map { it.matchRecordId }.distinct()
|
||||||
|
val matchRecords = matchRecordIds.mapNotNull { id ->
|
||||||
|
sellMatchRecordRepository.findById(id).orElse(null)
|
||||||
|
}
|
||||||
|
val marketIds = matchRecords.map { it.marketId }.distinct()
|
||||||
|
val markets = marketService.getMarkets(marketIds)
|
||||||
|
|
||||||
// 筛选
|
// 筛选
|
||||||
var filtered = matchDetails
|
var filtered = matchDetails
|
||||||
if (!request.sellOrderId.isNullOrBlank()) {
|
if (!request.sellOrderId.isNullOrBlank()) {
|
||||||
@@ -269,6 +278,21 @@ class CopyTradingStatisticsService(
|
|||||||
if (!request.buyOrderId.isNullOrBlank()) {
|
if (!request.buyOrderId.isNullOrBlank()) {
|
||||||
filtered = filtered.filter { it.buyOrderId == request.buyOrderId }
|
filtered = filtered.filter { it.buyOrderId == request.buyOrderId }
|
||||||
}
|
}
|
||||||
|
if (!request.marketId.isNullOrBlank()) {
|
||||||
|
// marketId 支持模糊匹配
|
||||||
|
filtered = filtered.filter { detail ->
|
||||||
|
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
|
||||||
|
matchRecord?.marketId?.contains(request.marketId!!, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!request.marketTitle.isNullOrBlank()) {
|
||||||
|
// marketTitle 关键字筛选
|
||||||
|
filtered = filtered.filter { detail ->
|
||||||
|
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
|
||||||
|
val market = matchRecord?.let { markets[it.marketId] }
|
||||||
|
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val total = filtered.size.toLong()
|
val total = filtered.size.toLong()
|
||||||
|
|
||||||
@@ -283,17 +307,17 @@ class CopyTradingStatisticsService(
|
|||||||
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
|
val pagedDetails = if (start < filtered.size) filtered.subList(start, end) else emptyList()
|
||||||
|
|
||||||
// 获取匹配记录以获取市场ID
|
// 获取匹配记录以获取市场ID
|
||||||
val matchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
|
val pagedMatchRecordIds = pagedDetails.map { it.matchRecordId }.distinct()
|
||||||
val matchRecords = matchRecordIds.mapNotNull { id ->
|
val pagedMatchRecords = pagedMatchRecordIds.mapNotNull { id ->
|
||||||
sellMatchRecordRepository.findById(id).orElse(null)
|
sellMatchRecordRepository.findById(id).orElse(null)
|
||||||
}
|
}
|
||||||
val marketIds = matchRecords.map { it.marketId }.distinct()
|
val pagedMarketIds = pagedMatchRecords.map { it.marketId }.distinct()
|
||||||
val markets = marketService.getMarkets(marketIds)
|
val pagedMarkets = marketService.getMarkets(pagedMarketIds)
|
||||||
|
|
||||||
// 转换为DTO
|
// 转换为DTO
|
||||||
val list = pagedDetails.map { detail ->
|
val list = pagedDetails.map { detail ->
|
||||||
val matchRecord = matchRecords.find { it.id == detail.matchRecordId }
|
val matchRecord = pagedMatchRecords.find { it.id == detail.matchRecordId }
|
||||||
val market = matchRecord?.let { markets[it.marketId] }
|
val market = matchRecord?.let { pagedMarkets[it.marketId] }
|
||||||
MatchedOrderInfo(
|
MatchedOrderInfo(
|
||||||
sellOrderId = matchRecord?.sellOrderId ?: "",
|
sellOrderId = matchRecord?.sellOrderId ?: "",
|
||||||
buyOrderId = detail.buyOrderId,
|
buyOrderId = detail.buyOrderId,
|
||||||
@@ -358,200 +382,16 @@ class CopyTradingStatisticsService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前市场价格
|
* 计算盈亏百分比(仅基于已实现盈亏)
|
||||||
* 按 (marketId, outcomeIndex) 组合获取价格,支持多元市场
|
|
||||||
*/
|
*/
|
||||||
private suspend fun getCurrentMarketPrice(buyOrders: List<CopyOrderTracking>): Map<String, String> {
|
private fun calculatePnlPercentOnlyRealized(
|
||||||
val prices = mutableMapOf<String, String>()
|
|
||||||
|
|
||||||
// 获取所有不同的 (marketId, outcomeIndex) 组合
|
|
||||||
val marketOutcomePairs = buyOrders
|
|
||||||
.filter { it.outcomeIndex != null }
|
|
||||||
.map { Pair(it.marketId, it.outcomeIndex!!) }
|
|
||||||
.distinct()
|
|
||||||
|
|
||||||
for ((marketId, outcomeIndex) in marketOutcomePairs) {
|
|
||||||
try {
|
|
||||||
// 传递 outcomeIndex 参数,确保获取对应 outcome 的价格
|
|
||||||
val result = accountService.getMarketPrice(marketId, outcomeIndex)
|
|
||||||
result.onSuccess { response ->
|
|
||||||
// 使用当前价格
|
|
||||||
val price = response.currentPrice
|
|
||||||
if (price.isNotBlank() && price != "0") {
|
|
||||||
// 使用 "marketId:outcomeIndex" 作为 key
|
|
||||||
val key = "$marketId:$outcomeIndex"
|
|
||||||
prices[key] = price
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.warn("获取市场价格失败: marketId=$marketId, outcomeIndex=$outcomeIndex", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return prices
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取链上实际持仓
|
|
||||||
* 按 (marketId, outcomeIndex) 组合返回实际持仓数量
|
|
||||||
*/
|
|
||||||
private suspend fun getActualPositions(account: Account?): Map<String, BigDecimal> {
|
|
||||||
val positions = mutableMapOf<String, BigDecimal>()
|
|
||||||
|
|
||||||
if (account == null || account.proxyAddress.isBlank()) {
|
|
||||||
return positions
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val positionsResult = blockchainService.getPositions(account.proxyAddress)
|
|
||||||
if (positionsResult.isSuccess) {
|
|
||||||
val positionList = positionsResult.getOrNull() ?: emptyList()
|
|
||||||
for (pos in positionList) {
|
|
||||||
// 只处理有 conditionId 和 outcomeIndex 的仓位
|
|
||||||
if (pos.conditionId != null && pos.outcomeIndex != null && pos.size != null) {
|
|
||||||
val key = "${pos.conditionId}:${pos.outcomeIndex}"
|
|
||||||
val size = pos.size.toSafeBigDecimal()
|
|
||||||
// 如果 size > 0,表示有持仓;如果 size < 0,表示做空(取绝对值)
|
|
||||||
positions[key] = size.abs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.warn("获取链上持仓失败: accountId=${account.id}, error=${e.message}", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return positions
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算未实现盈亏
|
|
||||||
* 使用链上实际持仓数量,而不是 remainingQuantity(考虑手动卖出的情况)
|
|
||||||
* 按市场聚合订单,计算加权平均买入价格,避免重复计算
|
|
||||||
*/
|
|
||||||
private fun calculateUnrealizedPnl(
|
|
||||||
buyOrders: List<CopyOrderTracking>,
|
|
||||||
currentPrices: Map<String, String>,
|
|
||||||
actualPositions: Map<String, BigDecimal>
|
|
||||||
): String {
|
|
||||||
var totalUnrealizedPnl = BigDecimal.ZERO
|
|
||||||
|
|
||||||
// 按市场聚合订单,计算加权平均买入价格
|
|
||||||
val marketAggregates = mutableMapOf<String, Pair<BigDecimal, BigDecimal>>() // key -> (总持仓, 总成本)
|
|
||||||
|
|
||||||
for (order in buyOrders) {
|
|
||||||
// 如果没有 outcomeIndex,跳过(无法确定价格和持仓)
|
|
||||||
if (order.outcomeIndex == null) {
|
|
||||||
logger.warn("订单缺少 outcomeIndex,跳过未实现盈亏计算: orderId=${order.buyOrderId}, marketId=${order.marketId}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 "marketId:outcomeIndex" 作为 key
|
|
||||||
val key = "${order.marketId}:${order.outcomeIndex}"
|
|
||||||
|
|
||||||
// 获取订单的持仓数量(使用 remainingQuantity,因为这是该订单的持仓)
|
|
||||||
val orderQty = order.remainingQuantity.toSafeBigDecimal()
|
|
||||||
|
|
||||||
// 如果订单持仓 <= 0,跳过
|
|
||||||
if (orderQty.lte(BigDecimal.ZERO)) continue
|
|
||||||
|
|
||||||
val buyPrice = order.price.toSafeBigDecimal()
|
|
||||||
val orderCost = orderQty.multi(buyPrice)
|
|
||||||
|
|
||||||
// 聚合同一市场的订单
|
|
||||||
val existing = marketAggregates[key]
|
|
||||||
if (existing != null) {
|
|
||||||
val totalQty = existing.first.add(orderQty)
|
|
||||||
val totalCost = existing.second.add(orderCost)
|
|
||||||
marketAggregates[key] = Pair(totalQty, totalCost)
|
|
||||||
} else {
|
|
||||||
marketAggregates[key] = Pair(orderQty, orderCost)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算每个市场的未实现盈亏
|
|
||||||
for ((key, aggregate) in marketAggregates) {
|
|
||||||
val (totalQty, totalCost) = aggregate
|
|
||||||
|
|
||||||
// 获取链上实际持仓数量(如果存在),否则使用聚合的持仓数量
|
|
||||||
val actualQty = actualPositions[key] ?: totalQty
|
|
||||||
|
|
||||||
// 如果实际持仓 <= 0,说明已全部卖出(包括手动卖出),跳过未实现盈亏计算
|
|
||||||
if (actualQty.lte(BigDecimal.ZERO)) continue
|
|
||||||
|
|
||||||
// 获取当前市场价格
|
|
||||||
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
|
|
||||||
?: continue // 如果没有当前价格,跳过
|
|
||||||
|
|
||||||
// 计算加权平均买入价格
|
|
||||||
val avgBuyPrice = if (totalQty.gt(BigDecimal.ZERO)) {
|
|
||||||
totalCost.div(totalQty)
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用实际持仓数量和加权平均买入价格计算未实现盈亏
|
|
||||||
val unrealizedPnl = currentPrice.subtract(avgBuyPrice).multi(actualQty)
|
|
||||||
totalUnrealizedPnl = totalUnrealizedPnl.add(unrealizedPnl)
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalUnrealizedPnl.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算持仓价值
|
|
||||||
* 使用链上实际持仓数量和当前市场价格计算
|
|
||||||
* 按市场聚合,避免重复计算
|
|
||||||
*/
|
|
||||||
private fun calculatePositionValue(
|
|
||||||
buyOrders: List<CopyOrderTracking>,
|
|
||||||
currentPrices: Map<String, String>,
|
|
||||||
actualPositions: Map<String, BigDecimal>
|
|
||||||
): String {
|
|
||||||
var totalPositionValue = BigDecimal.ZERO
|
|
||||||
|
|
||||||
// 按市场聚合,获取所有不同的市场
|
|
||||||
val marketKeys = buyOrders
|
|
||||||
.filter { it.outcomeIndex != null }
|
|
||||||
.map { "${it.marketId}:${it.outcomeIndex}" }
|
|
||||||
.distinct()
|
|
||||||
|
|
||||||
for (key in marketKeys) {
|
|
||||||
// 获取链上实际持仓数量(如果存在)
|
|
||||||
val actualQty = actualPositions[key]
|
|
||||||
|
|
||||||
// 如果没有链上持仓,计算该市场的总持仓(所有订单的 remainingQuantity 之和)
|
|
||||||
val totalQty = actualQty ?: buyOrders
|
|
||||||
.filter { it.outcomeIndex != null && "${it.marketId}:${it.outcomeIndex}" == key }
|
|
||||||
.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
|
||||||
|
|
||||||
// 如果持仓 <= 0,跳过
|
|
||||||
if (totalQty.lte(BigDecimal.ZERO)) continue
|
|
||||||
|
|
||||||
// 获取当前市场价格
|
|
||||||
val currentPrice = currentPrices[key]?.toSafeBigDecimal()
|
|
||||||
?: continue // 如果没有当前价格,跳过
|
|
||||||
|
|
||||||
// 计算持仓价值:持仓数量 × 当前价格
|
|
||||||
val positionValue = totalQty.multi(currentPrice)
|
|
||||||
totalPositionValue = totalPositionValue.add(positionValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
return totalPositionValue.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算盈亏百分比
|
|
||||||
*/
|
|
||||||
private fun calculatePnlPercent(
|
|
||||||
totalBuyAmount: String,
|
totalBuyAmount: String,
|
||||||
totalRealizedPnl: String,
|
totalRealizedPnl: String
|
||||||
totalUnrealizedPnl: String
|
|
||||||
): String {
|
): String {
|
||||||
val buyAmount = totalBuyAmount.toSafeBigDecimal()
|
val buyAmount = totalBuyAmount.toSafeBigDecimal()
|
||||||
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
|
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
|
||||||
|
|
||||||
val totalPnl = totalRealizedPnl.toSafeBigDecimal().add(totalUnrealizedPnl.toSafeBigDecimal())
|
val percent = totalRealizedPnl.toSafeBigDecimal().div(buyAmount).multi(100)
|
||||||
val percent = totalPnl.div(buyAmount).multi(100)
|
|
||||||
|
|
||||||
return percent.setScale(2, RoundingMode.HALF_UP).toString()
|
return percent.setScale(2, RoundingMode.HALF_UP).toString()
|
||||||
}
|
}
|
||||||
@@ -743,7 +583,23 @@ class CopyTradingStatisticsService(
|
|||||||
// 2. 获取所有买入订单
|
// 2. 获取所有买入订单
|
||||||
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
var orders = copyOrderTrackingRepository.findByCopyTradingId(request.copyTradingId)
|
||||||
|
|
||||||
// 3. 按市场ID分组
|
// 3. 批量获取市场信息(用于筛选)
|
||||||
|
val allMarketIds = orders.map { it.marketId }.distinct()
|
||||||
|
val markets = marketService.getMarkets(allMarketIds)
|
||||||
|
|
||||||
|
// 4. 筛选
|
||||||
|
if (!request.marketId.isNullOrBlank()) {
|
||||||
|
// marketId 支持模糊匹配
|
||||||
|
orders = orders.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
if (!request.marketTitle.isNullOrBlank()) {
|
||||||
|
// marketTitle 关键字筛选
|
||||||
|
orders = orders.filter { order ->
|
||||||
|
val market = markets[order.marketId]
|
||||||
|
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 5. 按市场ID分组
|
||||||
val groups = mutableMapOf<String, MutableList<CopyOrderTracking>>()
|
val groups = mutableMapOf<String, MutableList<CopyOrderTracking>>()
|
||||||
orders.forEach { order ->
|
orders.forEach { order ->
|
||||||
val marketId = order.marketId
|
val marketId = order.marketId
|
||||||
@@ -755,23 +611,22 @@ class CopyTradingStatisticsService(
|
|||||||
|
|
||||||
// 4. 转换为分组数据并计算统计信息
|
// 4. 转换为分组数据并计算统计信息
|
||||||
val marketIds = groups.keys.toList()
|
val marketIds = groups.keys.toList()
|
||||||
val markets = marketService.getMarkets(marketIds)
|
|
||||||
|
|
||||||
val list = marketIds.map { marketId ->
|
val list = marketIds.map { marketId ->
|
||||||
val marketOrders = groups[marketId] ?: mutableListOf()
|
val marketOrders = groups[marketId] ?: mutableListOf()
|
||||||
|
|
||||||
// 计算统计信息
|
// 计算统计信息
|
||||||
val count = marketOrders.size.toLong()
|
val count = marketOrders.size.toLong()
|
||||||
val totalAmount = marketOrders.sumOf { order ->
|
val totalAmount = marketOrders.sumOf { order ->
|
||||||
order.quantity.toSafeBigDecimal().multi(order.price)
|
order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算订单状态统计
|
// 计算订单状态统计
|
||||||
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
val fullyMatchedCount = marketOrders.count { it.status == "fully_matched" }
|
||||||
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
val partiallyMatchedCount = marketOrders.count { it.status == "partially_matched" }
|
||||||
val filledCount = marketOrders.count { it.status == "filled" }
|
val filledCount = marketOrders.count { it.status == "filled" }
|
||||||
val fullyMatched = fullyMatchedCount == marketOrders.size
|
val fullyMatched = fullyMatchedCount == marketOrders.size
|
||||||
|
|
||||||
val stats = MarketOrderStats(
|
val stats = MarketOrderStats(
|
||||||
count = count,
|
count = count,
|
||||||
totalAmount = totalAmount.toString(),
|
totalAmount = totalAmount.toString(),
|
||||||
@@ -781,10 +636,10 @@ class CopyTradingStatisticsService(
|
|||||||
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
partiallyMatchedCount = partiallyMatchedCount.toLong(),
|
||||||
filledCount = filledCount.toLong()
|
filledCount = filledCount.toLong()
|
||||||
)
|
)
|
||||||
|
|
||||||
// 排序(按创建时间倒序)
|
// 排序(按创建时间倒序)
|
||||||
marketOrders.sortByDescending { it.createdAt }
|
marketOrders.sortByDescending { it.createdAt }
|
||||||
|
|
||||||
// 转换为 DTO
|
// 转换为 DTO
|
||||||
val orderDtos = marketOrders.map { order ->
|
val orderDtos = marketOrders.map { order ->
|
||||||
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
val amount = order.quantity.toSafeBigDecimal().multi(order.price)
|
||||||
@@ -807,7 +662,7 @@ class CopyTradingStatisticsService(
|
|||||||
createdAt = order.createdAt
|
createdAt = order.createdAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
MarketOrderGroup(
|
MarketOrderGroup(
|
||||||
marketId = marketId,
|
marketId = marketId,
|
||||||
marketTitle = markets[marketId]?.title,
|
marketTitle = markets[marketId]?.title,
|
||||||
@@ -817,27 +672,35 @@ class CopyTradingStatisticsService(
|
|||||||
stats = stats,
|
stats = stats,
|
||||||
orders = orderDtos as List<Any>
|
orders = orderDtos as List<Any>
|
||||||
)
|
)
|
||||||
}.sortedByDescending { it.stats.count }
|
}.sortedByDescending { group ->
|
||||||
|
// 找出该市场最近的买入订单时间
|
||||||
|
group.orders.mapNotNull { order ->
|
||||||
|
when (order) {
|
||||||
|
is BuyOrderInfo -> order.createdAt
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.maxOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 分页
|
// 5. 分页
|
||||||
val page = (request.page ?: 1)
|
val page = (request.page ?: 1)
|
||||||
val limit = request.limit ?: 20
|
val limit = request.limit ?: 20
|
||||||
val total = list.size.toLong()
|
val total = list.size.toLong()
|
||||||
|
|
||||||
val start = (page - 1) * limit
|
val start = (page - 1) * limit
|
||||||
val end = minOf(start + limit, list.size)
|
val end = minOf(start + limit, list.size)
|
||||||
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
val pagedList = if (start < list.size) list.subList(start, end) else emptyList()
|
||||||
|
|
||||||
val response = MarketGroupedOrdersResponse(
|
val response = MarketGroupedOrdersResponse(
|
||||||
list = pagedList,
|
list = pagedList,
|
||||||
total = total,
|
total = total,
|
||||||
page = page,
|
page = page,
|
||||||
limit = limit
|
limit = limit
|
||||||
)
|
)
|
||||||
|
|
||||||
Result.success(response)
|
Result.success(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("获取按市场分组的买入订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
logger.error("获取按市场分组的卖出订单列表失败: copyTradingId=${request.copyTradingId}", e)
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,9 +715,25 @@ class CopyTradingStatisticsService(
|
|||||||
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
|
?: return Result.failure(IllegalArgumentException("跟单关系不存在: ${request.copyTradingId}"))
|
||||||
|
|
||||||
// 2. 获取所有卖出记录
|
// 2. 获取所有卖出记录
|
||||||
val sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
var sellRecords = sellMatchRecordRepository.findByCopyTradingId(request.copyTradingId)
|
||||||
|
|
||||||
// 3. 按市场ID分组
|
// 3. 批量获取市场信息(用于筛选)
|
||||||
|
val allMarketIds = sellRecords.map { it.marketId }.distinct()
|
||||||
|
val markets = marketService.getMarkets(allMarketIds)
|
||||||
|
|
||||||
|
// 4. 筛选
|
||||||
|
if (!request.marketId.isNullOrBlank()) {
|
||||||
|
// marketId 支持模糊匹配
|
||||||
|
sellRecords = sellRecords.filter { it.marketId.contains(request.marketId!!, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
if (!request.marketTitle.isNullOrBlank()) {
|
||||||
|
// marketTitle 关键字筛选
|
||||||
|
sellRecords = sellRecords.filter { record ->
|
||||||
|
val market = markets[record.marketId]
|
||||||
|
market?.title?.contains(request.marketTitle!!, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 5. 按市场ID分组
|
||||||
val groups = mutableMapOf<String, MutableList<SellMatchRecord>>()
|
val groups = mutableMapOf<String, MutableList<SellMatchRecord>>()
|
||||||
sellRecords.forEach { record ->
|
sellRecords.forEach { record ->
|
||||||
val marketId = record.marketId
|
val marketId = record.marketId
|
||||||
@@ -866,7 +745,6 @@ class CopyTradingStatisticsService(
|
|||||||
|
|
||||||
// 4. 转换为分组数据并计算统计信息
|
// 4. 转换为分组数据并计算统计信息
|
||||||
val marketIds = groups.keys.toList()
|
val marketIds = groups.keys.toList()
|
||||||
val markets = marketService.getMarkets(marketIds)
|
|
||||||
|
|
||||||
val list = marketIds.map { marketId ->
|
val list = marketIds.map { marketId ->
|
||||||
val marketRecords = groups[marketId] ?: mutableListOf()
|
val marketRecords = groups[marketId] ?: mutableListOf()
|
||||||
@@ -921,7 +799,15 @@ class CopyTradingStatisticsService(
|
|||||||
stats = stats,
|
stats = stats,
|
||||||
orders = orderDtos as List<Any>
|
orders = orderDtos as List<Any>
|
||||||
)
|
)
|
||||||
}.sortedByDescending { it.stats.count }
|
}.sortedByDescending { group ->
|
||||||
|
// 找出该市场最近的卖出订单时间(与买入订单分组排序规则一致)
|
||||||
|
group.orders.mapNotNull { order ->
|
||||||
|
when (order) {
|
||||||
|
is SellOrderInfo -> order.createdAt
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}.maxOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 分页
|
// 5. 分页
|
||||||
val page = (request.page ?: 1)
|
val page = (request.page ?: 1)
|
||||||
|
|||||||
+372
-181
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -62,7 +62,8 @@ class CopyTradingTemplateService(
|
|||||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal(),
|
||||||
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
maxSpread = request.maxSpread?.toSafeBigDecimal(),
|
||||||
minPrice = request.minPrice?.toSafeBigDecimal(),
|
minPrice = request.minPrice?.toSafeBigDecimal(),
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal()
|
maxPrice = request.maxPrice?.toSafeBigDecimal(),
|
||||||
|
pushFilteredOrders = request.pushFilteredOrders ?: false
|
||||||
)
|
)
|
||||||
|
|
||||||
val saved = templateRepository.save(template)
|
val saved = templateRepository.save(template)
|
||||||
@@ -121,6 +122,7 @@ class CopyTradingTemplateService(
|
|||||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: template.maxSpread,
|
||||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
minPrice = request.minPrice?.toSafeBigDecimal() ?: template.minPrice,
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: template.maxPrice,
|
||||||
|
pushFilteredOrders = request.pushFilteredOrders ?: template.pushFilteredOrders,
|
||||||
updatedAt = System.currentTimeMillis()
|
updatedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,7 +188,8 @@ class CopyTradingTemplateService(
|
|||||||
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
|
minOrderDepth = request.minOrderDepth?.toSafeBigDecimal() ?: sourceTemplate.minOrderDepth,
|
||||||
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
|
maxSpread = request.maxSpread?.toSafeBigDecimal() ?: sourceTemplate.maxSpread,
|
||||||
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
|
minPrice = request.minPrice?.toSafeBigDecimal() ?: sourceTemplate.minPrice,
|
||||||
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice
|
maxPrice = request.maxPrice?.toSafeBigDecimal() ?: sourceTemplate.maxPrice,
|
||||||
|
pushFilteredOrders = request.pushFilteredOrders ?: sourceTemplate.pushFilteredOrders
|
||||||
)
|
)
|
||||||
|
|
||||||
val saved = templateRepository.save(newTemplate)
|
val saved = templateRepository.save(newTemplate)
|
||||||
@@ -260,6 +263,7 @@ class CopyTradingTemplateService(
|
|||||||
maxSpread = template.maxSpread?.toPlainString(),
|
maxSpread = template.maxSpread?.toPlainString(),
|
||||||
minPrice = template.minPrice?.toPlainString(),
|
minPrice = template.minPrice?.toPlainString(),
|
||||||
maxPrice = template.maxPrice?.toPlainString(),
|
maxPrice = template.maxPrice?.toPlainString(),
|
||||||
|
pushFilteredOrders = template.pushFilteredOrders,
|
||||||
createdAt = template.createdAt,
|
createdAt = template.createdAt,
|
||||||
updatedAt = template.updatedAt
|
updatedAt = template.updatedAt
|
||||||
)
|
)
|
||||||
|
|||||||
+843
@@ -0,0 +1,843 @@
|
|||||||
|
package com.wrbug.polymarketbot.service.cryptotail
|
||||||
|
|
||||||
|
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||||
|
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
|
||||||
|
import com.wrbug.polymarketbot.dto.CryptoTailMonitorPushData
|
||||||
|
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||||
|
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||||
|
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||||
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||||
|
import com.wrbug.polymarketbot.service.binance.BinanceKlineService
|
||||||
|
import com.wrbug.polymarketbot.service.common.WebSocketSubscriptionService
|
||||||
|
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||||
|
import com.wrbug.polymarketbot.util.createClient
|
||||||
|
import com.wrbug.polymarketbot.util.fromJson
|
||||||
|
import com.wrbug.polymarketbot.util.toJson
|
||||||
|
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import jakarta.annotation.PostConstruct
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略监控服务
|
||||||
|
* 负责实时推送监控数据到前端
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class CryptoTailMonitorService(
|
||||||
|
private val strategyRepository: CryptoTailStrategyRepository,
|
||||||
|
private val accountRepository: AccountRepository,
|
||||||
|
private val retrofitFactory: RetrofitFactory,
|
||||||
|
private val binanceKlineService: BinanceKlineService,
|
||||||
|
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||||
|
private val webSocketSubscriptionService: WebSocketSubscriptionService
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val logger = LoggerFactory.getLogger(CryptoTailMonitorService::class.java)
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
|
/** 当前周期 token 映射 */
|
||||||
|
private val currentPeriodTokenToStrategy = AtomicReference<Map<String, List<MonitorEntry>>>(emptyMap())
|
||||||
|
|
||||||
|
/** 下一周期 token 映射 */
|
||||||
|
private val nextPeriodTokenToStrategy = AtomicReference<Map<String, List<MonitorEntry>>>(emptyMap())
|
||||||
|
|
||||||
|
/** strategyId -> 当前价格数据 */
|
||||||
|
private val strategyPriceData = ConcurrentHashMap<Long, StrategyPriceData>()
|
||||||
|
|
||||||
|
/** strategyId -> 订阅者数量 */
|
||||||
|
private val strategySubscribers = ConcurrentHashMap<Long, Int>()
|
||||||
|
|
||||||
|
private var currentPeriodWebSocket: WebSocket? = null
|
||||||
|
private var nextPeriodWebSocket: WebSocket? = null
|
||||||
|
private val wsUrl = PolymarketConstants.RTDS_WS_URL + "/ws/market"
|
||||||
|
|
||||||
|
private val client by lazy {
|
||||||
|
createClient().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val reconnectDelayMs = 3_000L
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
private val closedForNoSubscribers = AtomicBoolean(false)
|
||||||
|
private val connectLock = Any()
|
||||||
|
|
||||||
|
/** 防止 refreshSubscription 并发执行(周期结束时定时器与消息可能同时触发) */
|
||||||
|
private val refreshSubscriptionMutex = Mutex()
|
||||||
|
|
||||||
|
/** 周期结束倒计时 Job */
|
||||||
|
private var periodEndCountdownJob: Job? = null
|
||||||
|
|
||||||
|
/** 定时推送 Job(每 1.5 秒推送一次,保证 BTC 价格和分时图持续更新) */
|
||||||
|
private var periodicPushJob: Job? = null
|
||||||
|
private val pushIntervalMs = 1_500L
|
||||||
|
|
||||||
|
/** 策略推送历史(用于中途进入时补全分时图,最多保留 300 条) */
|
||||||
|
private val strategyPushHistory = ConcurrentHashMap<Long, MutableList<CryptoTailMonitorPushData>>()
|
||||||
|
private val strategyHistoryPeriod = ConcurrentHashMap<Long, Long>()
|
||||||
|
private val maxHistorySize = 300
|
||||||
|
|
||||||
|
/** price_change 推送节流:每策略最近一次推送时间,1s 内不重复推送 */
|
||||||
|
private val lastPriceChangePushTime = ConcurrentHashMap<Long, Long>()
|
||||||
|
private val priceChangePushThrottleMs = 1_000L
|
||||||
|
|
||||||
|
/** 当前周期/下一周期构建时缓存的市场标题,key = "strategyId-periodStartUnix",供推送携带 */
|
||||||
|
private val marketTitleByStrategyPeriod = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
data class MonitorEntry(
|
||||||
|
val strategyId: Long,
|
||||||
|
val strategy: CryptoTailStrategy,
|
||||||
|
val periodStartUnix: Long,
|
||||||
|
val outcomeIndex: Int,
|
||||||
|
val tokenId: String,
|
||||||
|
/** 是否为下一个周期(用于预先订阅) */
|
||||||
|
val isNextPeriod: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StrategyPriceData(
|
||||||
|
val currentPriceUp: BigDecimal? = null,
|
||||||
|
val currentPriceDown: BigDecimal? = null,
|
||||||
|
/** BTC 开盘价 USDC(币安 K 线 open) */
|
||||||
|
val openPriceBtc: BigDecimal? = null,
|
||||||
|
val spreadUp: BigDecimal? = null,
|
||||||
|
val spreadDown: BigDecimal? = null,
|
||||||
|
val minSpreadLineUp: BigDecimal? = null,
|
||||||
|
val minSpreadLineDown: BigDecimal? = null,
|
||||||
|
val triggered: Boolean = false,
|
||||||
|
val triggerDirection: String? = null,
|
||||||
|
val lastUpdateTime: Long = System.currentTimeMillis(),
|
||||||
|
/** 当前周期开始时间(用于双连接周期切换) */
|
||||||
|
val periodStartUnix: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
fun init() {
|
||||||
|
// 服务启动时不主动连接,等待前端订阅
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化监控数据
|
||||||
|
*/
|
||||||
|
fun initMonitor(request: CryptoTailMonitorInitRequest): Result<CryptoTailMonitorInitResponse> {
|
||||||
|
return try {
|
||||||
|
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
|
||||||
|
if (strategy == null) {
|
||||||
|
return Result.failure(IllegalArgumentException("策略不存在"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val account = accountRepository.findById(strategy.accountId).orElse(null)
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val periodStartUnix = request.periodStartUnix
|
||||||
|
?: ((nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds)
|
||||||
|
|
||||||
|
// 获取市场信息
|
||||||
|
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||||
|
val event = fetchEventBySlug(slug).getOrNull()
|
||||||
|
val market = event?.markets?.firstOrNull()
|
||||||
|
val tokenIds = parseClobTokenIds(market?.clobTokenIds)
|
||||||
|
|
||||||
|
// 获取开盘价(币安 K 线 open = BTC 价格 USDC)
|
||||||
|
val openClose = binanceKlineService.getCurrentOpenClose(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
val openPriceBtc = openClose?.first
|
||||||
|
|
||||||
|
// 获取自动计算的最小价差
|
||||||
|
var autoMinSpreadUp: BigDecimal? = null
|
||||||
|
var autoMinSpreadDown: BigDecimal? = null
|
||||||
|
if (strategy.spreadMode.name.uppercase() == "AUTO") {
|
||||||
|
val autoSpreads = binanceKlineAutoSpreadService.computeAndCache(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
autoMinSpreadUp = autoSpreads?.first
|
||||||
|
autoMinSpreadDown = autoSpreads?.second
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存价格数据到缓存
|
||||||
|
val priceData = StrategyPriceData(
|
||||||
|
openPriceBtc = openPriceBtc,
|
||||||
|
minSpreadLineUp = autoMinSpreadUp ?: strategy.spreadValue?.toSafeBigDecimal(),
|
||||||
|
minSpreadLineDown = autoMinSpreadDown ?: strategy.spreadValue?.toSafeBigDecimal(),
|
||||||
|
periodStartUnix = periodStartUnix
|
||||||
|
)
|
||||||
|
strategyPriceData[strategy.id!!] = priceData
|
||||||
|
|
||||||
|
val response = CryptoTailMonitorInitResponse(
|
||||||
|
strategyId = strategy.id!!,
|
||||||
|
name = strategy.name ?: "",
|
||||||
|
accountId = strategy.accountId,
|
||||||
|
accountName = account?.accountName ?: "",
|
||||||
|
marketSlugPrefix = strategy.marketSlugPrefix,
|
||||||
|
marketTitle = event?.title ?: strategy.marketSlugPrefix,
|
||||||
|
intervalSeconds = strategy.intervalSeconds,
|
||||||
|
periodStartUnix = periodStartUnix,
|
||||||
|
windowStartSeconds = strategy.windowStartSeconds,
|
||||||
|
windowEndSeconds = strategy.windowEndSeconds,
|
||||||
|
minPrice = strategy.minPrice.toPlainString(),
|
||||||
|
maxPrice = strategy.maxPrice.toPlainString(),
|
||||||
|
minSpreadMode = strategy.spreadMode.name,
|
||||||
|
spreadDirection = strategy.spreadDirection.name,
|
||||||
|
minSpreadValue = strategy.spreadValue?.toPlainString(),
|
||||||
|
autoMinSpreadUp = autoMinSpreadUp?.toPlainString(),
|
||||||
|
autoMinSpreadDown = autoMinSpreadDown?.toPlainString(),
|
||||||
|
openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
tokenIdUp = tokenIds.getOrNull(0),
|
||||||
|
tokenIdDown = tokenIds.getOrNull(1),
|
||||||
|
currentTimestamp = System.currentTimeMillis(),
|
||||||
|
enabled = strategy.enabled,
|
||||||
|
amountMode = strategy.amountMode,
|
||||||
|
amountValue = strategy.amountValue.toPlainString()
|
||||||
|
)
|
||||||
|
|
||||||
|
Result.success(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("初始化监控失败: ${e.message}", e)
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅策略监控
|
||||||
|
*/
|
||||||
|
fun subscribe(sessionId: String, strategyId: Long, callback: (CryptoTailMonitorPushData) -> Unit) {
|
||||||
|
// 增加订阅计数
|
||||||
|
val count = strategySubscribers.merge(strategyId, 1) { old, inc -> old + inc } ?: 1
|
||||||
|
|
||||||
|
// 注册推送回调
|
||||||
|
webSocketSubscriptionService.registerMonitorCallback(sessionId, strategyId, callback)
|
||||||
|
|
||||||
|
// 如果是第一个订阅者,启动 WebSocket 和定时推送
|
||||||
|
if (count == 1) {
|
||||||
|
scope.launch {
|
||||||
|
refreshSubscription()
|
||||||
|
}
|
||||||
|
startPeriodicPush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即发送当前数据
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
sendCurrentData(sessionId, strategyId, callback)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("发送当前监控数据失败: $sessionId, ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订阅策略监控
|
||||||
|
*/
|
||||||
|
fun unsubscribe(sessionId: String, strategyId: Long) {
|
||||||
|
// 减少订阅计数
|
||||||
|
val currentCount = strategySubscribers[strategyId] ?: 0
|
||||||
|
val newCount = (currentCount - 1).coerceAtLeast(0)
|
||||||
|
|
||||||
|
if (newCount == 0) {
|
||||||
|
strategySubscribers.remove(strategyId)
|
||||||
|
} else {
|
||||||
|
strategySubscribers[strategyId] = newCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除回调
|
||||||
|
webSocketSubscriptionService.unregisterMonitorCallback(sessionId, strategyId)
|
||||||
|
|
||||||
|
// 如果没有订阅者,关闭 WebSocket 和定时推送
|
||||||
|
if (newCount == 0) {
|
||||||
|
scope.launch {
|
||||||
|
refreshSubscription()
|
||||||
|
}
|
||||||
|
stopPeriodicPush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPeriodicPush() {
|
||||||
|
if (periodicPushJob?.isActive == true) return
|
||||||
|
periodicPushJob = scope.launch {
|
||||||
|
while (strategySubscribers.isNotEmpty() && strategySubscribers.values.any { (it ?: 0) > 0 }) {
|
||||||
|
delay(pushIntervalMs)
|
||||||
|
if (closedForNoSubscribers.get()) continue
|
||||||
|
val ids = strategySubscribers.filter { (it.value ?: 0) > 0 }.keys.toList()
|
||||||
|
for (strategyId in ids) {
|
||||||
|
try {
|
||||||
|
val strategy = strategyRepository.findById(strategyId).orElse(null) ?: continue
|
||||||
|
val priceData = strategyPriceData[strategyId] ?: continue
|
||||||
|
val pushData = buildPushData(strategy, priceData)
|
||||||
|
addToHistoryAndPush(strategyId, pushData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.debug("定时推送失败 strategyId=$strategyId: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPeriodicPush() {
|
||||||
|
if (strategySubscribers.isEmpty() || strategySubscribers.values.all { (it ?: 0) <= 0 }) {
|
||||||
|
periodicPushJob?.cancel()
|
||||||
|
periodicPushJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送当前数据(含历史补全,用于中途进入时填充分时图)
|
||||||
|
*/
|
||||||
|
private suspend fun sendCurrentData(
|
||||||
|
sessionId: String,
|
||||||
|
strategyId: Long,
|
||||||
|
callback: (CryptoTailMonitorPushData) -> Unit
|
||||||
|
) {
|
||||||
|
val strategy = strategyRepository.findById(strategyId).orElse(null) ?: return
|
||||||
|
val priceData = strategyPriceData[strategyId] ?: StrategyPriceData()
|
||||||
|
|
||||||
|
val history = strategyPushHistory[strategyId]?.let { list ->
|
||||||
|
synchronized(list) { list.toList() }
|
||||||
|
} ?: emptyList()
|
||||||
|
for (item in history) {
|
||||||
|
callback(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pushData = buildPushData(strategy, priceData)
|
||||||
|
callback(pushData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新订阅:双连接模式。当前周期连接 + 下一周期连接;周期切换时关闭过期连接,下一连接晋升为当前,并新建下一周期连接。
|
||||||
|
* 使用 Mutex 防止周期结束时 scheduleRefreshAtPeriodEnd 与 maybeRefreshSubscriptionIfPeriodChanged 同时触发导致重复执行。
|
||||||
|
*/
|
||||||
|
private suspend fun refreshSubscription() {
|
||||||
|
if (!refreshSubscriptionMutex.tryLock()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
refreshSubscriptionInternal()
|
||||||
|
} finally {
|
||||||
|
refreshSubscriptionMutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshSubscriptionInternal() {
|
||||||
|
periodEndCountdownJob?.cancel()
|
||||||
|
periodEndCountdownJob = null
|
||||||
|
|
||||||
|
val subscribedStrategyIds = strategySubscribers.keys.filter { (strategySubscribers[it] ?: 0) > 0 }
|
||||||
|
if (subscribedStrategyIds.isEmpty()) {
|
||||||
|
closeAllWebSockets()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val strategies = strategyRepository.findAllById(subscribedStrategyIds).filter { it.enabled && it.id != null }
|
||||||
|
if (strategies.isEmpty()) {
|
||||||
|
closeAllWebSockets()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val isSwitch = currentPeriodWebSocket != null
|
||||||
|
|
||||||
|
if (isSwitch) {
|
||||||
|
// 周期切换:关闭当前周期连接,下一晋升为当前,新建下一周期连接
|
||||||
|
closeCurrentPeriodWebSocket()
|
||||||
|
currentPeriodWebSocket = nextPeriodWebSocket
|
||||||
|
nextPeriodWebSocket = null
|
||||||
|
val nextMap = nextPeriodTokenToStrategy.get()
|
||||||
|
currentPeriodTokenToStrategy.set(nextMap)
|
||||||
|
val nextPeriodByStrategy =
|
||||||
|
nextMap.values.flatten().distinctBy { it.strategyId }.associate { it.strategyId to it.periodStartUnix }
|
||||||
|
logger.info("周期切换:下一周期连接晋升为当前")
|
||||||
|
for ((strategyId, periodStartUnix) in nextPeriodByStrategy) {
|
||||||
|
updateStrategyPriceDataForPeriod(listOf(strategyId), periodStartUnix, pushDefault = true)
|
||||||
|
}
|
||||||
|
val (newNextTokenIds, newNextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds)
|
||||||
|
nextPeriodTokenToStrategy.set(newNextMap)
|
||||||
|
if (newNextTokenIds.isNotEmpty()) {
|
||||||
|
connectNextPeriod(newNextTokenIds, newNextMap)
|
||||||
|
} else {
|
||||||
|
logger.info("下一周期市场尚未创建,仅建立空连接以便周期切换时复用")
|
||||||
|
connectNextPeriod(emptyList(), emptyMap())
|
||||||
|
}
|
||||||
|
scheduleRefreshAtPeriodEnd(if (newNextMap.isNotEmpty()) newNextMap else nextMap)
|
||||||
|
} else {
|
||||||
|
// 首次:建立当前周期连接 + 下一周期连接
|
||||||
|
val (currentTokenIds, currentMap) = buildSubscriptionMapForCurrentPeriod(subscribedStrategyIds)
|
||||||
|
currentPeriodTokenToStrategy.set(currentMap)
|
||||||
|
for (entry in currentMap.values.flatten().distinctBy { it.strategyId }) {
|
||||||
|
updateStrategyPriceDataForPeriod(listOf(entry.strategyId), entry.periodStartUnix, pushDefault = false)
|
||||||
|
}
|
||||||
|
if (currentTokenIds.isEmpty()) {
|
||||||
|
closeAllWebSockets()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectCurrentPeriod(currentTokenIds, currentMap)
|
||||||
|
val (nextTokenIds, nextMap) = buildSubscriptionMapForNextPeriod(subscribedStrategyIds)
|
||||||
|
nextPeriodTokenToStrategy.set(nextMap)
|
||||||
|
if (nextTokenIds.isNotEmpty()) {
|
||||||
|
connectNextPeriod(nextTokenIds, nextMap)
|
||||||
|
} else {
|
||||||
|
logger.info("下一周期市场尚未创建,先建立空连接,周期切换时会重新订阅")
|
||||||
|
connectNextPeriod(emptyList(), emptyMap())
|
||||||
|
}
|
||||||
|
scheduleRefreshAtPeriodEnd(currentMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建当前周期订阅(每个策略按自己的 interval 算当前周期) */
|
||||||
|
private suspend fun buildSubscriptionMapForCurrentPeriod(strategyIds: List<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
|
||||||
|
val strategies = strategyRepository.findAllById(strategyIds)
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val tokenIdSet = mutableSetOf<String>()
|
||||||
|
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
|
||||||
|
|
||||||
|
for (strategy in strategies) {
|
||||||
|
if (!strategy.enabled || strategy.id == null) continue
|
||||||
|
val strategyPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||||
|
val slug = "${strategy.marketSlugPrefix}-$strategyPeriod"
|
||||||
|
val event = fetchEventBySlug(slug).getOrNull() ?: continue
|
||||||
|
marketTitleByStrategyPeriod["${strategy.id!!}-$strategyPeriod"] = event.title ?: strategy.marketSlugPrefix
|
||||||
|
val market = event.markets?.firstOrNull() ?: continue
|
||||||
|
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||||
|
if (tokenIds.size < 2) continue
|
||||||
|
for (i in tokenIds.indices) {
|
||||||
|
tokenIdSet.add(tokenIds[i])
|
||||||
|
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||||
|
MonitorEntry(strategy.id!!, strategy, strategyPeriod, i, tokenIds[i], false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Pair(tokenIdSet.toList(), map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建下一周期订阅(每个策略按自己的 interval 算下一周期) */
|
||||||
|
private suspend fun buildSubscriptionMapForNextPeriod(strategyIds: List<Long>): Pair<List<String>, Map<String, List<MonitorEntry>>> {
|
||||||
|
val strategies = strategyRepository.findAllById(strategyIds)
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val tokenIdSet = mutableSetOf<String>()
|
||||||
|
val map = mutableMapOf<String, MutableList<MonitorEntry>>()
|
||||||
|
|
||||||
|
for (strategy in strategies) {
|
||||||
|
if (!strategy.enabled || strategy.id == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val currentPeriod = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||||
|
val nextPeriod = currentPeriod + strategy.intervalSeconds
|
||||||
|
val slug = "${strategy.marketSlugPrefix}-$nextPeriod"
|
||||||
|
val event = fetchEventBySlug(slug).getOrNull()
|
||||||
|
if (event == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
marketTitleByStrategyPeriod["${strategy.id!!}-$nextPeriod"] = event.title ?: strategy.marketSlugPrefix
|
||||||
|
val market = event.markets?.firstOrNull()
|
||||||
|
if (market == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val tokenIds = parseClobTokenIds(market.clobTokenIds)
|
||||||
|
if (tokenIds.size < 2) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (i in tokenIds.indices) {
|
||||||
|
tokenIdSet.add(tokenIds[i])
|
||||||
|
map.getOrPut(tokenIds[i]) { mutableListOf() }.add(
|
||||||
|
MonitorEntry(strategy.id!!, strategy, nextPeriod, i, tokenIds[i], true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Pair(tokenIdSet.toList(), map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新策略价格数据为指定周期(开盘价、价差线等),可选是否推送默认 0.5 */
|
||||||
|
private suspend fun updateStrategyPriceDataForPeriod(
|
||||||
|
strategyIds: List<Long>,
|
||||||
|
periodStartUnix: Long,
|
||||||
|
pushDefault: Boolean
|
||||||
|
) {
|
||||||
|
val strategies = strategyRepository.findAllById(strategyIds)
|
||||||
|
for (strategy in strategies) {
|
||||||
|
if (strategy.id == null) continue
|
||||||
|
val openClose = binanceKlineService.getCurrentOpenClose(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
val openPriceBtc = openClose?.first
|
||||||
|
var minSpreadLineUp: BigDecimal? = null
|
||||||
|
var minSpreadLineDown: BigDecimal? = null
|
||||||
|
when (strategy.spreadMode.name.uppercase()) {
|
||||||
|
"FIXED" -> {
|
||||||
|
minSpreadLineUp = strategy.spreadValue?.toSafeBigDecimal()
|
||||||
|
minSpreadLineDown = strategy.spreadValue?.toSafeBigDecimal()
|
||||||
|
}
|
||||||
|
|
||||||
|
"AUTO" -> {
|
||||||
|
val autoSpreads = binanceKlineAutoSpreadService.computeAndCache(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
minSpreadLineUp = autoSpreads?.first
|
||||||
|
minSpreadLineDown = autoSpreads?.second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val existingData = strategyPriceData[strategy.id] ?: StrategyPriceData()
|
||||||
|
val periodChanged = existingData.periodStartUnix != null && existingData.periodStartUnix != periodStartUnix
|
||||||
|
val newData = StrategyPriceData(
|
||||||
|
currentPriceUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceUp,
|
||||||
|
currentPriceDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.currentPriceDown,
|
||||||
|
spreadUp = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadUp,
|
||||||
|
spreadDown = if (periodChanged && pushDefault) BigDecimal("0.5") else existingData.spreadDown,
|
||||||
|
openPriceBtc = openPriceBtc,
|
||||||
|
minSpreadLineUp = minSpreadLineUp,
|
||||||
|
minSpreadLineDown = minSpreadLineDown,
|
||||||
|
periodStartUnix = periodStartUnix
|
||||||
|
)
|
||||||
|
strategyPriceData[strategy.id!!] = newData
|
||||||
|
if (periodChanged && pushDefault) {
|
||||||
|
val pushData = buildPushData(strategy, newData)
|
||||||
|
addToHistoryAndPush(strategy.id!!, pushData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectCurrentPeriod(tokenIds: List<String>, map: Map<String, List<MonitorEntry>>) {
|
||||||
|
if (currentPeriodWebSocket != null) return
|
||||||
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
|
currentPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
|
closedForNoSubscribers.set(false)
|
||||||
|
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||||
|
try {
|
||||||
|
webSocket.send(msg)
|
||||||
|
logger.info("加密价差策略监控 WebSocket(当前周期)已连接并订阅: ${tokenIds.size} 个 token")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("发送当前周期订阅失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
handleMessage(webSocket, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) {
|
||||||
|
this@CryptoTailMonitorService.currentPeriodWebSocket = null
|
||||||
|
if (!closedForNoSubscribers.get()) scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
|
if (this@CryptoTailMonitorService.currentPeriodWebSocket == webSocket) {
|
||||||
|
this@CryptoTailMonitorService.currentPeriodWebSocket = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectNextPeriod(tokenIds: List<String>, map: Map<String, List<MonitorEntry>>) {
|
||||||
|
if (nextPeriodWebSocket != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = Request.Builder().url(wsUrl).build()
|
||||||
|
nextPeriodWebSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||||
|
val msg = """{"type":"MARKET","assets_ids":${tokenIds.toJson()}}"""
|
||||||
|
try {
|
||||||
|
webSocket.send(msg)
|
||||||
|
if (tokenIds.isEmpty()) {
|
||||||
|
logger.info("加密价差策略监控 WebSocket(下一周期)已连接,暂无 token 订阅,等待周期切换后更新")
|
||||||
|
} else {
|
||||||
|
logger.info("加密价差策略监控 WebSocket(下一周期)已连接并订阅: ${tokenIds.size} 个 token")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn("发送下一周期订阅失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
handleMessage(webSocket, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) {
|
||||||
|
this@CryptoTailMonitorService.nextPeriodWebSocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
|
if (this@CryptoTailMonitorService.nextPeriodWebSocket == webSocket) {
|
||||||
|
this@CryptoTailMonitorService.nextPeriodWebSocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeCurrentPeriodWebSocket() {
|
||||||
|
currentPeriodWebSocket?.close(1000, "period_ended")
|
||||||
|
currentPeriodWebSocket = null
|
||||||
|
logger.info("加密价差策略监控 WebSocket(当前周期)已关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeAllWebSockets() {
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
closedForNoSubscribers.set(true)
|
||||||
|
currentPeriodWebSocket?.close(1000, "no_subscribers")
|
||||||
|
currentPeriodWebSocket = null
|
||||||
|
nextPeriodWebSocket?.close(1000, "no_subscribers")
|
||||||
|
nextPeriodWebSocket = null
|
||||||
|
logger.info("加密价差策略监控 WebSocket 已全部关闭(无订阅者)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMessage(webSocket: WebSocket, text: String) {
|
||||||
|
if (text == "pong" || text.isEmpty()) return
|
||||||
|
if (closedForNoSubscribers.get()) return
|
||||||
|
|
||||||
|
maybeRefreshSubscriptionIfPeriodChanged()
|
||||||
|
|
||||||
|
val json = text.fromJson<com.google.gson.JsonObject>() ?: return
|
||||||
|
val eventType = (json.get("event_type") as? com.google.gson.JsonPrimitive)?.asString ?: return
|
||||||
|
val map = currentPeriodTokenToStrategy.get()
|
||||||
|
|
||||||
|
when (eventType) {
|
||||||
|
"price_change" -> {
|
||||||
|
val priceChanges = json.get("price_changes") as? com.google.gson.JsonArray ?: return
|
||||||
|
for (i in 0 until priceChanges.size()) {
|
||||||
|
val pc = priceChanges.get(i) as? com.google.gson.JsonObject ?: continue
|
||||||
|
val assetId = (pc.get("asset_id") as? com.google.gson.JsonPrimitive)?.asString ?: continue
|
||||||
|
val bestBidStr = (pc.get("best_bid") as? com.google.gson.JsonPrimitive)?.asString
|
||||||
|
val bestBid = bestBidStr?.toSafeBigDecimal()
|
||||||
|
if (bestBid != null) onPriceUpdate(assetId, bestBid, map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPriceUpdate(tokenId: String, bestBid: BigDecimal, map: Map<String, List<MonitorEntry>>) {
|
||||||
|
if (closedForNoSubscribers.get()) return
|
||||||
|
val entries = map[tokenId] ?: return
|
||||||
|
|
||||||
|
for (entry in entries) {
|
||||||
|
val strategy = entry.strategy
|
||||||
|
val priceData = strategyPriceData[strategy.id!!] ?: StrategyPriceData()
|
||||||
|
|
||||||
|
// 根据方向更新价格
|
||||||
|
val newPriceData = if (entry.outcomeIndex == 0) {
|
||||||
|
// Up 方向
|
||||||
|
priceData.copy(
|
||||||
|
currentPriceUp = bestBid,
|
||||||
|
currentPriceDown = BigDecimal.ONE.subtract(bestBid),
|
||||||
|
spreadUp = BigDecimal.ONE.subtract(bestBid),
|
||||||
|
spreadDown = bestBid,
|
||||||
|
lastUpdateTime = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Down 方向
|
||||||
|
priceData.copy(
|
||||||
|
currentPriceDown = bestBid,
|
||||||
|
currentPriceUp = BigDecimal.ONE.subtract(bestBid),
|
||||||
|
spreadUp = bestBid,
|
||||||
|
spreadDown = BigDecimal.ONE.subtract(bestBid),
|
||||||
|
lastUpdateTime = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
strategyPriceData[strategy.id!!] = newPriceData
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val last = lastPriceChangePushTime[strategy.id!!] ?: 0L
|
||||||
|
if (now - last >= priceChangePushThrottleMs) {
|
||||||
|
lastPriceChangePushTime[strategy.id!!] = now
|
||||||
|
val pushData = buildPushData(strategy, newPriceData)
|
||||||
|
addToHistoryAndPush(strategy.id!!, pushData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addToHistoryAndPush(strategyId: Long, pushData: CryptoTailMonitorPushData) {
|
||||||
|
addToHistory(strategyId, pushData)
|
||||||
|
webSocketSubscriptionService.pushMonitorData(strategyId, pushData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addToHistory(strategyId: Long, pushData: CryptoTailMonitorPushData) {
|
||||||
|
val list = strategyPushHistory.getOrPut(strategyId) {
|
||||||
|
Collections.synchronizedList(mutableListOf<CryptoTailMonitorPushData>())
|
||||||
|
}
|
||||||
|
synchronized(list) {
|
||||||
|
val lastPeriod = strategyHistoryPeriod[strategyId]
|
||||||
|
if (lastPeriod != null && lastPeriod != pushData.periodStartUnix) {
|
||||||
|
list.clear()
|
||||||
|
}
|
||||||
|
strategyHistoryPeriod[strategyId] = pushData.periodStartUnix
|
||||||
|
list.add(pushData)
|
||||||
|
while (list.size > maxHistorySize) {
|
||||||
|
list.removeAt(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建推送数据
|
||||||
|
* 最新价、价差使用币安 K 线的 BTC 价格(open/close)
|
||||||
|
*/
|
||||||
|
private fun buildPushData(strategy: CryptoTailStrategy, priceData: StrategyPriceData): CryptoTailMonitorPushData {
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||||
|
val periodEndUnix = periodStartUnix + strategy.intervalSeconds
|
||||||
|
val remainingSeconds = (periodEndUnix - nowSeconds).toInt().coerceAtLeast(0)
|
||||||
|
|
||||||
|
val windowStart = periodStartUnix + strategy.windowStartSeconds
|
||||||
|
val windowEnd = periodStartUnix + strategy.windowEndSeconds
|
||||||
|
val inTimeWindow = nowSeconds >= windowStart && nowSeconds < windowEnd
|
||||||
|
|
||||||
|
// 币安 K 线:open = 周期开盘价,close = 当前最新价(实时更新)
|
||||||
|
val openClose = binanceKlineService.getCurrentOpenClose(
|
||||||
|
strategy.marketSlugPrefix,
|
||||||
|
strategy.intervalSeconds,
|
||||||
|
periodStartUnix
|
||||||
|
)
|
||||||
|
val openPriceBtc = priceData.openPriceBtc ?: openClose?.first
|
||||||
|
val currentPriceBtc = openClose?.second
|
||||||
|
// K 线数据回来后更新缓存,供后续使用
|
||||||
|
if (openPriceBtc != null && priceData.openPriceBtc == null && strategy.id != null) {
|
||||||
|
strategyPriceData[strategy.id] = priceData.copy(openPriceBtc = openPriceBtc)
|
||||||
|
}
|
||||||
|
val spreadBtc = if (openPriceBtc != null && currentPriceBtc != null) {
|
||||||
|
currentPriceBtc.subtract(openPriceBtc)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
// 判断价格区间(Polymarket 0-1)
|
||||||
|
val currentUp = priceData.currentPriceUp
|
||||||
|
val currentDown = priceData.currentPriceDown
|
||||||
|
val inPriceRangeUp = currentUp != null &&
|
||||||
|
currentUp >= strategy.minPrice && currentUp <= strategy.maxPrice
|
||||||
|
val inPriceRangeDown = currentDown != null &&
|
||||||
|
currentDown >= strategy.minPrice && currentDown <= strategy.maxPrice
|
||||||
|
|
||||||
|
val marketTitle = marketTitleByStrategyPeriod["${strategy.id!!}-$periodStartUnix"] ?: strategy.marketSlugPrefix
|
||||||
|
|
||||||
|
return CryptoTailMonitorPushData(
|
||||||
|
strategyId = strategy.id!!,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
periodStartUnix = periodStartUnix,
|
||||||
|
marketTitle = marketTitle,
|
||||||
|
currentPriceUp = priceData.currentPriceUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
currentPriceDown = priceData.currentPriceDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
spreadUp = priceData.spreadUp?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
spreadDown = priceData.spreadDown?.setScale(4, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
minSpreadLineUp = priceData.minSpreadLineUp?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
minSpreadLineDown = priceData.minSpreadLineDown?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
openPriceBtc = openPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
currentPriceBtc = currentPriceBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
spreadBtc = spreadBtc?.setScale(2, RoundingMode.HALF_UP)?.toPlainString(),
|
||||||
|
remainingSeconds = remainingSeconds,
|
||||||
|
inTimeWindow = inTimeWindow,
|
||||||
|
inPriceRangeUp = inPriceRangeUp,
|
||||||
|
inPriceRangeDown = inPriceRangeDown,
|
||||||
|
triggered = priceData.triggered,
|
||||||
|
triggerDirection = priceData.triggerDirection,
|
||||||
|
periodEnded = remainingSeconds <= 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maybeRefreshSubscriptionIfPeriodChanged() {
|
||||||
|
val subscribed = currentPeriodTokenToStrategy.get().values.flatten().distinctBy { it.strategyId }
|
||||||
|
.associate { it.strategyId to it.periodStartUnix }
|
||||||
|
if (subscribed.isEmpty()) return
|
||||||
|
|
||||||
|
val strategies = strategyRepository.findAllById(subscribed.keys)
|
||||||
|
val nowSeconds = System.currentTimeMillis() / 1000
|
||||||
|
|
||||||
|
for (s in strategies) {
|
||||||
|
if (s.id == null) continue
|
||||||
|
val currentPeriod = (nowSeconds / s.intervalSeconds) * s.intervalSeconds
|
||||||
|
val subPeriod = subscribed[s.id] ?: continue
|
||||||
|
if (currentPeriod != subPeriod) {
|
||||||
|
scope.launch { refreshSubscription() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRefreshAtPeriodEnd(newMap: Map<String, List<MonitorEntry>>) {
|
||||||
|
val entries = newMap.values.flatten()
|
||||||
|
if (entries.isEmpty()) return
|
||||||
|
|
||||||
|
val nextPeriodEndSeconds = entries.minOf { it.periodStartUnix + it.strategy.intervalSeconds }
|
||||||
|
val delayMs = (nextPeriodEndSeconds * 1000) - System.currentTimeMillis() + 2000
|
||||||
|
if (delayMs <= 0) return
|
||||||
|
|
||||||
|
periodEndCountdownJob = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
periodEndCountdownJob = null
|
||||||
|
refreshSubscription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeWebSocketForNoSubscribers() {
|
||||||
|
closeAllWebSockets()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (reconnectJob?.isActive == true) return
|
||||||
|
reconnectJob = scope.launch {
|
||||||
|
delay(reconnectDelayMs)
|
||||||
|
reconnectJob = null
|
||||||
|
if (strategySubscribers.isNotEmpty()) {
|
||||||
|
logger.info("加密价差策略监控 WebSocket 尝试重连")
|
||||||
|
refreshSubscription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchEventBySlug(slug: String): Result<GammaEventBySlugResponse> {
|
||||||
|
return try {
|
||||||
|
val api = retrofitFactory.createGammaApi()
|
||||||
|
val response = runBlocking { api.getEventBySlug(slug) }
|
||||||
|
if (response.isSuccessful && response.body() != null) {
|
||||||
|
Result.success(response.body()!!)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseClobTokenIds(clobTokenIds: String?): List<String> {
|
||||||
|
if (clobTokenIds.isNullOrBlank()) return emptyList()
|
||||||
|
return clobTokenIds.fromJson<List<String>>() ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
periodEndCountdownJob?.cancel()
|
||||||
|
periodicPushJob?.cancel()
|
||||||
|
currentPeriodWebSocket?.close(1000, "shutdown")
|
||||||
|
currentPeriodWebSocket = null
|
||||||
|
nextPeriodWebSocket?.close(1000, "shutdown")
|
||||||
|
nextPeriodWebSocket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
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
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密价差策略订单 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 scopeJob = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + scopeJob)
|
||||||
|
|
||||||
|
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)
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun destroy() {
|
||||||
|
notificationJob?.cancel()
|
||||||
|
notificationJob = null
|
||||||
|
scopeJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user