Compare commits
483 Commits
all_in_one
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d68d3ad66c | ||
|
|
199c4a95dd | ||
|
|
3e6ce55663 | ||
|
|
1446bf852b | ||
|
|
218ab7e195 | ||
|
|
6858ddad6b | ||
|
|
4ea1c53813 | ||
|
|
e2c37e6d62 | ||
|
|
1e55365d43 | ||
|
|
7bc384a58f | ||
|
|
1f796a3fef | ||
|
|
74c21bea9b | ||
|
|
007827464a | ||
|
|
0127edbc97 | ||
|
|
8bb1bf7254 | ||
|
|
c926586f8d | ||
|
|
4a5406c7e8 | ||
|
|
5d5ead36ac | ||
|
|
849b550910 | ||
|
|
78007040f1 | ||
|
|
d5ef525224 | ||
|
|
861f1dc548 | ||
|
|
076b597fb4 | ||
|
|
dfe29d70dc | ||
|
|
e2e7effca2 | ||
|
|
30c5635570 | ||
|
|
ed0c6754e0 | ||
|
|
44aa7ef273 | ||
|
|
a34b6ba448 | ||
|
|
83bb687a97 | ||
|
|
99281395c1 | ||
|
|
e609d45fcd | ||
|
|
10e6096cc1 | ||
|
|
432fc85a79 | ||
|
|
df2b8d6372 | ||
|
|
ab100bdc23 | ||
|
|
d80d4559c5 | ||
|
|
beafeb2707 | ||
|
|
87c018594b | ||
|
|
ff1d985859 | ||
|
|
c53b67e294 | ||
|
|
34e276474a | ||
|
|
ac022bd62a | ||
|
|
f3ce4d5d11 | ||
|
|
41b2a21c3d | ||
|
|
d7ccbe38e4 | ||
|
|
1c33096917 | ||
|
|
e0dfb4c31e | ||
|
|
5a3888d905 | ||
|
|
5c854290eb | ||
|
|
81747c4eef | ||
|
|
6a9fcddfdc | ||
|
|
8bd7bae718 | ||
|
|
09edc4f57d | ||
|
|
d1c560ae16 | ||
|
|
0f0aa1bf5d | ||
|
|
5b2adb0b62 | ||
|
|
e99f0fc7c2 | ||
|
|
9c620e0aa0 | ||
|
|
104cb63802 | ||
|
|
9a720b9a19 | ||
|
|
693a2306ca | ||
|
|
41e53755ea | ||
|
|
9086c15f2e | ||
|
|
163b8303ec | ||
|
|
cbba86001a | ||
|
|
3389e0aafc | ||
|
|
1dea3df84a | ||
|
|
4dd44782c5 | ||
|
|
4ccf067b24 | ||
|
|
4479c4f02d | ||
|
|
5f256daf27 | ||
|
|
24d01cba0d | ||
|
|
d42cee2f1a | ||
|
|
cddcf35481 | ||
|
|
cbf778d560 | ||
|
|
452e40bdf5 | ||
|
|
3b0526f392 | ||
|
|
1e478c8428 | ||
|
|
fc81a8d5d6 | ||
|
|
aaef73c2b3 | ||
|
|
69be629369 | ||
|
|
f2b04911a2 | ||
|
|
fa7208f5f3 | ||
|
|
32c50466f3 | ||
|
|
e40f5c797f | ||
|
|
fc6c31dd5d | ||
|
|
e1759a7f4c | ||
|
|
83a09f24f8 | ||
|
|
e4e6e64608 | ||
|
|
a371e50a3e | ||
|
|
3bfbafbab2 | ||
|
|
b588d5b82b | ||
|
|
8e0233dd5d | ||
|
|
1a9e5a382a | ||
|
|
418eff6fb7 | ||
|
|
22901abe39 | ||
|
|
f1e2cabc01 | ||
|
|
dbcb7012bd | ||
|
|
174943722a | ||
|
|
6f9e55aaee | ||
|
|
3ce8493af2 | ||
|
|
7569c88a67 | ||
|
|
13a0e7d580 | ||
|
|
f3089fdf7f | ||
|
|
9b81832af2 | ||
|
|
22830355c6 | ||
|
|
9299d70a31 | ||
|
|
2c5524bdcf | ||
|
|
bfe3d8ec75 | ||
|
|
d31c44a22a | ||
|
|
43daa922a4 | ||
|
|
f5570f4804 | ||
|
|
be43ec1c33 | ||
|
|
95867e90f8 | ||
|
|
f4feea6b87 | ||
|
|
a498520c51 | ||
|
|
59e25558cd | ||
|
|
80872231a5 | ||
|
|
e21014eb50 | ||
|
|
33ac043324 | ||
|
|
7139b5de76 | ||
|
|
44458dca90 | ||
|
|
c9d9836df5 | ||
|
|
0a7bb0de2d | ||
|
|
a404f1fdf8 | ||
|
|
a862aec4f5 | ||
|
|
c7f1361d99 | ||
|
|
b0392f358e | ||
|
|
60a7e15100 | ||
|
|
1430ddc532 | ||
|
|
ac1336dab8 | ||
|
|
01b8a4932f | ||
|
|
55ae7b5b08 | ||
|
|
415589e625 | ||
|
|
48c3f946cc | ||
|
|
42480ef886 | ||
|
|
3a2536ae96 | ||
|
|
c750478af9 | ||
|
|
e5bc2547aa | ||
|
|
857128bca9 | ||
|
|
0fb42a5f24 | ||
|
|
43e993034f | ||
|
|
249aec917a | ||
|
|
3539180362 | ||
|
|
30f4a22fb4 | ||
|
|
dfbdfee596 | ||
|
|
ec5c76c546 | ||
|
|
c6126a42c9 | ||
|
|
5154b4933e | ||
|
|
aa073099f2 | ||
|
|
b9392e096c | ||
|
|
225cb436d1 | ||
|
|
c7e39ec1a4 | ||
|
|
a402007b99 | ||
|
|
22efd377a7 | ||
|
|
0eb9b076e3 | ||
|
|
a884ed13ad | ||
|
|
b5590b760f | ||
|
|
c1a9d52ae7 | ||
|
|
8cb9bbf42f | ||
|
|
2c8c13b8d9 | ||
|
|
550d0b278d | ||
|
|
2061583482 | ||
|
|
161d42c90b | ||
|
|
94ba0ab5a4 | ||
|
|
fe3da9dfb5 | ||
|
|
b325084d91 | ||
|
|
f6f4ca11ae | ||
|
|
9cd39c3655 | ||
|
|
ab8023139f | ||
|
|
2b5906ca6d | ||
|
|
977669302f | ||
|
|
dda1ffc849 | ||
|
|
66a78759d3 | ||
|
|
965c1651cd | ||
|
|
a1b54d658f | ||
|
|
cb251a7866 | ||
|
|
e024bf8ebe | ||
|
|
c4a23be3bf | ||
|
|
7abf7db2df | ||
|
|
d3ca06a8ad | ||
|
|
154f1fbf1d | ||
|
|
7cf6613540 | ||
|
|
9379a9815e | ||
|
|
ba4a4b2205 | ||
|
|
99df066101 | ||
|
|
d985b94161 | ||
|
|
baa8277aee | ||
|
|
7df054f638 | ||
|
|
0a9377f5ac | ||
|
|
b779b7b9ec | ||
|
|
11cd55ff7b | ||
|
|
c53c5fc64a | ||
|
|
78667c2604 | ||
|
|
d7b4b82293 | ||
|
|
3f4e0d8971 | ||
|
|
3d9f58f049 | ||
|
|
a52b8c4738 | ||
|
|
16cf4f2157 | ||
|
|
19371a8e60 | ||
|
|
1830444ef0 | ||
|
|
a88e114b4c | ||
|
|
777f9ff703 | ||
|
|
345416e32f | ||
|
|
e816524972 | ||
|
|
ca0bbeddbf | ||
|
|
ca959c1f8a | ||
|
|
4a69b42392 | ||
|
|
d7f4f43d7f | ||
|
|
6da90babe9 | ||
|
|
29ebb8e2c9 | ||
|
|
3d350ebea6 | ||
|
|
4b6d73a5c4 | ||
|
|
dab0981935 | ||
|
|
d97363e3d4 | ||
|
|
f8058083e3 | ||
|
|
213e31142c | ||
|
|
a19c716166 | ||
|
|
c9f676c68a | ||
|
|
60823b4056 | ||
|
|
42c6904604 | ||
|
|
43a09a57a6 | ||
|
|
43e44a976b | ||
|
|
8b45c81906 | ||
|
|
41630bf580 | ||
|
|
5f18abbe2f | ||
|
|
78dcba0c34 | ||
|
|
80485e7271 | ||
|
|
be1349c1fc | ||
|
|
d4fa954682 | ||
|
|
cb5f513904 | ||
|
|
46d31fde59 | ||
|
|
69327a6668 | ||
|
|
a03bb0e8f3 | ||
|
|
4c7cd86fb0 | ||
|
|
8154508c82 | ||
|
|
73f148a120 | ||
|
|
01c11d62f6 | ||
|
|
ce54164b63 | ||
|
|
fcbf702f71 | ||
|
|
7550b707f4 | ||
|
|
2c81d47b2b | ||
|
|
e7443dddf3 | ||
|
|
7379dd1f4b | ||
|
|
42eab75e3e | ||
|
|
68f028f0fc | ||
|
|
0df841c93c | ||
|
|
8c91db3f60 | ||
|
|
a033d1ea6d | ||
|
|
71f0378c5f | ||
|
|
20788e4b77 | ||
|
|
99c40c5752 | ||
|
|
972156a702 | ||
|
|
1dd8d5893d | ||
|
|
c27bed1efd | ||
|
|
78d1c3ac37 | ||
|
|
d16bb53e60 | ||
|
|
bbbac43506 | ||
|
|
d184eafae8 | ||
|
|
79adc79f98 | ||
|
|
ecb4b9fc2f | ||
|
|
ec54716266 | ||
|
|
cc324eead5 | ||
|
|
bfae183e39 | ||
|
|
262ee661a5 | ||
|
|
3609bddace | ||
|
|
c4cd3e0ffa | ||
|
|
411bb1d3d3 | ||
|
|
b4b001833f | ||
|
|
7e62247217 | ||
|
|
79fb20bf41 | ||
|
|
a38f5ff05d | ||
|
|
9be1c5777d | ||
|
|
f8eca1ed59 | ||
|
|
922a8f3820 | ||
|
|
6bee742413 | ||
|
|
ea4410da0f | ||
|
|
2f50ecd172 | ||
|
|
833f8096d7 | ||
|
|
78c2d7f1ae | ||
|
|
efc88b2083 | ||
|
|
9f21cc1d02 | ||
|
|
de3d9568e9 | ||
|
|
81daf10555 | ||
|
|
9491012938 | ||
|
|
9958af7c3f | ||
|
|
654103177d | ||
|
|
1e9b27f8b4 | ||
|
|
50c933a8b0 | ||
|
|
614b28493b | ||
|
|
a9bed79871 | ||
|
|
46eb9f1187 | ||
|
|
d34e3cc998 | ||
|
|
02a1a087ab | ||
|
|
8ece78a3dc | ||
|
|
c713e7d27e | ||
|
|
8bc6c1ecc4 | ||
|
|
c23db4aba0 | ||
|
|
377ae3b966 | ||
|
|
d0688c57b7 | ||
|
|
0962df4112 | ||
|
|
97ecf8e605 | ||
|
|
040473d4d3 | ||
|
|
cc6750fa47 | ||
|
|
464b6af410 | ||
|
|
9d78c227a4 | ||
|
|
48f6ab4fea | ||
|
|
449ad01ede | ||
|
|
7b8bcd758d | ||
|
|
76e6e5efd0 | ||
|
|
d3f2cce922 | ||
|
|
8ff8cd4ebc | ||
|
|
18257b7d8a | ||
|
|
0a4bbd3132 | ||
|
|
ce3a4953f5 | ||
|
|
4da3e0bd48 | ||
|
|
c01f681dec | ||
|
|
0a0bcd941b | ||
|
|
cb8b393550 | ||
|
|
cf86c64296 | ||
|
|
380ce7cda9 | ||
|
|
aaca165f55 | ||
|
|
6e23c924b2 | ||
|
|
4f21240116 | ||
|
|
9490207537 | ||
|
|
53396adf26 | ||
|
|
f1a82f53e0 | ||
|
|
e328272701 | ||
|
|
15394445b4 | ||
|
|
8337893b0c | ||
|
|
8422e93aa2 | ||
|
|
461aeaf359 | ||
|
|
8eb2476192 | ||
|
|
3865e25a2b | ||
|
|
dfd899256b | ||
|
|
cf678569ee | ||
|
|
5faf3e103d | ||
|
|
fb04f69965 | ||
|
|
1d25b3cb79 | ||
|
|
4c5d040746 | ||
|
|
8667c07134 | ||
|
|
8e365b3a9a | ||
|
|
16c4cfbdd8 | ||
|
|
9fe028d704 | ||
|
|
d4edc16f43 | ||
|
|
88ed3bfab4 | ||
|
|
9e0f180229 | ||
|
|
042cb02563 | ||
|
|
3057ce0e8b | ||
|
|
1eb5c618eb | ||
|
|
51fd3c6550 | ||
|
|
be6459d5dd | ||
|
|
9448996837 | ||
|
|
ed994e6e8e | ||
|
|
7f736c9081 | ||
|
|
7947101bc4 | ||
|
|
c3a14f0f1a | ||
|
|
83e628b611 | ||
|
|
07d3bf4398 | ||
|
|
00751298bb | ||
|
|
4c640f780b | ||
|
|
86b85c2609 | ||
|
|
096b838769 | ||
|
|
10fd7a7d60 | ||
|
|
04f222875a | ||
|
|
1032295052 | ||
|
|
d9270ad6b4 | ||
|
|
762e9c4b38 | ||
|
|
731e71aae8 | ||
|
|
8d2fb4b9af | ||
|
|
f716ea69d5 | ||
|
|
81c73eb9cd | ||
|
|
f7c68efb3e | ||
|
|
b01920cadf | ||
|
|
f2d71d3390 | ||
|
|
27ddbcb8c1 | ||
|
|
6504efbf15 | ||
|
|
fb3d1a0dda | ||
|
|
14b5acae09 | ||
|
|
6341bacc20 | ||
|
|
aca1cf26b7 | ||
|
|
7847d3100b | ||
|
|
8d3991c74c | ||
|
|
211ef38ee9 | ||
|
|
fad8a1d6fd | ||
|
|
150eea7a28 | ||
|
|
e1c6cc2681 | ||
|
|
0c98bfe236 | ||
|
|
7adf5c7126 | ||
|
|
7a64ff44c2 | ||
|
|
2ee6e7a009 | ||
|
|
1fcd692368 | ||
|
|
cb7b091280 | ||
|
|
95abbf50be | ||
|
|
fe420ad1a4 | ||
|
|
efbd149f1f | ||
|
|
9e70f90260 | ||
|
|
f1bc8413df | ||
|
|
0c489bfdee | ||
|
|
f798782a6d | ||
|
|
f9ce156e9a | ||
|
|
63f1ea05f0 | ||
|
|
6aeed50d83 | ||
|
|
7a72cfb30c | ||
|
|
cdbb660c1d | ||
|
|
84c4af5ff5 | ||
|
|
2ba8d69ee0 | ||
|
|
ae953d119e | ||
|
|
3154dd5518 | ||
|
|
fc128f98b4 | ||
|
|
7ec1ae32d7 | ||
|
|
ba818b480d | ||
|
|
972694e98f | ||
|
|
76bde06a4f | ||
|
|
a7e1e6ca0a | ||
|
|
651e736474 | ||
|
|
e7efc5e8fa | ||
|
|
c581174747 | ||
|
|
1c1580b344 | ||
|
|
d051be3f65 | ||
|
|
d6cdc2f055 | ||
|
|
971d4e3a39 | ||
|
|
c9120294c9 | ||
|
|
18eeac233a | ||
|
|
d914b64294 | ||
|
|
078576ddf8 | ||
|
|
3e9d24ebea | ||
|
|
43d54bad97 | ||
|
|
5d7166d404 | ||
|
|
14773e530a | ||
|
|
717f51b015 | ||
|
|
5503860a04 | ||
|
|
d354aec939 | ||
|
|
8afd282ca5 | ||
|
|
e5a281569c | ||
|
|
b1f4cbddac | ||
|
|
3baa00c851 | ||
|
|
06272b6922 | ||
|
|
244ef6f4ba | ||
|
|
3e1e3392b7 | ||
|
|
9ed4d4259a | ||
|
|
5717614f61 | ||
|
|
0ecdff4530 | ||
|
|
7d3d9c7de1 | ||
|
|
fac2651911 | ||
|
|
3ef66fb54a | ||
|
|
490e94f7c7 | ||
|
|
9a62528c93 | ||
|
|
66d68e319f | ||
|
|
3bac042273 | ||
|
|
28bce8f02b | ||
|
|
352d36e7a5 | ||
|
|
156acc92e0 | ||
|
|
dc49c2717b | ||
|
|
5b1370a5a2 | ||
|
|
87e7865cbb | ||
|
|
414607d566 | ||
|
|
2d17406799 | ||
|
|
5a00dc75d7 | ||
|
|
45a654f654 | ||
|
|
e80fd1059b | ||
|
|
d75293e037 | ||
|
|
fe855df566 | ||
|
|
18cfeaf5db | ||
|
|
fe60f12ee0 | ||
|
|
3b0ff0227e | ||
|
|
8d8bc409a6 | ||
|
|
d7813bbc80 | ||
|
|
ed5ea8b733 | ||
|
|
52e849a586 | ||
|
|
2d9adddb91 | ||
|
|
9bc73b63a3 | ||
|
|
c28400e51e | ||
|
|
11e6361235 | ||
|
|
4d26777845 | ||
|
|
6d48dc98d2 | ||
|
|
1fdcb9c8b7 | ||
|
|
ad63dbd234 | ||
|
|
7fb0ed39a7 | ||
|
|
4c20a7a488 | ||
|
|
8832b83ced | ||
|
|
746c8ac25b |
8
. cursorignore
Normal file
8
. cursorignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
venv
|
||||
.git
|
||||
logs
|
||||
*.log
|
||||
18
.cursorrules
Normal file
18
.cursorrules
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 交易系统开发最高准则
|
||||
|
||||
## 1. 风险控制(核心)
|
||||
- **止损高于一切**:严禁在任何平仓逻辑前添加时间限制。任何情况下,只要触发止损条件,必须立即执行平仓。
|
||||
- **严禁恢复时间锁**:绝对不允许重新启用 `MIN_HOLD_TIME_SEC` 来限制止损或止盈。
|
||||
- **异常处理**:所有涉及 `binance.create_order` 的操作必须包含 try-catch 逻辑,并有重试机制或错误预警。
|
||||
|
||||
## 2. 币安合约逻辑
|
||||
- **挂单确认**:在开仓订单成交后,必须立即调用 `_ensure_exchange_sltp_orders` 在交易所侧挂好止损单。
|
||||
- **价格类型**:区分 Mark Price(标记价格)和 Last Price(最新价格),止损逻辑应优先参考标记价格以防插针。
|
||||
|
||||
## 3. 代码风格
|
||||
- 使用 Python 异步编程 (asyncio)。
|
||||
- 所有的交易日志必须记录 Symbol、价格、原因和时间戳。
|
||||
|
||||
## 4. 不要在本地运行交易系统,后台服务,数据库连接
|
||||
- 交易系统必须在服务器上运行,严禁在本地环境测试。
|
||||
- 所有配置(如 API 密钥、数据库连接等)必须在服务器上配置,本地环境不得包含任何敏感信息。
|
||||
103
backend/TROUBLESHOOTING.md
Normal file
103
backend/TROUBLESHOOTING.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Backend 启动问题排查指南
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 语法错误
|
||||
**错误信息**: `SyntaxError: expected 'except' or 'finally' block`
|
||||
|
||||
**解决方法**:
|
||||
- 检查代码中的 `try:` 块是否都有对应的 `except` 或 `finally`
|
||||
- 检查缩进是否正确
|
||||
- 运行 `python3 -m py_compile api/routes/trades.py` 检查语法
|
||||
|
||||
### 2. 缺少依赖模块
|
||||
**错误信息**: `ModuleNotFoundError: No module named 'xxx'`
|
||||
|
||||
**解决方法**:
|
||||
```bash
|
||||
# 激活虚拟环境
|
||||
source ../.venv/bin/activate # 或 source .venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 或者单独安装缺失的模块
|
||||
pip install python-jose[cryptography]
|
||||
```
|
||||
|
||||
### 3. 导入错误
|
||||
**错误信息**: `ModuleNotFoundError: No module named 'api'`
|
||||
|
||||
**解决方法**:
|
||||
- 确保在 `backend` 目录下运行
|
||||
- 检查 `PYTHONPATH` 是否正确设置
|
||||
- 使用 `cd backend && python3 -m uvicorn api.main:app` 启动
|
||||
|
||||
## 排查步骤
|
||||
|
||||
### 步骤 1: 检查依赖
|
||||
```bash
|
||||
cd backend
|
||||
bash check_dependencies.sh
|
||||
```
|
||||
|
||||
### 步骤 2: 检查语法
|
||||
```bash
|
||||
cd backend
|
||||
source ../.venv/bin/activate
|
||||
python3 -m py_compile api/main.py
|
||||
python3 -m py_compile api/routes/*.py
|
||||
```
|
||||
|
||||
### 步骤 3: 测试导入
|
||||
```bash
|
||||
cd backend
|
||||
source ../.venv/bin/activate
|
||||
python3 -c "import api.main; print('✓ 导入成功')"
|
||||
```
|
||||
|
||||
### 步骤 4: 查看日志
|
||||
```bash
|
||||
# 查看最新的错误日志
|
||||
tail -50 backend/logs/api.log
|
||||
tail -50 backend/logs/uvicorn.log
|
||||
```
|
||||
|
||||
### 步骤 5: 手动启动测试
|
||||
```bash
|
||||
cd backend
|
||||
source ../.venv/bin/activate
|
||||
export DB_HOST=your_db_host
|
||||
export DB_PORT=3306
|
||||
export DB_USER=your_db_user
|
||||
export DB_PASSWORD=your_db_password
|
||||
export DB_NAME=auto_trade_sys
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8001 --log-level info
|
||||
```
|
||||
|
||||
## 启动脚本
|
||||
|
||||
### 开发模式(自动重载)
|
||||
```bash
|
||||
cd backend
|
||||
./start_dev.sh
|
||||
```
|
||||
|
||||
### 生产模式(后台运行)
|
||||
```bash
|
||||
cd backend
|
||||
./start.sh
|
||||
```
|
||||
|
||||
## 检查服务状态
|
||||
|
||||
```bash
|
||||
# 检查进程
|
||||
ps aux | grep uvicorn
|
||||
|
||||
# 检查端口
|
||||
lsof -i :8001
|
||||
|
||||
# 测试健康检查
|
||||
curl http://localhost:8001/api/health
|
||||
```
|
||||
138
backend/api/auth_deps.py
Normal file
138
backend/api/auth_deps.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""
|
||||
FastAPI 依赖:解析 JWT、获取当前用户、校验 admin、校验 account_id 访问权
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Header, HTTPException, Depends, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
|
||||
from api.auth_utils import jwt_decode
|
||||
from database.models import User, UserAccountMembership
|
||||
|
||||
|
||||
def _auth_enabled() -> bool:
|
||||
v = (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower()
|
||||
return v not in {"0", "false", "no"}
|
||||
|
||||
|
||||
_bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer_scheme)) -> Dict[str, Any]:
|
||||
if not _auth_enabled():
|
||||
# 未启用登录:视为超级管理员(兼容开发/灰度)
|
||||
return {"id": 0, "username": "dev", "role": "admin", "status": "active"}
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
if (credentials.scheme or "").lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
token = (credentials.credentials or "").strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
try:
|
||||
payload = jwt_decode(token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="登录已失效")
|
||||
|
||||
sub = payload.get("sub")
|
||||
try:
|
||||
uid = int(sub)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="登录已失效")
|
||||
|
||||
u = User.get_by_id(uid)
|
||||
if not u:
|
||||
raise HTTPException(status_code=401, detail="登录已失效")
|
||||
if (u.get("status") or "active") != "active":
|
||||
raise HTTPException(status_code=403, detail="用户已被禁用")
|
||||
return {"id": int(u["id"]), "username": u.get("username") or "", "role": u.get("role") or "user", "status": u.get("status") or "active"}
|
||||
|
||||
|
||||
def require_admin(user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if (user.get("role") or "user") != "admin":
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
return user
|
||||
|
||||
|
||||
def require_account_access(account_id: int, user: Dict[str, Any]) -> int:
|
||||
aid = int(account_id or 1)
|
||||
if (user.get("role") or "user") == "admin":
|
||||
return aid
|
||||
if UserAccountMembership.has_access(int(user["id"]), aid):
|
||||
return aid
|
||||
raise HTTPException(status_code=403, detail="无权访问该账号")
|
||||
|
||||
|
||||
def require_account_owner(account_id: int, user: Dict[str, Any]) -> int:
|
||||
"""
|
||||
账号“拥有者”权限:用于启停交易进程等高危操作。
|
||||
"""
|
||||
aid = int(account_id or 1)
|
||||
if (user.get("role") or "user") == "admin":
|
||||
return aid
|
||||
role = UserAccountMembership.get_role(int(user["id"]), aid)
|
||||
if role == "owner":
|
||||
return aid
|
||||
raise HTTPException(status_code=403, detail="需要该账号 owner 权限")
|
||||
|
||||
|
||||
def get_admin_user(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
return require_admin(user)
|
||||
|
||||
|
||||
def get_account_id(
|
||||
x_account_id: Optional[int] = Header(None, alias="X-Account-Id"),
|
||||
user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> int:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 1. 如果 header 存在,直接校验
|
||||
if x_account_id is not None:
|
||||
aid = int(x_account_id)
|
||||
return require_account_access(aid, user)
|
||||
|
||||
# 2. 如果 header 不存在
|
||||
# 如果是 admin,默认访问 1
|
||||
if (user.get("role") or "user") == "admin":
|
||||
return require_account_access(1, user)
|
||||
|
||||
# 如果是普通用户,尝试查找他拥有的第一个账号
|
||||
try:
|
||||
# 查找用户关联的账号
|
||||
accounts = UserAccountMembership.get_user_accounts(int(user["id"]))
|
||||
if accounts and len(accounts) > 0:
|
||||
first_aid = int(accounts[0]["id"])
|
||||
logger.info(f"get_account_id: No header provided, auto-selected account_id={first_aid} for user {user['id']}")
|
||||
return first_aid
|
||||
except Exception as e:
|
||||
logger.error(f"get_account_id: Failed to auto-select account for user {user['id']}: {e}")
|
||||
|
||||
# 兜底:仍然尝试 1,然后会由 require_account_access 抛出 403
|
||||
logger.warning(f"get_account_id: No header provided and no accounts found for user {user['id']}, defaulting to 1")
|
||||
return require_account_access(1, user)
|
||||
|
||||
|
||||
def require_system_admin(
|
||||
x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
|
||||
user: Dict[str, Any] = Depends(get_admin_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
/api/system/* 管理员保护:
|
||||
- 启用登录(ATS_AUTH_ENABLED=true):要求 JWT 为 admin
|
||||
- 未启用登录:兼容旧逻辑,若配置了 SYSTEM_CONTROL_TOKEN,则要求 X-Admin-Token
|
||||
"""
|
||||
if _auth_enabled():
|
||||
return user
|
||||
|
||||
token = (os.getenv("SYSTEM_CONTROL_TOKEN") or "").strip()
|
||||
if not token:
|
||||
return user
|
||||
if not x_admin_token or x_admin_token != token:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return user
|
||||
|
||||
75
backend/api/auth_utils.py
Normal file
75
backend/api/auth_utils.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""
|
||||
登录鉴权工具(JWT + 密码哈希)
|
||||
|
||||
设计目标:
|
||||
- 最小依赖:密码哈希用 pbkdf2_hmac(标准库)
|
||||
- JWT 使用 python-jose(已加入 requirements)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jose import jwt # type: ignore
|
||||
|
||||
|
||||
def _jwt_secret() -> str:
|
||||
s = (os.getenv("ATS_JWT_SECRET") or os.getenv("JWT_SECRET") or "").strip()
|
||||
if s:
|
||||
return s
|
||||
# 允许开发环境兜底,但线上务必配置
|
||||
return "dev-secret-change-me"
|
||||
|
||||
|
||||
def jwt_encode(payload: Dict[str, Any], exp_sec: int = 3600) -> str:
|
||||
now = int(time.time())
|
||||
body = dict(payload or {})
|
||||
body["iat"] = now
|
||||
body["exp"] = now + int(exp_sec)
|
||||
return jwt.encode(body, _jwt_secret(), algorithm="HS256")
|
||||
|
||||
|
||||
def jwt_decode(token: str) -> Dict[str, Any]:
|
||||
return jwt.decode(token, _jwt_secret(), algorithms=["HS256"])
|
||||
|
||||
|
||||
def _b64(b: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(b).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def _b64d(s: str) -> bytes:
|
||||
s = (s or "").strip()
|
||||
s = s + ("=" * (-len(s) % 4))
|
||||
return base64.urlsafe_b64decode(s.encode("utf-8"))
|
||||
|
||||
|
||||
def hash_password(password: str, iterations: int = 260_000) -> str:
|
||||
"""
|
||||
PBKDF2-SHA256:返回格式
|
||||
pbkdf2_sha256$<iterations>$<salt_b64>$<hash_b64>
|
||||
"""
|
||||
pw = (password or "").encode("utf-8")
|
||||
salt = os.urandom(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", pw, salt, int(iterations))
|
||||
return f"pbkdf2_sha256${int(iterations)}${_b64(salt)}${_b64(dk)}"
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
s = str(password_hash or "")
|
||||
if not s.startswith("pbkdf2_sha256$"):
|
||||
return False
|
||||
_, it_s, salt_b64, dk_b64 = s.split("$", 3)
|
||||
it = int(it_s)
|
||||
salt = _b64d(salt_b64)
|
||||
dk0 = _b64d(dk_b64)
|
||||
dk1 = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, it)
|
||||
return hmac.compare_digest(dk0, dk1)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
|
@ -3,8 +3,9 @@ FastAPI应用主入口
|
|||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from api.routes import config, trades, stats, dashboard, account, recommendations, system
|
||||
from api.routes import config, trades, stats, dashboard, account, recommendations, system, accounts, auth, admin, public, data_management
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
|
@ -141,12 +142,12 @@ logger.info(f"日志级别: {os.getenv('LOG_LEVEL', 'INFO')}")
|
|||
|
||||
# 检查 redis-py 是否可用(redis-py 4.2+ 同时支持同步和异步,可替代aioredis)
|
||||
try:
|
||||
import redis
|
||||
import redis # type: ignore
|
||||
# 检查是否是 redis-py 4.2+(支持异步)
|
||||
if hasattr(redis, 'asyncio'):
|
||||
logger.info(f"✓ redis-py 已安装 (版本: {redis.__version__ if hasattr(redis, '__version__') else '未知'}),支持同步和异步客户端")
|
||||
logger.info(f" - redis.Redis: 同步客户端(用于config_manager)")
|
||||
logger.info(f" - redis.asyncio.Redis: 异步客户端(用于trading_system,可替代aioredis)")
|
||||
logger.info(" - redis.Redis: 同步客户端(用于config_manager)")
|
||||
logger.info(" - redis.asyncio.Redis: 异步客户端(用于trading_system,可替代aioredis)")
|
||||
else:
|
||||
logger.warning("⚠ redis-py 版本可能过低,建议升级到 4.2+ 以获得异步支持")
|
||||
except ImportError as e:
|
||||
|
|
@ -154,9 +155,9 @@ except ImportError as e:
|
|||
logger.warning("⚠ redis-py 未安装,Redis/Valkey 缓存将不可用")
|
||||
logger.warning(f" Python 路径: {sys.executable}")
|
||||
logger.warning(f" 导入错误: {e}")
|
||||
logger.warning(f" 提示: 请运行 'pip install redis>=4.2.0' 安装 redis-py")
|
||||
logger.warning(f" 注意: redis-py 4.2+ 同时支持同步和异步,无需安装 aioredis")
|
||||
logger.warning(f" 或者运行 'pip install -r backend/requirements.txt' 安装所有依赖")
|
||||
logger.warning(" 提示: 请运行 'pip install redis>=4.2.0' 安装 redis-py")
|
||||
logger.warning(" 注意: redis-py 4.2+ 同时支持同步和异步,无需安装 aioredis")
|
||||
logger.warning(" 或者运行 'pip install -r backend/requirements.txt' 安装所有依赖")
|
||||
|
||||
app = FastAPI(
|
||||
title="Auto Trade System API",
|
||||
|
|
@ -165,9 +166,79 @@ app = FastAPI(
|
|||
redirect_slashes=False # 禁用自动重定向,避免307重定向问题
|
||||
)
|
||||
|
||||
# 现货推荐定时扫描间隔(秒),默认 15 分钟;设为 0 关闭定时扫描
|
||||
SPOT_SCAN_INTERVAL_SEC = int(os.getenv("SPOT_SCAN_INTERVAL_SEC", "900"))
|
||||
|
||||
|
||||
async def _spot_scan_loop():
|
||||
"""后台循环:每隔 SPOT_SCAN_INTERVAL_SEC 执行一次现货扫描并写入 Redis。"""
|
||||
if SPOT_SCAN_INTERVAL_SEC <= 0:
|
||||
logger.info("现货推荐定时扫描已关闭(SPOT_SCAN_INTERVAL_SEC=0)")
|
||||
return
|
||||
import asyncio
|
||||
backend_dir = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
try:
|
||||
from spot_scanner import run_spot_scan_and_cache
|
||||
except Exception as e:
|
||||
logger.warning("现货扫描模块加载失败,跳过定时任务: %s", e)
|
||||
return
|
||||
logger.info("现货推荐定时扫描已启动,间隔 %d 秒", SPOT_SCAN_INTERVAL_SEC)
|
||||
while True:
|
||||
try:
|
||||
await run_spot_scan_and_cache(ttl_sec=900)
|
||||
except Exception as e:
|
||||
logger.warning("现货扫描执行失败: %s", e)
|
||||
await asyncio.sleep(SPOT_SCAN_INTERVAL_SEC)
|
||||
|
||||
|
||||
# 启动时:确保存在一个初始管理员(通过环境变量配置)
|
||||
@app.on_event("startup")
|
||||
async def _ensure_initial_admin():
|
||||
try:
|
||||
import os
|
||||
from database.models import User, UserAccountMembership
|
||||
from api.auth_utils import hash_password
|
||||
|
||||
username = (os.getenv("ATS_ADMIN_USERNAME") or "admin").strip()
|
||||
password = (os.getenv("ATS_ADMIN_PASSWORD") or "").strip()
|
||||
if not password:
|
||||
# 不强制创建,避免你忘记改默认密码导致安全风险
|
||||
# 你可以设置 ATS_ADMIN_PASSWORD 后重启后端自动创建
|
||||
logger.warning("未设置 ATS_ADMIN_PASSWORD,跳过自动创建初始管理员")
|
||||
return
|
||||
|
||||
u = User.get_by_username(username)
|
||||
if not u:
|
||||
uid = User.create(username=username, password_hash=hash_password(password), role="admin", status="active")
|
||||
# 默认给管理员绑定 account_id=1(default)
|
||||
try:
|
||||
UserAccountMembership.add(int(uid), 1, role="owner")
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"✓ 已创建初始管理员用户: {username} (id={uid})")
|
||||
else:
|
||||
# 若已存在但不是 admin,则提升为 admin(可注释掉更保守)
|
||||
if (u.get("role") or "user") != "admin":
|
||||
try:
|
||||
User.set_role(int(u["id"]), "admin")
|
||||
logger.warning(f"已将用户 {username} 提升为 admin")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"初始化管理员失败(可忽略): {e}")
|
||||
|
||||
# 启动现货推荐定时扫描(后台任务)
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.create_task(_spot_scan_loop())
|
||||
except Exception as e:
|
||||
logger.warning("启动现货扫描定时任务失败(可忽略): %s", e)
|
||||
|
||||
|
||||
# CORS配置(允许React前端访问)
|
||||
# 默认包含:本地开发端口、主前端域名、推荐查看器域名
|
||||
cors_origins_str = os.getenv('CORS_ORIGINS', 'http://localhost:3000,http://localhost:3001,http://localhost:5173,http://as.deepx1.com,http://asapi.deepx1.com,http://r.deepx1.com,https://r.deepx1.com')
|
||||
cors_origins_str = os.getenv('CORS_ORIGINS', 'http://localhost:3000,http://localhost:3001,http://localhost:5173,http://as.deepx1.com,http://asapi.deepx1.com,http://r.deepx1.com,https://r.deepx1.com,http://asapi-new.deepx1.com')
|
||||
cors_origins = [origin.strip() for origin in cors_origins_str.split(',') if origin.strip()]
|
||||
|
||||
logger.info(f"CORS允许的源: {cors_origins}")
|
||||
|
|
@ -183,12 +254,17 @@ app.add_middleware(
|
|||
|
||||
# 注册路由
|
||||
app.include_router(config.router, prefix="/api/config", tags=["配置管理"])
|
||||
app.include_router(auth.router, tags=["auth"])
|
||||
app.include_router(admin.router)
|
||||
app.include_router(accounts.router, prefix="/api/accounts", tags=["账号管理"])
|
||||
app.include_router(trades.router, prefix="/api/trades", tags=["交易记录"])
|
||||
app.include_router(stats.router, prefix="/api/stats", tags=["统计分析"])
|
||||
app.include_router(dashboard.router, prefix="/api/dashboard", tags=["仪表板"])
|
||||
app.include_router(account.router, prefix="/api/account", tags=["账户数据"])
|
||||
app.include_router(recommendations.router, tags=["交易推荐"])
|
||||
app.include_router(system.router, tags=["系统控制"])
|
||||
app.include_router(data_management.router)
|
||||
app.include_router(public.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
301
backend/api/routes/accounts.py
Normal file
301
backend/api/routes/accounts.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"""
|
||||
账号管理 API(多账号)
|
||||
|
||||
说明:
|
||||
- 这是“多账号第一步”的管理入口:创建/禁用/更新密钥
|
||||
- 交易/配置/统计接口通过 X-Account-Id 头来选择账号(默认 1)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
import logging
|
||||
|
||||
from database.models import Account, UserAccountMembership
|
||||
from api.auth_deps import get_current_user, get_admin_user, require_account_access, require_account_owner
|
||||
|
||||
from api.supervisor_account import (
|
||||
ensure_account_program,
|
||||
run_supervisorctl,
|
||||
parse_supervisor_status,
|
||||
program_name_for_account,
|
||||
tail_supervisor,
|
||||
tail_supervisord_log,
|
||||
tail_trading_log_files,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AccountCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
api_key: Optional[str] = ""
|
||||
api_secret: Optional[str] = ""
|
||||
use_testnet: bool = False
|
||||
status: str = Field("active", pattern="^(active|disabled)$")
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
status: Optional[str] = Field(None, pattern="^(active|disabled)$")
|
||||
use_testnet: Optional[bool] = None
|
||||
|
||||
|
||||
class AccountCredentialsUpdate(BaseModel):
|
||||
api_key: Optional[str] = None
|
||||
api_secret: Optional[str] = None
|
||||
use_testnet: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_my_accounts(user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""列出我有权访问的账号"""
|
||||
try:
|
||||
if user.get("role") == "admin":
|
||||
accounts = Account.list_all()
|
||||
else:
|
||||
accounts = UserAccountMembership.get_user_accounts(user["id"])
|
||||
|
||||
# 补充一些运行时信息(可选),并处理敏感字段
|
||||
for acc in accounts:
|
||||
acc['has_api_key'] = bool(acc.get('api_key_enc'))
|
||||
acc['has_api_secret'] = bool(acc.get('api_secret_enc'))
|
||||
# 移除加密字段,不直接暴露给前端
|
||||
acc.pop('api_key_enc', None)
|
||||
acc.pop('api_secret_enc', None)
|
||||
|
||||
return accounts
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_account(
|
||||
data: AccountCreate,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""创建新账号(仅管理员或允许的用户)"""
|
||||
# 暂时只允许 admin 创建
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Only admin can create accounts")
|
||||
|
||||
try:
|
||||
aid = Account.create(
|
||||
name=data.name,
|
||||
api_key=data.api_key,
|
||||
api_secret=data.api_secret,
|
||||
use_testnet=data.use_testnet,
|
||||
status=data.status
|
||||
)
|
||||
# 自动将创建者关联为 owner
|
||||
UserAccountMembership.add_membership(user["id"], aid, "owner")
|
||||
|
||||
return {"id": aid, "message": "Account created successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{account_id}")
|
||||
async def get_account_detail(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""获取账号详情"""
|
||||
require_account_access(account_id, user)
|
||||
try:
|
||||
acc = Account.get_by_id(account_id)
|
||||
if not acc:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return acc
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{account_id}")
|
||||
async def update_account(
|
||||
account_id: int,
|
||||
data: AccountUpdate,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""更新账号基本信息"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
updates = {}
|
||||
if data.name is not None:
|
||||
updates['name'] = data.name
|
||||
if data.status is not None:
|
||||
updates['status'] = data.status
|
||||
if data.use_testnet is not None:
|
||||
updates['testnet'] = 1 if data.use_testnet else 0
|
||||
|
||||
if updates:
|
||||
Account.update(account_id, **updates)
|
||||
|
||||
return {"message": "Account updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{account_id}/credentials")
|
||||
async def update_credentials(
|
||||
account_id: int,
|
||||
data: AccountCredentialsUpdate,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""更新API密钥"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
updates = {}
|
||||
if data.api_key is not None:
|
||||
updates['api_key'] = data.api_key
|
||||
if data.api_secret is not None:
|
||||
updates['api_secret'] = data.api_secret
|
||||
if data.use_testnet is not None:
|
||||
updates['testnet'] = 1 if data.use_testnet else 0
|
||||
|
||||
if updates:
|
||||
Account.update(account_id, **updates)
|
||||
|
||||
return {"message": "Credentials updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# --- Service Management ---
|
||||
|
||||
@router.get("/{account_id}/trading/status")
|
||||
@router.get("/{account_id}/service/status", include_in_schema=False) # 兼容旧路由
|
||||
async def get_service_status(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""获取该账号关联的交易服务状态"""
|
||||
# 手动调用权限检查,因为 Depends(require_account_access) 无法直接获取路径参数 account_id
|
||||
require_account_access(account_id, user)
|
||||
|
||||
try:
|
||||
program = program_name_for_account(account_id)
|
||||
# status <program>
|
||||
try:
|
||||
out = run_supervisorctl(["status", program])
|
||||
running, pid, state = parse_supervisor_status(out)
|
||||
return {
|
||||
"program": program,
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"state": state,
|
||||
"raw": out
|
||||
}
|
||||
except RuntimeError as e:
|
||||
# 可能进程不存在
|
||||
return {
|
||||
"program": program,
|
||||
"running": False,
|
||||
"pid": None,
|
||||
"state": "UNKNOWN",
|
||||
"raw": str(e),
|
||||
"error": "Process likely not configured or supervisor error"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{account_id}/trading/start")
|
||||
@router.post("/{account_id}/service/start", include_in_schema=False)
|
||||
async def start_service(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""启动交易服务(需该账号 owner 或管理员)"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
program = program_name_for_account(account_id)
|
||||
out = run_supervisorctl(["start", program])
|
||||
# Check status again
|
||||
status_out = run_supervisorctl(["status", program])
|
||||
running, pid, state = parse_supervisor_status(status_out)
|
||||
return {
|
||||
"message": "Service start command sent",
|
||||
"output": out,
|
||||
"status": {
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"state": state
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{account_id}/trading/stop")
|
||||
@router.post("/{account_id}/service/stop", include_in_schema=False)
|
||||
async def stop_service(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""停止交易服务(需该账号 owner 或管理员)"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
program = program_name_for_account(account_id)
|
||||
out = run_supervisorctl(["stop", program])
|
||||
# Check status again
|
||||
status_out = run_supervisorctl(["status", program])
|
||||
running, pid, state = parse_supervisor_status(status_out)
|
||||
return {
|
||||
"message": "Service stop command sent",
|
||||
"output": out,
|
||||
"status": {
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"state": state
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{account_id}/trading/restart")
|
||||
@router.post("/{account_id}/service/restart", include_in_schema=False)
|
||||
async def restart_service(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""重启交易服务(需该账号 owner 或管理员)"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
program = program_name_for_account(account_id)
|
||||
out = run_supervisorctl(["restart", program])
|
||||
# Check status again
|
||||
status_out = run_supervisorctl(["status", program])
|
||||
running, pid, state = parse_supervisor_status(status_out)
|
||||
return {
|
||||
"message": "Service restart command sent",
|
||||
"output": out,
|
||||
"status": {
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"state": state
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{account_id}/trading/ensure-program")
|
||||
async def ensure_trading_program(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
|
||||
if int(account_id) <= 0:
|
||||
raise HTTPException(status_code=400, detail="account_id 必须 >= 1")
|
||||
require_account_owner(int(account_id), user)
|
||||
sup = ensure_account_program(int(account_id))
|
||||
if not sup.ok:
|
||||
raise HTTPException(status_code=500, detail=sup.error or "生成 supervisor 配置失败")
|
||||
return {
|
||||
"ok": True,
|
||||
"program": sup.program,
|
||||
"ini_path": sup.ini_path,
|
||||
"program_dir": sup.program_dir,
|
||||
"supervisor_conf": sup.supervisor_conf,
|
||||
"reread": sup.reread,
|
||||
"update": sup.update,
|
||||
}
|
||||
168
backend/api/routes/admin.py
Normal file
168
backend/api/routes/admin.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""
|
||||
管理员接口:用户管理 / 授权管理
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from api.auth_deps import get_admin_user
|
||||
from api.auth_utils import hash_password
|
||||
from database.models import User, UserAccountMembership, Account
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
class UserCreateReq(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=200)
|
||||
role: str = Field("user", pattern="^(admin|user)$")
|
||||
status: str = Field("active", pattern="^(active|disabled)$")
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(_admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
return User.list_all()
|
||||
|
||||
|
||||
@router.get("/users/detailed")
|
||||
async def list_users_with_accounts(_admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
"""获取所有用户及其关联账号列表"""
|
||||
users = User.list_all()
|
||||
out = []
|
||||
|
||||
# 获取所有授权关系
|
||||
# 优化:一次性查询所有 memberships 并在内存中分组,避免 N+1 查询
|
||||
# 但由于 UserAccountMembership 没有 list_all 方法,暂时循环查询或添加 list_all
|
||||
# 考虑到用户量不大,循环查询尚可接受。
|
||||
|
||||
for u in users:
|
||||
uid = u['id']
|
||||
memberships = UserAccountMembership.get_user_accounts(uid)
|
||||
user_accounts = []
|
||||
for m in memberships or []:
|
||||
user_accounts.append({
|
||||
"id": m.get("id"),
|
||||
"name": m.get("name"),
|
||||
"status": m.get("status"),
|
||||
"role": m.get("role"),
|
||||
"has_api_key": bool(m.get("api_key_enc")),
|
||||
"has_api_secret": bool(m.get("api_secret_enc"))
|
||||
})
|
||||
|
||||
out.append({
|
||||
"id": uid,
|
||||
"username": u['username'],
|
||||
"role": u['role'],
|
||||
"status": u['status'],
|
||||
"accounts": user_accounts
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(payload: UserCreateReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
exists = User.get_by_username(payload.username)
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
uid = User.create(
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
role=payload.role,
|
||||
status=payload.status,
|
||||
)
|
||||
return {"success": True, "id": int(uid)}
|
||||
|
||||
|
||||
class UserPasswordReq(BaseModel):
|
||||
password: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/password")
|
||||
async def set_user_password(user_id: int, payload: UserPasswordReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
User.set_password(int(user_id), hash_password(payload.password))
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class UserRoleReq(BaseModel):
|
||||
role: str = Field(..., pattern="^(admin|user)$")
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/role")
|
||||
async def set_user_role(user_id: int, payload: UserRoleReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
User.set_role(int(user_id), payload.role)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class UserStatusReq(BaseModel):
|
||||
status: str = Field(..., pattern="^(active|disabled)$")
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/status")
|
||||
async def set_user_status(user_id: int, payload: UserStatusReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
User.set_status(int(user_id), payload.status)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/accounts")
|
||||
async def list_user_accounts(user_id: int, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
memberships = UserAccountMembership.list_for_user(int(user_id))
|
||||
# 追加账号名称(便于前端展示)
|
||||
out = []
|
||||
for m in memberships or []:
|
||||
aid = int(m.get("account_id"))
|
||||
a = Account.get(aid) or {}
|
||||
out.append(
|
||||
{
|
||||
"user_id": int(m.get("user_id")),
|
||||
"account_id": aid,
|
||||
"role": m.get("role") or "viewer",
|
||||
"account_name": a.get("name") or "",
|
||||
"account_status": a.get("status") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class GrantReq(BaseModel):
|
||||
role: str = Field("viewer", pattern="^(owner|viewer)$")
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/accounts/{account_id}")
|
||||
async def grant_user_account(user_id: int, account_id: int, payload: GrantReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
a = Account.get(int(account_id))
|
||||
if not a:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
try:
|
||||
if payload.role == "owner":
|
||||
UserAccountMembership.clear_other_owners_for_account(int(account_id), int(user_id))
|
||||
UserAccountMembership.add(int(user_id), int(account_id), role=payload.role)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"关联账号失败: {str(e)}",
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}/accounts/{account_id}")
|
||||
async def revoke_user_account(user_id: int, account_id: int, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
UserAccountMembership.remove(int(user_id), int(account_id))
|
||||
return {"success": True}
|
||||
|
||||
71
backend/api/routes/auth.py
Normal file
71
backend/api/routes/auth.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""
|
||||
登录鉴权 API(JWT)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
|
||||
from database.models import User
|
||||
from api.auth_utils import verify_password, jwt_encode
|
||||
from api.auth_deps import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginReq(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class LoginResp(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: Dict[str, Any]
|
||||
|
||||
|
||||
def _auth_enabled() -> bool:
|
||||
v = (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower()
|
||||
return v not in {"0", "false", "no"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResp)
|
||||
async def login(payload: LoginReq):
|
||||
if not _auth_enabled():
|
||||
raise HTTPException(status_code=400, detail="当前环境未启用登录(ATS_AUTH_ENABLED=false)")
|
||||
|
||||
u = User.get_by_username(payload.username)
|
||||
if not u:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
if (u.get("status") or "active") != "active":
|
||||
raise HTTPException(status_code=403, detail="用户已被禁用")
|
||||
if not verify_password(payload.password, u.get("password_hash") or ""):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
token = jwt_encode({"sub": str(u["id"]), "role": u.get("role") or "user"}, exp_sec=24 * 3600)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"id": u["id"], "username": u["username"], "role": u.get("role") or "user", "status": u.get("status") or "active"},
|
||||
}
|
||||
|
||||
|
||||
class MeResp(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResp)
|
||||
async def me(user: Dict[str, Any] = Depends(get_current_user)):
|
||||
return {
|
||||
"id": int(user["id"]),
|
||||
"username": user.get("username") or "",
|
||||
"role": user.get("role") or "user",
|
||||
"status": user.get("status") or "active",
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
374
backend/api/routes/data_management.py
Normal file
374
backend/api/routes/data_management.py
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
"""
|
||||
数据管理:查询 DB 交易、从币安拉取订单/成交,供策略分析与导出。
|
||||
仅管理员可用。
|
||||
"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Query, Depends, HTTPException
|
||||
from typing import Optional
|
||||
|
||||
from api.auth_deps import get_admin_user
|
||||
from database.models import Trade, Account
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
router = APIRouter(prefix="/api/admin/data", tags=["数据管理"])
|
||||
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _get_timestamp_range(period: Optional[str], start_date: Optional[str], end_date: Optional[str]):
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
end_ts = int(now.timestamp())
|
||||
start_ts = None
|
||||
|
||||
if period:
|
||||
if period == "today":
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_ts = int(today.timestamp())
|
||||
elif period == "1d":
|
||||
start_ts = end_ts - 24 * 3600
|
||||
elif period == "7d":
|
||||
start_ts = end_ts - 7 * 24 * 3600
|
||||
elif period == "30d":
|
||||
start_ts = end_ts - 30 * 24 * 3600
|
||||
elif period == "week":
|
||||
days = now.weekday()
|
||||
week_start = (now - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_ts = int(week_start.timestamp())
|
||||
elif period == "month":
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start_ts = int(month_start.timestamp())
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
s = start_date if len(start_date) > 10 else f"{start_date} 00:00:00"
|
||||
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=BEIJING_TZ)
|
||||
start_ts = int(dt.timestamp())
|
||||
except ValueError:
|
||||
pass
|
||||
if end_date:
|
||||
try:
|
||||
s = end_date if len(end_date) > 10 else f"{end_date} 23:59:59"
|
||||
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=BEIJING_TZ)
|
||||
end_ts = int(dt.timestamp())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if start_ts is None:
|
||||
start_ts = end_ts - 7 * 24 * 3600 # 默认 7 天
|
||||
return start_ts, end_ts
|
||||
|
||||
|
||||
def _compute_binance_stats(data: list, data_type: str) -> dict:
|
||||
"""计算用于策略分析的统计数据(成交/订单原始字段均已保留,导出 JSON 含全部)"""
|
||||
stats = {"count": len(data)}
|
||||
valid = [r for r in data if isinstance(r, dict) and "_error" not in r]
|
||||
if not valid:
|
||||
return stats
|
||||
|
||||
if data_type == "trades":
|
||||
pnls = []
|
||||
commissions = []
|
||||
quote_qtys = []
|
||||
by_symbol = {}
|
||||
wins, losses = 0, 0
|
||||
maker_count, taker_count = 0, 0
|
||||
for r in valid:
|
||||
sym = r.get("_symbol") or r.get("symbol") or "-"
|
||||
p = float(r.get("realizedPnl") or 0)
|
||||
c = float(r.get("commission") or 0)
|
||||
qq = float(r.get("quoteQty") or 0)
|
||||
pnls.append(p)
|
||||
commissions.append(c)
|
||||
if qq:
|
||||
quote_qtys.append(qq)
|
||||
if p > 0:
|
||||
wins += 1
|
||||
elif p < 0:
|
||||
losses += 1
|
||||
if r.get("maker"):
|
||||
maker_count += 1
|
||||
else:
|
||||
taker_count += 1
|
||||
by_symbol[sym] = by_symbol.get(sym, {"count": 0, "pnl": 0.0, "commission": 0.0, "quoteQty": 0.0})
|
||||
by_symbol[sym]["count"] += 1
|
||||
by_symbol[sym]["pnl"] += p
|
||||
by_symbol[sym]["commission"] += c
|
||||
by_symbol[sym]["quoteQty"] += qq
|
||||
|
||||
stats["total_realized_pnl"] = round(sum(pnls), 4)
|
||||
stats["total_commission"] = round(sum(commissions), 4)
|
||||
stats["net_pnl"] = round(stats["total_realized_pnl"] - stats["total_commission"], 4)
|
||||
stats["win_count"] = wins
|
||||
stats["loss_count"] = losses
|
||||
stats["win_rate"] = round(100 * wins / (wins + losses), 1) if (wins + losses) > 0 else 0
|
||||
stats["avg_pnl_per_trade"] = round(sum(pnls) / len(pnls), 4) if pnls else 0
|
||||
stats["total_quote_qty"] = round(sum(quote_qtys), 2)
|
||||
stats["maker_count"] = maker_count
|
||||
stats["taker_count"] = taker_count
|
||||
stats["by_symbol"] = {
|
||||
k: {
|
||||
"count": v["count"],
|
||||
"pnl": round(v["pnl"], 4),
|
||||
"commission": round(v["commission"], 4),
|
||||
"quoteQty": round(v["quoteQty"], 2),
|
||||
}
|
||||
for k, v in sorted(by_symbol.items())
|
||||
}
|
||||
|
||||
by_hour = {}
|
||||
by_weekday = {}
|
||||
weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
for r in valid:
|
||||
t = r.get("time") or r.get("trade_time") or 0
|
||||
if t:
|
||||
dt = datetime.fromtimestamp(t / 1000, tz=BEIJING_TZ)
|
||||
h = dt.hour
|
||||
wd = dt.weekday()
|
||||
by_hour[h] = by_hour.get(h, {"count": 0, "pnl": 0.0})
|
||||
by_hour[h]["count"] += 1
|
||||
by_hour[h]["pnl"] += float(r.get("realizedPnl") or 0)
|
||||
by_weekday[wd] = by_weekday.get(wd, {"count": 0, "pnl": 0.0})
|
||||
by_weekday[wd]["count"] += 1
|
||||
by_weekday[wd]["pnl"] += float(r.get("realizedPnl") or 0)
|
||||
stats["by_hour"] = {str(k): {"count": v["count"], "pnl": round(v["pnl"], 4)} for k, v in sorted(by_hour.items())}
|
||||
stats["by_weekday"] = {weekday_names[k]: {"count": v["count"], "pnl": round(v["pnl"], 4)} for k, v in sorted(by_weekday.items())}
|
||||
else:
|
||||
by_status = {}
|
||||
by_type = {}
|
||||
by_symbol = {}
|
||||
filled_count = 0
|
||||
for r in valid:
|
||||
status = r.get("status") or "UNKNOWN"
|
||||
typ = r.get("type") or r.get("origType") or "UNKNOWN"
|
||||
sym = r.get("_symbol") or r.get("symbol") or "-"
|
||||
by_status[status] = by_status.get(status, 0) + 1
|
||||
by_type[typ] = by_type.get(typ, 0) + 1
|
||||
by_symbol[sym] = by_symbol.get(sym, 0) + 1
|
||||
if status == "FILLED":
|
||||
filled_count += 1
|
||||
stats["by_status"] = by_status
|
||||
stats["by_type"] = by_type
|
||||
stats["by_symbol"] = dict(sorted(by_symbol.items()))
|
||||
stats["filled_count"] = filled_count
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def _get_active_symbols_from_income(binance_client, start_ms: int, end_ms: int) -> list:
|
||||
"""
|
||||
通过收益历史 API 获取该时间段内有交易活动的交易对,避免全量遍历 250+ 交易对。
|
||||
一次 API 调用(weight 100)即可拿到有成交/盈亏的 symbol 列表,大幅减少后续 trades/orders 的请求数。
|
||||
"""
|
||||
try:
|
||||
symbols = set()
|
||||
current_end = end_ms
|
||||
for _ in range(10): # 最多分页 10 次(单次最多 1000 条)
|
||||
rows = await binance_client.futures_income_history(
|
||||
startTime=start_ms,
|
||||
endTime=current_end,
|
||||
limit=1000,
|
||||
recvWindow=20000,
|
||||
)
|
||||
if not rows:
|
||||
break
|
||||
for r in rows:
|
||||
sym = (r.get("symbol") or "").strip()
|
||||
if sym and sym.endswith("USDT"):
|
||||
symbols.add(sym)
|
||||
if len(rows) < 1000:
|
||||
break
|
||||
oldest = min(r.get("time", current_end) for r in rows)
|
||||
current_end = oldest - 1
|
||||
if current_end < start_ms:
|
||||
break
|
||||
await asyncio.sleep(0.15)
|
||||
return sorted(symbols)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/accounts")
|
||||
async def list_accounts(_admin=Depends(get_admin_user), active_only: bool = Query(False)):
|
||||
"""获取账号列表,供数据管理选择。active_only=true 时仅返回 status=active 的账号"""
|
||||
rows = Account.list_all()
|
||||
accounts = [{"id": r["id"], "name": r.get("name") or f"Account {r['id']}", "status": r.get("status") or "active"} for r in (rows or [])]
|
||||
if active_only:
|
||||
accounts = [a for a in accounts if (a.get("status") or "").lower() == "active"]
|
||||
return {"accounts": accounts}
|
||||
|
||||
|
||||
@router.get("/trades")
|
||||
async def query_db_trades(
|
||||
_admin=Depends(get_admin_user),
|
||||
account_id: int = Query(..., ge=1, description="账号 ID"),
|
||||
period: Optional[str] = Query(None, description="today/1d/7d/30d/week/month"),
|
||||
date: Optional[str] = Query(None, description="YYYY-MM-DD,指定日期(等同于 start_date=end_date)"),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
symbol: Optional[str] = Query(None),
|
||||
time_filter: str = Query("created", description="created/entry/exit"),
|
||||
reconciled_only: Optional[str] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=2000),
|
||||
):
|
||||
"""
|
||||
查询 DB 交易记录(管理员可指定任意账号)
|
||||
"""
|
||||
sd, ed = start_date, end_date
|
||||
if date:
|
||||
sd, ed = date, date
|
||||
_reconciled = str(reconciled_only or "").lower() in ("true", "1", "yes")
|
||||
start_ts, end_ts = _get_timestamp_range(period or "today", sd, ed)
|
||||
trades = Trade.get_all(
|
||||
start_timestamp=start_ts,
|
||||
end_timestamp=end_ts,
|
||||
symbol=symbol,
|
||||
status=None,
|
||||
account_id=account_id,
|
||||
time_filter=time_filter,
|
||||
limit=limit,
|
||||
reconciled_only=_reconciled,
|
||||
include_sync=True,
|
||||
)
|
||||
out = []
|
||||
for t in trades:
|
||||
row = dict(t)
|
||||
for k, v in row.items():
|
||||
if hasattr(v, "isoformat"):
|
||||
row[k] = v.isoformat()
|
||||
out.append(row)
|
||||
return {"total": len(out), "trades": out}
|
||||
|
||||
|
||||
def _enrich_trades_with_derived(trades: list) -> list:
|
||||
"""补充推算字段:入场价、交易小时、星期,便于策略分析"""
|
||||
result = []
|
||||
for r in trades:
|
||||
out = dict(r)
|
||||
t = r.get("time") or 0
|
||||
if t:
|
||||
dt = datetime.fromtimestamp(t / 1000, tz=BEIJING_TZ)
|
||||
out["_trade_hour"] = dt.hour
|
||||
out["_trade_weekday"] = dt.weekday()
|
||||
out["_trade_date"] = dt.strftime("%Y-%m-%d")
|
||||
pnl = float(r.get("realizedPnl") or 0)
|
||||
qty = float(r.get("qty") or 0)
|
||||
price = float(r.get("price") or 0)
|
||||
side = (r.get("side") or "").upper()
|
||||
if qty and pnl != 0 and side:
|
||||
if side == "SELL":
|
||||
out["_approx_entry_price"] = round(price - pnl / qty, 8)
|
||||
else:
|
||||
out["_approx_entry_price"] = round(price + pnl / qty, 8)
|
||||
else:
|
||||
out["_approx_entry_price"] = None
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
def _binance_row_to_api_format(row: dict, data_type: str) -> dict:
|
||||
"""将 DB 行转换为前端/导出期望的币安 API 格式"""
|
||||
if data_type == "trades":
|
||||
return {
|
||||
"id": row.get("trade_id"),
|
||||
"orderId": row.get("order_id"),
|
||||
"symbol": row.get("symbol"),
|
||||
"_symbol": row.get("symbol"),
|
||||
"side": row.get("side"),
|
||||
"positionSide": row.get("position_side"),
|
||||
"price": str(row.get("price") or ""),
|
||||
"qty": str(row.get("qty") or ""),
|
||||
"quoteQty": str(row.get("quote_qty") or ""),
|
||||
"realizedPnl": str(row.get("realized_pnl") or ""),
|
||||
"commission": str(row.get("commission") or ""),
|
||||
"commissionAsset": row.get("commission_asset"),
|
||||
"buyer": bool(row.get("buyer")),
|
||||
"maker": bool(row.get("maker")),
|
||||
"time": row.get("trade_time"),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"orderId": row.get("order_id"),
|
||||
"clientOrderId": row.get("client_order_id"),
|
||||
"symbol": row.get("symbol"),
|
||||
"_symbol": row.get("symbol"),
|
||||
"side": row.get("side"),
|
||||
"type": row.get("type"),
|
||||
"origType": row.get("orig_type"),
|
||||
"status": row.get("status"),
|
||||
"price": str(row.get("price") or ""),
|
||||
"avgPrice": str(row.get("avg_price") or ""),
|
||||
"origQty": str(row.get("orig_qty") or ""),
|
||||
"executedQty": str(row.get("executed_qty") or ""),
|
||||
"cumQty": str(row.get("cum_qty") or ""),
|
||||
"cumQuote": str(row.get("cum_quote") or ""),
|
||||
"stopPrice": str(row.get("stop_price") or "") if row.get("stop_price") else "",
|
||||
"reduceOnly": bool(row.get("reduce_only")),
|
||||
"positionSide": row.get("position_side"),
|
||||
"time": row.get("order_time"),
|
||||
"updateTime": row.get("update_time"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/binance-fetch")
|
||||
async def query_binance_data_from_db(
|
||||
_admin=Depends(get_admin_user),
|
||||
account_id: int = Query(..., ge=1),
|
||||
symbols: Optional[str] = Query(None, description="交易对,逗号分隔;留空则全部"),
|
||||
data_type: str = Query("trades", description="orders 或 trades"),
|
||||
days: int = Query(7, ge=0, le=7),
|
||||
):
|
||||
"""
|
||||
从 DB 查询已同步的币安订单/成交(由定时任务 scripts/sync_binance_orders.py 拉取入库)
|
||||
"""
|
||||
from database.connection import db
|
||||
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
end_ts = int(now.timestamp())
|
||||
if days == 0:
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_ts = int(today_start.timestamp())
|
||||
else:
|
||||
start_ts = end_ts - days * 24 * 3600
|
||||
start_ms = start_ts * 1000
|
||||
end_ms = end_ts * 1000
|
||||
|
||||
symbol_list = [s.strip().upper() for s in (symbols or "").split(",") if s.strip()]
|
||||
|
||||
try:
|
||||
if data_type == "trades":
|
||||
q = """SELECT * FROM binance_trades
|
||||
WHERE account_id = %s AND trade_time >= %s AND trade_time <= %s"""
|
||||
params = [account_id, start_ms, end_ms]
|
||||
if symbol_list:
|
||||
q += " AND symbol IN (" + ",".join(["%s"] * len(symbol_list)) + ")"
|
||||
params.extend(symbol_list)
|
||||
q += " ORDER BY trade_time DESC LIMIT 5000"
|
||||
else:
|
||||
q = """SELECT * FROM binance_orders
|
||||
WHERE account_id = %s AND order_time >= %s AND order_time <= %s"""
|
||||
params = [account_id, start_ms, end_ms]
|
||||
if symbol_list:
|
||||
q += " AND symbol IN (" + ",".join(["%s"] * len(symbol_list)) + ")"
|
||||
params.extend(symbol_list)
|
||||
q += " ORDER BY order_time DESC LIMIT 5000"
|
||||
|
||||
rows = db.execute_query(q, params)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"查询失败(请确认已执行 add_binance_sync_tables.sql 并运行过同步脚本): {e}")
|
||||
|
||||
all_data = [_binance_row_to_api_format(dict(r), data_type) for r in (rows or [])]
|
||||
if data_type == "trades":
|
||||
all_data = _enrich_trades_with_derived(all_data)
|
||||
symbols_queried = len(symbol_list) if symbol_list else len({(r or {}).get("symbol") for r in (rows or []) if (r or {}).get("symbol")})
|
||||
stats = _compute_binance_stats(all_data, data_type)
|
||||
|
||||
return {
|
||||
"total": len(all_data),
|
||||
"data_type": data_type,
|
||||
"symbols_queried": symbols_queried,
|
||||
"stats": stats,
|
||||
"data": all_data,
|
||||
"source": "db",
|
||||
}
|
||||
183
backend/api/routes/public.py
Normal file
183
backend/api/routes/public.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""
|
||||
公开只读状态接口(非管理员也可访问)
|
||||
|
||||
用途:
|
||||
- 普通用户能看到:后端是否在线、启动时间、推荐是否在更新(snapshot 时间)
|
||||
- recommendations-viewer 也可复用该接口展示“服务状态”
|
||||
|
||||
安全原则:
|
||||
- 不返回任何敏感信息(不返回密钥、密码、完整 Redis URL 等)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
try:
|
||||
import redis.asyncio as redis_async
|
||||
except Exception: # pragma: no cover
|
||||
redis_async = None
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
_STARTED_AT_MS = int(time.time() * 1000)
|
||||
|
||||
REDIS_KEY_RECOMMENDATIONS_SNAPSHOT = "recommendations:snapshot"
|
||||
|
||||
|
||||
def _beijing_time_str(ts_ms: Optional[int] = None) -> str:
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
if ts_ms is None:
|
||||
return datetime.now(tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _mask_redis_url(redis_url: str) -> str:
|
||||
s = (redis_url or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
# 简单脱敏:去掉 username/password(如果有)
|
||||
# rediss://user:pass@host:6379/0 -> rediss://***@host:6379/0
|
||||
if "://" in s and "@" in s:
|
||||
scheme, rest = s.split("://", 1)
|
||||
creds_and_host = rest
|
||||
# 仅替换 @ 前面的内容
|
||||
idx = creds_and_host.rfind("@")
|
||||
if idx > 0:
|
||||
return f"{scheme}://***@{creds_and_host[idx+1:]}"
|
||||
return s
|
||||
|
||||
|
||||
def _redis_connection_kwargs() -> Tuple[str, Dict[str, Any]]:
|
||||
redis_url = (os.getenv("REDIS_URL", "") or "").strip() or "redis://localhost:6379"
|
||||
username = os.getenv("REDIS_USERNAME", None)
|
||||
password = os.getenv("REDIS_PASSWORD", None)
|
||||
ssl_cert_reqs = (os.getenv("REDIS_SSL_CERT_REQS", "required") or "required").strip()
|
||||
ssl_ca_certs = os.getenv("REDIS_SSL_CA_CERTS", None)
|
||||
|
||||
select = os.getenv("REDIS_SELECT", None)
|
||||
try:
|
||||
select_i = int(select) if select is not None else 0
|
||||
except Exception:
|
||||
select_i = 0
|
||||
|
||||
kwargs: Dict[str, Any] = {"decode_responses": True}
|
||||
if username:
|
||||
kwargs["username"] = username
|
||||
if password:
|
||||
kwargs["password"] = password
|
||||
kwargs["db"] = select_i
|
||||
|
||||
use_tls = redis_url.startswith("rediss://") or (os.getenv("REDIS_USE_TLS", "False").lower() == "true")
|
||||
if use_tls and not redis_url.startswith("rediss://"):
|
||||
if redis_url.startswith("redis://"):
|
||||
redis_url = redis_url.replace("redis://", "rediss://", 1)
|
||||
else:
|
||||
redis_url = f"rediss://{redis_url}"
|
||||
|
||||
if use_tls or redis_url.startswith("rediss://"):
|
||||
kwargs["ssl_cert_reqs"] = ssl_cert_reqs
|
||||
if ssl_ca_certs:
|
||||
kwargs["ssl_ca_certs"] = ssl_ca_certs
|
||||
kwargs["ssl_check_hostname"] = (ssl_cert_reqs == "required")
|
||||
|
||||
return redis_url, kwargs
|
||||
|
||||
|
||||
async def _get_redis():
|
||||
if redis_async is None:
|
||||
return None
|
||||
redis_url, kwargs = _redis_connection_kwargs()
|
||||
try:
|
||||
client = redis_async.from_url(redis_url, **kwargs)
|
||||
await client.ping()
|
||||
return client
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _get_cached_json(client, key: str) -> Optional[Any]:
|
||||
try:
|
||||
raw = await client.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def public_status():
|
||||
"""
|
||||
公共状态:
|
||||
- backend:在线/启动时间
|
||||
- redis:可用性(不暴露密码)
|
||||
- recommendations:snapshot 最新生成时间(若推荐进程在跑,会持续更新)
|
||||
"""
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
# Redis + 推荐快照
|
||||
redis_ok = False
|
||||
reco: Dict[str, Any] = {"snapshot_ok": False}
|
||||
redis_meta: Dict[str, Any] = {"ok": False, "db": int(os.getenv("REDIS_SELECT", "0") or 0), "url": _mask_redis_url(os.getenv("REDIS_URL", ""))}
|
||||
|
||||
rds = await _get_redis()
|
||||
if rds is not None:
|
||||
redis_ok = True
|
||||
redis_meta["ok"] = True
|
||||
try:
|
||||
snap = await _get_cached_json(rds, REDIS_KEY_RECOMMENDATIONS_SNAPSHOT)
|
||||
except Exception:
|
||||
snap = None
|
||||
|
||||
if isinstance(snap, dict):
|
||||
gen_ms = snap.get("generated_at_ms")
|
||||
try:
|
||||
gen_ms = int(gen_ms) if gen_ms is not None else None
|
||||
except Exception:
|
||||
gen_ms = None
|
||||
count = snap.get("count")
|
||||
try:
|
||||
count = int(count) if count is not None else None
|
||||
except Exception:
|
||||
count = None
|
||||
age_sec = None
|
||||
if gen_ms:
|
||||
age_sec = max(0, int((now_ms - gen_ms) / 1000))
|
||||
reco = {
|
||||
"snapshot_ok": True,
|
||||
"generated_at_ms": gen_ms,
|
||||
"generated_at": snap.get("generated_at"),
|
||||
"generated_at_beijing": _beijing_time_str(gen_ms) if gen_ms else None,
|
||||
"age_sec": age_sec,
|
||||
"count": count,
|
||||
"ttl_sec": snap.get("ttl_sec"),
|
||||
}
|
||||
|
||||
try:
|
||||
await rds.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"backend": {
|
||||
"running": True,
|
||||
"started_at_ms": _STARTED_AT_MS,
|
||||
"started_at": _beijing_time_str(_STARTED_AT_MS),
|
||||
"now_ms": now_ms,
|
||||
"now": _beijing_time_str(now_ms),
|
||||
},
|
||||
"redis": redis_meta,
|
||||
"recommendations": reco,
|
||||
"auth": {
|
||||
"enabled": (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower() not in {"0", "false", "no"},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -366,6 +366,14 @@ async def get_recommendations(
|
|||
|
||||
# 限制返回数量
|
||||
recommendations = recommendations[:limit]
|
||||
# 合约推荐为空时给出排查提示(与现货独立:现货来自定时扫描,合约来自策略/推荐服务)
|
||||
hint = None
|
||||
if len(recommendations) == 0:
|
||||
hint = (
|
||||
"合约推荐来自策略扫描:需信号强度≥5且方向明确才会写入。"
|
||||
"若长期为空,请检查:1) 推荐服务(recommendations_main)或主策略(main)是否在运行;"
|
||||
"2) 扫描日志中是否有「信号:N」≥5 的标的;3) 是否有推荐被时间/价格偏离过滤掉(见 meta.dropped)。"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
|
@ -384,6 +392,7 @@ async def get_recommendations(
|
|||
"price_drift": dropped_drift,
|
||||
"invalid": dropped_invalid,
|
||||
},
|
||||
"hint": hint,
|
||||
},
|
||||
"data": recommendations
|
||||
}
|
||||
|
|
@ -495,6 +504,72 @@ async def get_recommendations(
|
|||
raise HTTPException(status_code=500, detail=f"获取推荐列表失败: {str(e)}")
|
||||
|
||||
|
||||
REDIS_KEY_SPOT_SNAPSHOT = "recommendations:spot:snapshot"
|
||||
|
||||
|
||||
@router.get("/spot")
|
||||
async def get_spot_recommendations(
|
||||
limit: int = Query(50, ge=1, le=200, description="返回数量限制"),
|
||||
):
|
||||
"""
|
||||
获取现货推荐(只做多)。数据来自定时任务扫描并写入的 Redis 缓存。
|
||||
"""
|
||||
try:
|
||||
rds = await _get_redis()
|
||||
if rds is None:
|
||||
raise HTTPException(status_code=503, detail="Redis 不可用,无法读取现货推荐缓存")
|
||||
snapshot = await _get_cached_json(rds, REDIS_KEY_SPOT_SNAPSHOT)
|
||||
if not isinstance(snapshot, dict):
|
||||
return {
|
||||
"success": True,
|
||||
"count": 0,
|
||||
"type": "spot",
|
||||
"from_cache": False,
|
||||
"meta": {"generated_at": None, "message": "暂无现货推荐数据,请等待定时扫描更新"},
|
||||
"data": [],
|
||||
}
|
||||
items = snapshot.get("items") or []
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
items = items[:limit]
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(items),
|
||||
"type": "spot",
|
||||
"from_cache": True,
|
||||
"meta": {
|
||||
"generated_at": snapshot.get("generated_at"),
|
||||
"generated_at_ms": snapshot.get("generated_at_ms"),
|
||||
"ttl_sec": snapshot.get("ttl_sec"),
|
||||
},
|
||||
"data": items,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取现货推荐失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取现货推荐失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/spot/scan")
|
||||
async def trigger_spot_scan():
|
||||
"""
|
||||
手动触发一次现货扫描并更新 Redis 缓存(供定时任务或管理员调用)。
|
||||
"""
|
||||
try:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
backend_dir = Path(__file__).resolve().parent.parent.parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
from spot_scanner import run_spot_scan_and_cache
|
||||
count = await run_spot_scan_and_cache(ttl_sec=900)
|
||||
return {"success": True, "message": f"已扫描并缓存 {count} 条现货推荐", "count": count}
|
||||
except Exception as e:
|
||||
logger.error(f"现货扫描失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"现货扫描失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
async def get_active_recommendations():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
统计分析API
|
||||
"""
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Query, Header, Depends
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
|
@ -11,23 +11,251 @@ project_root = Path(__file__).parent.parent.parent.parent
|
|||
sys.path.insert(0, str(project_root))
|
||||
sys.path.insert(0, str(project_root / 'backend'))
|
||||
|
||||
from database.models import AccountSnapshot, Trade, MarketScan, TradingSignal
|
||||
from database.models import AccountSnapshot, Trade, MarketScan, TradingSignal, Account, TradeStats
|
||||
from fastapi import HTTPException
|
||||
from api.auth_deps import get_account_id, get_admin_user
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin/dashboard")
|
||||
async def get_admin_dashboard_stats(user: Dict[str, Any] = Depends(get_admin_user)):
|
||||
"""获取管理员仪表板数据:总资产来自各账号快照汇总(不调币安),总盈亏为最近7天聚合已实现盈亏。"""
|
||||
try:
|
||||
accounts = Account.list_all()
|
||||
stats = []
|
||||
total_assets = 0.0
|
||||
active_accounts = 0
|
||||
for acc in accounts:
|
||||
aid = acc["id"]
|
||||
# 取最近 30 天内的快照,再取最新一条,避免“仅 1 天”导致无数据
|
||||
snapshots = AccountSnapshot.get_recent(30, account_id=aid)
|
||||
acc_stat = {
|
||||
"id": aid,
|
||||
"name": acc["name"],
|
||||
"status": acc["status"],
|
||||
"total_balance": 0,
|
||||
"total_pnl": 0,
|
||||
"open_positions": 0,
|
||||
}
|
||||
if snapshots:
|
||||
snap = snapshots[0]
|
||||
acc_stat["total_balance"] = snap.get("total_balance", 0)
|
||||
acc_stat["total_pnl"] = snap.get("total_pnl", 0)
|
||||
acc_stat["open_positions"] = snap.get("open_positions", 0)
|
||||
total_assets += float(acc_stat["total_balance"])
|
||||
if acc["status"] == "active":
|
||||
active_accounts += 1
|
||||
stats.append(acc_stat)
|
||||
total_pnl_7d = 0.0
|
||||
try:
|
||||
global_symbols = TradeStats.get_global_symbol_stats(days=7)
|
||||
for row in global_symbols:
|
||||
total_pnl_7d += float(row.get("net_pnl") or 0)
|
||||
except Exception as e:
|
||||
logger.debug(f"获取全局7天净盈亏失败: {e}")
|
||||
return {
|
||||
"summary": {
|
||||
"total_accounts": len(accounts),
|
||||
"active_accounts": active_accounts,
|
||||
"total_assets_usdt": round(total_assets, 2),
|
||||
"total_pnl_usdt": round(total_pnl_7d, 2),
|
||||
},
|
||||
"accounts": stats,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取管理员仪表板数据失败: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/admin/overall-trade-stats")
|
||||
async def get_admin_overall_trade_stats(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
user: Dict[str, Any] = Depends(get_admin_user),
|
||||
):
|
||||
"""管理员:全账号最近 N 天整体订单统计。"""
|
||||
try:
|
||||
by_symbol_raw = TradeStats.get_global_symbol_stats(days=days)
|
||||
by_hour_raw = TradeStats.get_global_hourly_stats(days=days)
|
||||
by_symbol = []
|
||||
for row in by_symbol_raw:
|
||||
tc = int(row.get("trade_count") or 0)
|
||||
win_count = int(row.get("win_count") or 0)
|
||||
loss_count = int(row.get("loss_count") or 0)
|
||||
net_pnl = float(row.get("net_pnl") or 0)
|
||||
win_rate = (100.0 * win_count / tc) if tc > 0 else 0.0
|
||||
by_symbol.append({
|
||||
"symbol": (row.get("symbol") or "").strip(),
|
||||
"trade_count": tc,
|
||||
"win_count": win_count,
|
||||
"loss_count": loss_count,
|
||||
"net_pnl": round(net_pnl, 4),
|
||||
"win_rate_pct": round(win_rate, 1),
|
||||
})
|
||||
by_symbol = [x for x in by_symbol if x["symbol"]]
|
||||
by_symbol.sort(key=lambda x: (-x["net_pnl"], -x["trade_count"]))
|
||||
hourly_agg = [{"hour": h, "trade_count": 0, "net_pnl": 0.0} for h in range(24)]
|
||||
for row in by_hour_raw:
|
||||
h = row.get("hour")
|
||||
if h is not None and 0 <= int(h) <= 23:
|
||||
hi = int(h)
|
||||
hourly_agg[hi]["trade_count"] = int(row.get("trade_count") or 0)
|
||||
hourly_agg[hi]["net_pnl"] = round(float(row.get("net_pnl") or 0), 4)
|
||||
total_trade_count = sum(x["trade_count"] for x in by_symbol)
|
||||
total_win = sum(x["win_count"] for x in by_symbol)
|
||||
total_loss = sum(x["loss_count"] for x in by_symbol)
|
||||
total_net_pnl = sum(x["net_pnl"] for x in by_symbol)
|
||||
suggestions = _build_suggestions(by_symbol)
|
||||
return {
|
||||
"days": days,
|
||||
"summary": {
|
||||
"trade_count": total_trade_count,
|
||||
"win_count": total_win,
|
||||
"loss_count": total_loss,
|
||||
"net_pnl": round(total_net_pnl, 4),
|
||||
},
|
||||
"by_symbol": by_symbol,
|
||||
"hourly_agg": hourly_agg,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("get_admin_overall_trade_stats 失败")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _aggregate_daily_by_symbol(daily: list) -> list:
|
||||
"""将 daily(按 date+symbol)聚合成按 symbol 的汇总。"""
|
||||
from collections import defaultdict
|
||||
agg = defaultdict(lambda: {"trade_count": 0, "win_count": 0, "loss_count": 0, "net_pnl": 0.0})
|
||||
for row in daily:
|
||||
sym = (row.get("symbol") or "").strip()
|
||||
if not sym:
|
||||
continue
|
||||
agg[sym]["trade_count"] += int(row.get("trade_count") or 0)
|
||||
agg[sym]["win_count"] += int(row.get("win_count") or 0)
|
||||
agg[sym]["loss_count"] += int(row.get("loss_count") or 0)
|
||||
try:
|
||||
agg[sym]["net_pnl"] += float(row.get("net_pnl") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
out = []
|
||||
for symbol, v in agg.items():
|
||||
tc = v["trade_count"]
|
||||
win_rate = (100.0 * v["win_count"] / tc) if tc > 0 else 0.0
|
||||
out.append({
|
||||
"symbol": symbol,
|
||||
"trade_count": tc,
|
||||
"win_count": v["win_count"],
|
||||
"loss_count": v["loss_count"],
|
||||
"net_pnl": round(v["net_pnl"], 4),
|
||||
"win_rate_pct": round(win_rate, 1),
|
||||
})
|
||||
return sorted(out, key=lambda x: (-x["net_pnl"], -x["trade_count"]))
|
||||
|
||||
|
||||
def _aggregate_hourly(by_hour: list) -> list:
|
||||
"""将 by_hour(按 date+hour)聚合成按 hour 0-23 的汇总。"""
|
||||
from collections import defaultdict
|
||||
agg = defaultdict(lambda: {"trade_count": 0, "net_pnl": 0.0})
|
||||
for row in by_hour:
|
||||
h = row.get("hour")
|
||||
if h is None:
|
||||
continue
|
||||
try:
|
||||
h = int(h)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 0 <= h <= 23:
|
||||
agg[h]["trade_count"] += int(row.get("trade_count") or 0)
|
||||
try:
|
||||
agg[h]["net_pnl"] += float(row.get("net_pnl") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return [{"hour": h, "trade_count": agg[h]["trade_count"], "net_pnl": round(agg[h]["net_pnl"], 4)} for h in range(24)]
|
||||
|
||||
|
||||
def _build_suggestions(by_symbol: list) -> dict:
|
||||
"""
|
||||
根据按交易对汇总生成白名单/黑名单建议(仅展示,不自动改策略)。
|
||||
- 黑名单:净亏且笔数多 → 建议降权或观察
|
||||
- 白名单:净盈且胜率较高、笔数足够 → 可优先考虑
|
||||
"""
|
||||
blacklist = []
|
||||
whitelist = []
|
||||
for row in by_symbol:
|
||||
sym = row.get("symbol", "")
|
||||
tc = int(row.get("trade_count") or 0)
|
||||
net_pnl = float(row.get("net_pnl") or 0)
|
||||
win_rate = float(row.get("win_rate_pct") or 0)
|
||||
if tc < 2:
|
||||
continue
|
||||
if net_pnl < 0:
|
||||
blacklist.append({
|
||||
"symbol": sym,
|
||||
"trade_count": tc,
|
||||
"net_pnl": round(net_pnl, 2),
|
||||
"win_rate_pct": round(win_rate, 1),
|
||||
"suggestion": "近期净亏且笔数较多,建议降权或观察后再开仓",
|
||||
})
|
||||
elif net_pnl > 0 and win_rate >= 50:
|
||||
whitelist.append({
|
||||
"symbol": sym,
|
||||
"trade_count": tc,
|
||||
"net_pnl": round(net_pnl, 2),
|
||||
"win_rate_pct": round(win_rate, 1),
|
||||
"suggestion": "近期净盈且胜率尚可,可优先考虑",
|
||||
})
|
||||
return {"blacklist": blacklist, "whitelist": whitelist}
|
||||
|
||||
|
||||
@router.get("/trade-stats")
|
||||
async def get_trade_stats(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
account_id: int = Depends(get_account_id),
|
||||
):
|
||||
"""获取交易统计:最近 N 天按交易对、按小时聚合(来自 trade_stats_daily / trade_stats_time_bucket)。
|
||||
返回原始 daily/by_hour、按交易对汇总 by_symbol、按小时汇总 hourly_agg、以及白名单/黑名单建议。"""
|
||||
try:
|
||||
daily = TradeStats.get_daily_stats(account_id=account_id, days=days)
|
||||
by_hour = TradeStats.get_hourly_stats(account_id=account_id, days=days)
|
||||
by_symbol = _aggregate_daily_by_symbol(daily)
|
||||
hourly_agg = _aggregate_hourly(by_hour)
|
||||
suggestions = _build_suggestions(by_symbol)
|
||||
return {
|
||||
"days": days,
|
||||
"daily": daily,
|
||||
"by_hour": by_hour,
|
||||
"by_symbol": by_symbol,
|
||||
"hourly_agg": hourly_agg,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("get_trade_stats 失败")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/performance")
|
||||
async def get_performance_stats(days: int = Query(7, ge=1, le=365)):
|
||||
async def get_performance_stats(
|
||||
days: int = Query(7, ge=1, le=365),
|
||||
account_id: int = Depends(get_account_id),
|
||||
):
|
||||
"""获取性能统计"""
|
||||
try:
|
||||
# 账户快照
|
||||
snapshots = AccountSnapshot.get_recent(days)
|
||||
snapshots = AccountSnapshot.get_recent(days, account_id=account_id)
|
||||
|
||||
# 交易统计
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
trades = Trade.get_all(start_date=start_date)
|
||||
# 交易统计(时间范围 + limit 防内存暴增)
|
||||
start_ts = int((datetime.now() - timedelta(days=days)).timestamp())
|
||||
end_ts = int(datetime.now().timestamp())
|
||||
trades = Trade.get_all(
|
||||
start_timestamp=start_ts,
|
||||
end_timestamp=end_ts,
|
||||
account_id=account_id,
|
||||
time_filter="exit",
|
||||
limit=10000,
|
||||
)
|
||||
|
||||
return {
|
||||
"snapshots": snapshots,
|
||||
|
|
@ -39,24 +267,32 @@ async def get_performance_stats(days: int = Query(7, ge=1, le=365)):
|
|||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_dashboard_data():
|
||||
async def get_dashboard_data(account_id: int = Depends(get_account_id)):
|
||||
"""获取仪表板数据"""
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"获取仪表板数据 - account_id={account_id}")
|
||||
logger.info("=" * 60)
|
||||
try:
|
||||
account_data = None
|
||||
account_error = None
|
||||
|
||||
# 优先尝试获取实时账户数据
|
||||
# 优先请求币安实时余额;失败时(如 -1003 IP 封禁)再回退到数据库快照
|
||||
try:
|
||||
from api.routes.account import get_realtime_account_data
|
||||
account_data = await get_realtime_account_data()
|
||||
logger.info("成功获取实时账户数据")
|
||||
except HTTPException as e:
|
||||
# HTTPException 需要特殊处理,提取错误信息
|
||||
account_error = e.detail
|
||||
logger.warning(f"获取实时账户数据失败 (HTTP {e.status_code}): {account_error}")
|
||||
# 回退到数据库快照
|
||||
account_data = await get_realtime_account_data(account_id=account_id)
|
||||
if account_data and account_data.get('total_balance') is not None:
|
||||
logger.info("使用币安实时账户数据")
|
||||
else:
|
||||
account_data = None
|
||||
account_error = "实时余额返回为空"
|
||||
except Exception as live_err:
|
||||
account_error = str(live_err)
|
||||
logger.warning(f"获取实时账户数据失败 (account_id={account_id}),回退到数据库快照: {live_err}")
|
||||
|
||||
# 实时请求失败或无数据时,使用数据库快照
|
||||
if not account_data or account_data.get('total_balance') is None:
|
||||
try:
|
||||
snapshots = AccountSnapshot.get_recent(1)
|
||||
snapshots = AccountSnapshot.get_recent(1, account_id=account_id)
|
||||
if snapshots:
|
||||
account_data = {
|
||||
"total_balance": snapshots[0].get('total_balance', 0),
|
||||
|
|
@ -67,94 +303,68 @@ async def get_dashboard_data():
|
|||
}
|
||||
logger.info("使用数据库快照作为账户数据")
|
||||
else:
|
||||
logger.warning("数据库中没有账户快照数据")
|
||||
if not account_data:
|
||||
account_data = {}
|
||||
account_data.setdefault("total_balance", 0)
|
||||
account_data.setdefault("available_balance", 0)
|
||||
account_data.setdefault("total_position_value", 0)
|
||||
account_data.setdefault("total_pnl", 0)
|
||||
account_data.setdefault("open_positions", 0)
|
||||
logger.warning("数据库中没有账户快照数据,仪表板显示 0;交易进程会定期写入快照")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取账户快照失败: {db_error}")
|
||||
except Exception as e:
|
||||
account_error = str(e)
|
||||
logger.warning(f"获取实时账户数据失败: {account_error}", exc_info=True)
|
||||
# 回退到数据库快照
|
||||
try:
|
||||
snapshots = AccountSnapshot.get_recent(1)
|
||||
if snapshots:
|
||||
if not account_data:
|
||||
account_data = {
|
||||
"total_balance": snapshots[0].get('total_balance', 0),
|
||||
"available_balance": snapshots[0].get('available_balance', 0),
|
||||
"total_position_value": snapshots[0].get('total_position_value', 0),
|
||||
"total_pnl": snapshots[0].get('total_pnl', 0),
|
||||
"open_positions": snapshots[0].get('open_positions', 0)
|
||||
"total_balance": 0,
|
||||
"available_balance": 0,
|
||||
"total_position_value": 0,
|
||||
"total_pnl": 0,
|
||||
"open_positions": 0
|
||||
}
|
||||
logger.info("使用数据库快照作为账户数据")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取账户快照失败: {db_error}")
|
||||
|
||||
# 获取持仓数据(优先实时,回退到数据库)
|
||||
# 获取持仓数据:优先「币安实时持仓」(含本系统下的挂单),失败时回退到数据库列表
|
||||
open_trades = []
|
||||
positions_error = None
|
||||
try:
|
||||
from api.routes.account import get_realtime_positions
|
||||
positions = await get_realtime_positions()
|
||||
# 转换为前端需要的格式
|
||||
open_trades = positions
|
||||
logger.info(f"成功获取实时持仓数据: {len(open_trades)} 个持仓")
|
||||
except HTTPException as e:
|
||||
positions_error = e.detail
|
||||
logger.warning(f"获取实时持仓失败 (HTTP {e.status_code}): {positions_error}")
|
||||
# 回退到数据库记录
|
||||
try:
|
||||
db_trades = Trade.get_all(status='open')[:10]
|
||||
# 格式化数据库记录,添加 entry_value_usdt 字段
|
||||
from api.routes.account import fetch_realtime_positions
|
||||
open_trades = await fetch_realtime_positions(account_id)
|
||||
except Exception as fetch_err:
|
||||
logger.warning(f"获取币安实时持仓失败,回退到数据库列表: {fetch_err}")
|
||||
open_trades = []
|
||||
if not open_trades:
|
||||
db_trades = Trade.get_all(status='open', account_id=account_id, limit=500)
|
||||
for trade in db_trades:
|
||||
entry_value_usdt = float(trade.get('quantity', 0)) * float(trade.get('entry_price', 0))
|
||||
leverage = float(trade.get('leverage', 1))
|
||||
pnl = float(trade.get('pnl', 0))
|
||||
|
||||
# 数据库中的pnl_percent是价格涨跌幅,需要转换为收益率
|
||||
# 收益率 = 盈亏 / 保证金
|
||||
margin = entry_value_usdt / leverage if leverage > 0 else entry_value_usdt
|
||||
pnl_percent = (pnl / margin * 100) if margin > 0 else 0
|
||||
|
||||
formatted_trade = {
|
||||
open_trades.append({
|
||||
**trade,
|
||||
'entry_value_usdt': entry_value_usdt,
|
||||
'mark_price': trade.get('entry_price', 0), # 数据库中没有标记价,使用入场价
|
||||
'mark_price': trade.get('entry_price', 0),
|
||||
'pnl': pnl,
|
||||
'pnl_percent': pnl_percent # 使用重新计算的收益率
|
||||
}
|
||||
open_trades.append(formatted_trade)
|
||||
'pnl_percent': pnl_percent
|
||||
})
|
||||
try:
|
||||
from api.routes.account import fetch_live_positions_pnl
|
||||
live_list = await fetch_live_positions_pnl(account_id)
|
||||
by_symbol = {p["symbol"]: p for p in live_list}
|
||||
for t in open_trades:
|
||||
sym = t.get("symbol")
|
||||
if sym and sym in by_symbol:
|
||||
lp = by_symbol[sym]
|
||||
t["mark_price"] = lp.get("mark_price", t.get("entry_price"))
|
||||
t["pnl"] = lp.get("pnl", 0)
|
||||
t["pnl_percent"] = lp.get("pnl_percent", 0)
|
||||
except Exception as merge_err:
|
||||
logger.debug(f"合并实时持仓盈亏失败: {merge_err}")
|
||||
logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取持仓记录失败: {db_error}")
|
||||
except Exception as e:
|
||||
positions_error = str(e)
|
||||
logger.warning(f"获取实时持仓失败: {positions_error}", exc_info=True)
|
||||
# 回退到数据库记录
|
||||
try:
|
||||
db_trades = Trade.get_all(status='open')[:10]
|
||||
# 格式化数据库记录,添加 entry_value_usdt 字段
|
||||
open_trades = []
|
||||
for trade in db_trades:
|
||||
entry_value_usdt = float(trade.get('quantity', 0)) * float(trade.get('entry_price', 0))
|
||||
leverage = float(trade.get('leverage', 1))
|
||||
pnl = float(trade.get('pnl', 0))
|
||||
|
||||
# 数据库中的pnl_percent是价格涨跌幅,需要转换为收益率
|
||||
# 收益率 = 盈亏 / 保证金
|
||||
margin = entry_value_usdt / leverage if leverage > 0 else entry_value_usdt
|
||||
pnl_percent = (pnl / margin * 100) if margin > 0 else 0
|
||||
|
||||
formatted_trade = {
|
||||
**trade,
|
||||
'entry_value_usdt': entry_value_usdt,
|
||||
'mark_price': trade.get('entry_price', 0), # 数据库中没有标记价,使用入场价
|
||||
'pnl': pnl,
|
||||
'pnl_percent': pnl_percent # 使用重新计算的收益率
|
||||
}
|
||||
open_trades.append(formatted_trade)
|
||||
logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取持仓记录失败: {db_error}")
|
||||
else:
|
||||
logger.info(f"使用币安实时持仓作为列表: {len(open_trades)} 个持仓")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取持仓记录失败: {db_error}")
|
||||
|
||||
# 最近的扫描记录
|
||||
recent_scans = []
|
||||
|
|
@ -176,7 +386,7 @@ async def get_dashboard_data():
|
|||
try:
|
||||
from database.models import TradingConfig
|
||||
total_balance = float(account_data.get('total_balance', 0))
|
||||
max_total_position_percent = float(TradingConfig.get_value('MAX_TOTAL_POSITION_PERCENT', 0.30))
|
||||
max_total_position_percent = float(TradingConfig.get_value('MAX_TOTAL_POSITION_PERCENT', 0.30, account_id=account_id))
|
||||
|
||||
# 名义仓位(notional)与保证金占用(margin)是两个口径:
|
||||
# - 名义仓位可以 > 100%(高杠杆下非常正常)
|
||||
|
|
@ -237,7 +447,7 @@ async def get_dashboard_data():
|
|||
from database.models import TradingConfig
|
||||
config_keys = ['STOP_LOSS_PERCENT', 'TAKE_PROFIT_PERCENT', 'LEVERAGE', 'MAX_POSITION_PERCENT']
|
||||
for key in config_keys:
|
||||
config = TradingConfig.get(key)
|
||||
config = TradingConfig.get(key, account_id=account_id)
|
||||
if config:
|
||||
trading_config[key] = {
|
||||
'value': TradingConfig._convert_value(config['config_value'], config['config_type']),
|
||||
|
|
@ -246,13 +456,21 @@ async def get_dashboard_data():
|
|||
except Exception as e:
|
||||
logger.debug(f"获取交易配置失败: {e}")
|
||||
|
||||
# 本系统持仓数 = 数据库 status=open 条数,与下方「当前持仓」列表一致;币安持仓数 = 接口/快照中的 open_positions,可能与币安页面一致
|
||||
open_trades_count = len(open_trades)
|
||||
result = {
|
||||
"account": account_data,
|
||||
"open_trades": open_trades,
|
||||
"open_trades_count": open_trades_count, # 本系统持仓数,与列表条数一致
|
||||
"recent_scans": recent_scans,
|
||||
"recent_signals": recent_signals,
|
||||
"position_stats": position_stats,
|
||||
"trading_config": trading_config # 添加交易配置
|
||||
"trading_config": trading_config, # 添加交易配置
|
||||
"_debug": { # 添加调试信息
|
||||
"account_id": account_id,
|
||||
"account_data_total_balance": account_data.get('total_balance', 'N/A') if account_data else 'N/A',
|
||||
"open_trades_count": open_trades_count,
|
||||
}
|
||||
}
|
||||
|
||||
# 如果有错误,在响应中包含错误信息(但不影响返回)
|
||||
|
|
@ -263,6 +481,14 @@ async def get_dashboard_data():
|
|||
if positions_error:
|
||||
result["warnings"]["positions"] = positions_error
|
||||
|
||||
logger.info(f"返回仪表板数据:")
|
||||
logger.info(f" - account_id: {account_id}")
|
||||
logger.info(f" - total_balance: {account_data.get('total_balance', 'N/A') if account_data else 'N/A'}")
|
||||
logger.info(f" - available_balance: {account_data.get('available_balance', 'N/A') if account_data else 'N/A'}")
|
||||
logger.info(f" - open_trades count: {len(open_trades)}")
|
||||
if open_trades and len(open_trades) > 0:
|
||||
logger.info(f" - 第一个持仓: {open_trades[0].get('symbol', 'N/A')}")
|
||||
logger.info("=" * 60)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"获取仪表板数据失败: {e}", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import time
|
|||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
|
||||
|
|
@ -15,6 +15,10 @@ logger = logging.getLogger(__name__)
|
|||
# 路由统一挂在 /api/system 下,前端直接调用 /api/system/...
|
||||
router = APIRouter(prefix="/api/system")
|
||||
|
||||
# 管理员鉴权(JWT;未启用登录时兼容 X-Admin-Token)
|
||||
from api.auth_deps import require_system_admin # noqa: E402
|
||||
from database.models import Account # noqa: E402
|
||||
|
||||
LOG_GROUPS = ("error", "warning", "info")
|
||||
|
||||
# 后端服务启动时间(用于前端展示“运行多久/是否已重启”)
|
||||
|
|
@ -175,13 +179,11 @@ def _beijing_time_str() -> str:
|
|||
|
||||
@router.post("/logs/test-write")
|
||||
async def logs_test_write(
|
||||
x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
写入 3 条测试日志到 Redis(error/warning/info),用于验证“是否写入到同一台 Redis、同一组 key”。
|
||||
"""
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
|
||||
client = _get_redis_client_for_logs()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="Redis 不可用,无法写入测试日志")
|
||||
|
|
@ -238,6 +240,35 @@ async def logs_test_write(
|
|||
}
|
||||
|
||||
|
||||
@router.post("/trading/trigger-scan")
|
||||
async def trigger_scan(
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
触发手动扫描:
|
||||
通过设置 Redis 信号(ats:trigger-scan),通知所有运行中的 strategy 进程立即执行扫描。
|
||||
"""
|
||||
client = _get_redis_client_for_logs()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="Redis 不可用,无法触发扫描")
|
||||
|
||||
try:
|
||||
# 设置触发信号(当前时间戳),让 strategy 检测到变化
|
||||
import time
|
||||
ts = int(time.time())
|
||||
# 使用 setex 设置 600秒过期,防止永久残留(虽然 strategy 只关心变化,但过期是个好习惯)
|
||||
# 注意:strategy 端比较的是时间戳大小,所以只要比上一次大即可
|
||||
client.setex("ats:trigger-scan", 600, str(ts))
|
||||
|
||||
return {
|
||||
"message": "已发送扫描触发信号",
|
||||
"timestamp": ts
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"触发扫描失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"触发扫描失败: {e}")
|
||||
|
||||
|
||||
def _get_redis_client_for_logs():
|
||||
"""
|
||||
获取 Redis 客户端(优先复用 config_manager 的连接;失败则自行创建)。
|
||||
|
|
@ -311,7 +342,7 @@ async def get_logs(
|
|||
start: int = 0,
|
||||
service: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
从 Redis List 读取最新日志(默认 group=error -> ats:logs:error)。
|
||||
|
|
@ -322,8 +353,6 @@ async def get_logs(
|
|||
- service: 过滤(backend / trading_system)
|
||||
- level: 过滤(ERROR / CRITICAL ...)
|
||||
"""
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
|
||||
if limit <= 0:
|
||||
limit = 200
|
||||
if limit > 20000:
|
||||
|
|
@ -332,6 +361,12 @@ async def get_logs(
|
|||
if start < 0:
|
||||
start = 0
|
||||
|
||||
# 定义管理员不需要关注的日志模式(噪声过滤)
|
||||
IGNORED_PATTERNS = [
|
||||
"API密钥未配置",
|
||||
"请在配置界面设置该账号的BINANCE_API_KEY",
|
||||
]
|
||||
|
||||
group = (group or "error").strip().lower()
|
||||
if group not in LOG_GROUPS:
|
||||
raise HTTPException(status_code=400, detail=f"非法 group:{group}(可选:{', '.join(LOG_GROUPS)})")
|
||||
|
|
@ -388,6 +423,12 @@ async def get_logs(
|
|||
continue
|
||||
if level and str(parsed.get("level")) != level:
|
||||
continue
|
||||
|
||||
# 噪声过滤
|
||||
msg = str(parsed.get("message", ""))
|
||||
if any(p in msg for p in IGNORED_PATTERNS):
|
||||
continue
|
||||
|
||||
items.append(parsed)
|
||||
if len(items) >= limit:
|
||||
break
|
||||
|
|
@ -414,8 +455,7 @@ async def get_logs(
|
|||
|
||||
|
||||
@router.get("/logs/overview")
|
||||
async def logs_overview(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
async def logs_overview(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
|
||||
client = _get_redis_client_for_logs()
|
||||
if client is None:
|
||||
|
|
@ -472,10 +512,8 @@ async def logs_overview(x_admin_token: Optional[str] = Header(default=None, alia
|
|||
@router.put("/logs/config")
|
||||
async def update_logs_config(
|
||||
payload: LogsConfigUpdate,
|
||||
x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
) -> Dict[str, Any]:
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
|
||||
client = _get_redis_client_for_logs()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="Redis 不可用,无法更新日志配置")
|
||||
|
|
@ -525,6 +563,10 @@ def _require_admin(token: Optional[str], provided: Optional[str]) -> None:
|
|||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
#
|
||||
# 注意:require_system_admin 已迁移到 api.auth_deps,避免导入不一致导致 uvicorn 启动失败
|
||||
|
||||
|
||||
def _build_supervisorctl_cmd(args: list[str]) -> list[str]:
|
||||
supervisorctl_path = os.getenv("SUPERVISORCTL_PATH", "supervisorctl")
|
||||
supervisor_conf = os.getenv("SUPERVISOR_CONF", "").strip()
|
||||
|
|
@ -567,7 +609,12 @@ def _run_supervisorctl(args: list[str]) -> str:
|
|||
out = (res.stdout or "").strip()
|
||||
err = (res.stderr or "").strip()
|
||||
combined = "\n".join([s for s in [out, err] if s]).strip()
|
||||
if res.returncode != 0:
|
||||
# supervisorctl 约定:
|
||||
# - status 在存在 STOPPED/FATAL 等进程时可能返回 exit=3,但输出仍然有效
|
||||
ok_rc = {0}
|
||||
if args and args[0] == "status":
|
||||
ok_rc.add(3)
|
||||
if res.returncode not in ok_rc:
|
||||
raise RuntimeError(combined or f"supervisorctl failed (exit={res.returncode})")
|
||||
return combined or out
|
||||
|
||||
|
|
@ -587,6 +634,20 @@ def _parse_supervisor_status(raw: str) -> Tuple[bool, Optional[int], str]:
|
|||
return False, None, state
|
||||
return False, None, "UNKNOWN"
|
||||
|
||||
def _list_supervisor_process_names(status_all_raw: str) -> list[str]:
|
||||
names: list[str] = []
|
||||
if not status_all_raw:
|
||||
return names
|
||||
for ln in status_all_raw.splitlines():
|
||||
s = (ln or "").strip()
|
||||
if not s:
|
||||
continue
|
||||
# 每行格式:<name> <STATE> ...
|
||||
name = s.split(None, 1)[0].strip()
|
||||
if name:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _get_program_name() -> str:
|
||||
# 你给的宝塔配置是 [program:auto_sys]
|
||||
|
|
@ -676,17 +737,68 @@ def _action_with_fallback(action: str, program: str) -> Tuple[str, Optional[str]
|
|||
return out, resolved, status_all
|
||||
|
||||
|
||||
|
||||
def _run_fix_script():
|
||||
"""Run the fix_trade_records.py script in a subprocess"""
|
||||
try:
|
||||
script_path = Path(__file__).parent.parent.parent.parent / "scripts" / "fix_trade_records.py"
|
||||
if not script_path.exists():
|
||||
logger.error(f"Fix script not found at {script_path}")
|
||||
return
|
||||
|
||||
logger.info(f"Starting trade record fix script: {script_path}")
|
||||
|
||||
# Ensure project root is in PYTHONPATH
|
||||
env = os.environ.copy()
|
||||
project_root = Path(__file__).parent.parent.parent.parent
|
||||
env["PYTHONPATH"] = f"{env.get('PYTHONPATH', '')}:{project_root}"
|
||||
|
||||
process = subprocess.Popen(
|
||||
["python3", str(script_path)],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
logger.info(f"Trade record fix completed successfully:\n{stdout}")
|
||||
else:
|
||||
logger.error(f"Trade record fix failed (exit code {process.returncode}):\n{stderr}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running trade record fix script: {e}")
|
||||
|
||||
@router.post("/fix-trade-records")
|
||||
async def fix_trade_records(
|
||||
background_tasks: BackgroundTasks,
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin)
|
||||
):
|
||||
"""
|
||||
Trigger the trade record fix script (time inversion & commission backfill).
|
||||
Runs in background.
|
||||
"""
|
||||
background_tasks.add_task(_run_fix_script)
|
||||
return {"message": "Trade fix task started in background"}
|
||||
|
||||
@router.post("/clear-cache")
|
||||
async def clear_cache(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
async def clear_cache(
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
x_account_id: Optional[int] = Header(default=None, alias="X-Account-Id"),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
清理配置缓存(Redis Hash: trading_config),并从数据库回灌到 Redis。
|
||||
"""
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
|
||||
try:
|
||||
import config_manager
|
||||
|
||||
cm = getattr(config_manager, "config_manager", None)
|
||||
account_id = int(x_account_id or 1)
|
||||
cm = None
|
||||
if hasattr(config_manager, "ConfigManager") and hasattr(config_manager.ConfigManager, "for_account"):
|
||||
cm = config_manager.ConfigManager.for_account(account_id)
|
||||
else:
|
||||
cm = getattr(config_manager, "config_manager", None)
|
||||
if cm is None:
|
||||
raise HTTPException(status_code=500, detail="config_manager 未初始化")
|
||||
|
||||
|
|
@ -710,10 +822,16 @@ async def clear_cache(x_admin_token: Optional[str] = Header(default=None, alias=
|
|||
|
||||
if redis_client is not None and redis_connected:
|
||||
try:
|
||||
redis_client.delete("trading_config")
|
||||
deleted_keys.append("trading_config")
|
||||
key = getattr(cm, "_redis_hash_key", "trading_config")
|
||||
redis_client.delete(key)
|
||||
deleted_keys.append(str(key))
|
||||
# 兼容:老 key(仅 default 账号)
|
||||
legacy = getattr(cm, "_legacy_hash_key", None)
|
||||
if legacy and legacy != key:
|
||||
redis_client.delete(legacy)
|
||||
deleted_keys.append(str(legacy))
|
||||
except Exception as e:
|
||||
logger.warning(f"删除 Redis key trading_config 失败: {e}")
|
||||
logger.warning(f"删除 Redis key 失败: {e}")
|
||||
|
||||
# 可选:实时推荐缓存(如果存在)
|
||||
try:
|
||||
|
|
@ -742,9 +860,90 @@ async def clear_cache(x_admin_token: Optional[str] = Header(default=None, alias=
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/trading/services")
|
||||
async def list_trading_services(_admin: Dict[str, Any] = Depends(require_system_admin)):
|
||||
"""获取所有交易服务状态(包括所有账号)"""
|
||||
try:
|
||||
# 获取所有 supervisor 进程状态
|
||||
status_all = _run_supervisorctl(["status"])
|
||||
|
||||
services = []
|
||||
summary = {"total": 0, "running": 0, "stopped": 0, "unknown": 0}
|
||||
|
||||
# 解析每一行
|
||||
# 格式通常是: name state description
|
||||
for line in status_all.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
name = parts[0]
|
||||
state = parts[1]
|
||||
desc = parts[2] if len(parts) > 2 else ""
|
||||
|
||||
# 只关注 auto_sys 开头的服务
|
||||
if name.startswith("auto_sys"):
|
||||
is_running = state == "RUNNING"
|
||||
pid = None
|
||||
if is_running:
|
||||
# Parse PID from desc: "pid 1234, uptime ..."
|
||||
m = re.search(r"pid\s+(\d+)", desc)
|
||||
if m:
|
||||
pid = int(m.group(1))
|
||||
|
||||
services.append({
|
||||
"program": name,
|
||||
"state": state,
|
||||
"running": is_running,
|
||||
"pid": pid,
|
||||
"description": desc
|
||||
})
|
||||
|
||||
summary["total"] += 1
|
||||
if is_running:
|
||||
summary["running"] += 1
|
||||
elif state in ["STOPPED", "EXITED", "FATAL"]:
|
||||
summary["stopped"] += 1
|
||||
else:
|
||||
summary["unknown"] += 1
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"services": services,
|
||||
"raw": status_all
|
||||
}
|
||||
except Exception as e:
|
||||
# supervisor 未安装/未运行时(如 unix socket 不存在)避免刷 ERROR,改为 WARNING 并返回友好说明
|
||||
err_msg = str(e).strip()
|
||||
if not err_msg:
|
||||
err_msg = repr(e)
|
||||
is_supervisor_unavailable = (
|
||||
"no such file" in err_msg.lower()
|
||||
or "connection refused" in err_msg.lower()
|
||||
or "sock" in err_msg.lower()
|
||||
or "unix://" in err_msg.lower()
|
||||
)
|
||||
if is_supervisor_unavailable:
|
||||
logger.warning(f"列出服务失败(supervisor 未运行或不可用): {err_msg}")
|
||||
return {
|
||||
"summary": {"total": 0, "running": 0, "stopped": 0, "unknown": 0},
|
||||
"services": [],
|
||||
"error": "supervisor 未安装或未运行,请检查 supervisord 或配置 SUPERVISOR_CONF"
|
||||
}
|
||||
logger.error(f"列出服务失败: {e}")
|
||||
return {
|
||||
"summary": {"total": 0, "running": 0, "stopped": 0, "unknown": 0},
|
||||
"services": [],
|
||||
"error": err_msg
|
||||
}
|
||||
|
||||
|
||||
@router.get("/trading/status")
|
||||
async def trading_status(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
async def trading_status(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
|
||||
program = _get_program_name()
|
||||
try:
|
||||
|
|
@ -770,8 +969,7 @@ async def trading_status(x_admin_token: Optional[str] = Header(default=None, ali
|
|||
|
||||
|
||||
@router.post("/trading/start")
|
||||
async def trading_start(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
async def trading_start(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
|
||||
program = _get_program_name()
|
||||
try:
|
||||
|
|
@ -797,8 +995,7 @@ async def trading_start(x_admin_token: Optional[str] = Header(default=None, alia
|
|||
|
||||
|
||||
@router.post("/trading/stop")
|
||||
async def trading_stop(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
async def trading_stop(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
|
||||
program = _get_program_name()
|
||||
try:
|
||||
|
|
@ -824,8 +1021,7 @@ async def trading_stop(x_admin_token: Optional[str] = Header(default=None, alias
|
|||
|
||||
|
||||
@router.post("/trading/restart")
|
||||
async def trading_restart(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
async def trading_restart(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
|
||||
program = _get_program_name()
|
||||
try:
|
||||
|
|
@ -867,8 +1063,274 @@ async def trading_restart(x_admin_token: Optional[str] = Header(default=None, al
|
|||
raise HTTPException(status_code=500, detail=f"supervisorctl restart 失败: {e}")
|
||||
|
||||
|
||||
@router.post("/trading/stop-all")
|
||||
async def trading_stop_all(
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
prefix: str = "auto_sys_acc",
|
||||
include_default: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
一键停止所有账号交易进程(supervisor)。
|
||||
"""
|
||||
try:
|
||||
prefix = (prefix or "auto_sys_acc").strip()
|
||||
if not prefix:
|
||||
prefix = "auto_sys_acc"
|
||||
|
||||
# 先读取全量 status,拿到有哪些进程
|
||||
status_all = _run_supervisorctl(["status"])
|
||||
names = _list_supervisor_process_names(status_all)
|
||||
|
||||
targets: list[str] = []
|
||||
for n in names:
|
||||
if n.startswith(prefix):
|
||||
targets.append(n)
|
||||
|
||||
if include_default:
|
||||
default_prog = _get_program_name()
|
||||
if default_prog and default_prog not in targets and default_prog in names:
|
||||
targets.append(default_prog)
|
||||
|
||||
if not targets:
|
||||
return {
|
||||
"message": "未找到可停止的交易进程",
|
||||
"prefix": prefix,
|
||||
"include_default": include_default,
|
||||
"count": 0,
|
||||
"targets": [],
|
||||
"status_all": status_all,
|
||||
}
|
||||
|
||||
results: list[Dict[str, Any]] = []
|
||||
ok = 0
|
||||
failed = 0
|
||||
for prog in targets:
|
||||
try:
|
||||
out = _run_supervisorctl(["stop", prog])
|
||||
raw = _run_supervisorctl(["status", prog])
|
||||
running, pid, state = _parse_supervisor_status(raw)
|
||||
results.append(
|
||||
{
|
||||
"program": prog,
|
||||
"ok": True,
|
||||
"output": out,
|
||||
"status": {"running": running, "pid": pid, "state": state, "raw": raw},
|
||||
}
|
||||
)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
results.append({"program": prog, "ok": False, "error": str(e)})
|
||||
|
||||
return {
|
||||
"message": "已发起批量停止",
|
||||
"prefix": prefix,
|
||||
"include_default": include_default,
|
||||
"count": len(targets),
|
||||
"ok": ok,
|
||||
"failed": failed,
|
||||
"targets": targets,
|
||||
"results": results,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"批量停止失败: {e}")
|
||||
|
||||
|
||||
@router.post("/trading/restart-all")
|
||||
async def trading_restart_all(
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
prefix: str = "auto_sys_acc",
|
||||
include_default: bool = False,
|
||||
do_update: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
一键重启所有账号交易进程(supervisor)。
|
||||
|
||||
- 默认重启所有以 auto_sys_acc 开头的 program(例如 auto_sys_acc1/2/3...)
|
||||
- 可选 include_default=true:同时包含 SUPERVISOR_TRADING_PROGRAM(默认 auto_sys)
|
||||
- 可选 do_update=true:先执行 supervisorctl reread/update 再重启(确保新 ini 生效)
|
||||
"""
|
||||
try:
|
||||
prefix = (prefix or "auto_sys_acc").strip()
|
||||
if not prefix:
|
||||
prefix = "auto_sys_acc"
|
||||
|
||||
# 先读取全量 status,拿到有哪些进程
|
||||
status_all = _run_supervisorctl(["status"])
|
||||
names = _list_supervisor_process_names(status_all)
|
||||
|
||||
targets: list[str] = []
|
||||
skipped_disabled: list[Dict[str, Any]] = []
|
||||
for n in names:
|
||||
if n.startswith(prefix):
|
||||
# 若能解析出 account_id,则跳过 disabled 的账号
|
||||
try:
|
||||
m = re.match(rf"^{re.escape(prefix)}(\d+)$", n)
|
||||
if m:
|
||||
aid = int(m.group(1))
|
||||
row = Account.get(aid)
|
||||
st = (row.get("status") if isinstance(row, dict) else None) or "active"
|
||||
if str(st).strip().lower() != "active":
|
||||
skipped_disabled.append({"program": n, "account_id": aid, "status": st})
|
||||
continue
|
||||
except Exception:
|
||||
# 解析失败/查库失败:不影响批量重启流程
|
||||
pass
|
||||
targets.append(n)
|
||||
|
||||
if include_default:
|
||||
default_prog = _get_program_name()
|
||||
if default_prog and default_prog not in targets and default_prog in names:
|
||||
targets.append(default_prog)
|
||||
|
||||
if not targets:
|
||||
return {
|
||||
"message": "未找到可重启的交易进程",
|
||||
"prefix": prefix,
|
||||
"include_default": include_default,
|
||||
"count": 0,
|
||||
"targets": [],
|
||||
"status_all": status_all,
|
||||
"skipped_disabled": skipped_disabled,
|
||||
}
|
||||
|
||||
reread_out = ""
|
||||
update_out = ""
|
||||
if do_update:
|
||||
try:
|
||||
reread_out = _run_supervisorctl(["reread"])
|
||||
except Exception as e:
|
||||
reread_out = f"failed: {e}"
|
||||
try:
|
||||
update_out = _run_supervisorctl(["update"])
|
||||
except Exception as e:
|
||||
update_out = f"failed: {e}"
|
||||
|
||||
results: list[Dict[str, Any]] = []
|
||||
ok = 0
|
||||
failed = 0
|
||||
for prog in targets:
|
||||
try:
|
||||
out = _run_supervisorctl(["restart", prog])
|
||||
raw = _run_supervisorctl(["status", prog])
|
||||
running, pid, state = _parse_supervisor_status(raw)
|
||||
results.append(
|
||||
{
|
||||
"program": prog,
|
||||
"ok": True,
|
||||
"output": out,
|
||||
"status": {"running": running, "pid": pid, "state": state, "raw": raw},
|
||||
}
|
||||
)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
results.append({"program": prog, "ok": False, "error": str(e)})
|
||||
|
||||
return {
|
||||
"message": "已发起批量重启",
|
||||
"prefix": prefix,
|
||||
"include_default": include_default,
|
||||
"do_update": do_update,
|
||||
"count": len(targets),
|
||||
"ok": ok,
|
||||
"failed": failed,
|
||||
"reread": reread_out,
|
||||
"update": update_out,
|
||||
"targets": targets,
|
||||
"results": results,
|
||||
"skipped_disabled": skipped_disabled,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"批量重启失败: {e}")
|
||||
|
||||
|
||||
@router.get("/market-overview")
|
||||
async def market_overview(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""
|
||||
市场行情概览:拉取 Binance 公开接口,展示与策略过滤对应的数据。
|
||||
供全局配置页展示,帮助用户确认当前策略方案是否匹配市场。
|
||||
"""
|
||||
try:
|
||||
from market_overview import get_market_overview
|
||||
except ImportError:
|
||||
try:
|
||||
from backend.market_overview import get_market_overview
|
||||
except ImportError:
|
||||
import sys
|
||||
backend_dir = Path(__file__).parent.parent.parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
from market_overview import get_market_overview
|
||||
|
||||
data = get_market_overview()
|
||||
|
||||
# 获取当前策略配置,用于对比
|
||||
beta_enabled = False
|
||||
beta_threshold = -0.005
|
||||
market_scheme = "normal"
|
||||
try:
|
||||
from config_manager import GlobalStrategyConfigManager
|
||||
mgr = GlobalStrategyConfigManager()
|
||||
beta_enabled = str(mgr.get("BETA_FILTER_ENABLED", "true")).lower() in ("true", "1", "yes")
|
||||
try:
|
||||
beta_threshold = float(mgr.get("BETA_FILTER_THRESHOLD", -0.005))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
market_scheme = str(mgr.get("MARKET_SCHEME", "normal")).strip().lower() or "normal"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 计算大盘共振是否触发(与 strategy._check_beta_filter 一致)
|
||||
threshold_pct = beta_threshold * 100
|
||||
triggered = False
|
||||
if beta_enabled:
|
||||
for key in ["btc_15m_change_pct", "btc_1h_change_pct", "eth_15m_change_pct", "eth_1h_change_pct"]:
|
||||
val = data.get(key)
|
||||
if val is not None and val < threshold_pct:
|
||||
triggered = True
|
||||
break
|
||||
|
||||
data["config"] = {
|
||||
"BETA_FILTER_ENABLED": beta_enabled,
|
||||
"BETA_FILTER_THRESHOLD": beta_threshold,
|
||||
"BETA_FILTER_THRESHOLD_PCT": round(threshold_pct, 2),
|
||||
"MARKET_SCHEME": market_scheme,
|
||||
}
|
||||
data["beta_filter_triggered"] = triggered
|
||||
|
||||
# 策略执行概览:当前执行方案与配置项执行情况(易读文字)
|
||||
get_strategy_execution_overview = None
|
||||
try:
|
||||
from market_overview import get_strategy_execution_overview
|
||||
except ImportError:
|
||||
try:
|
||||
from backend.market_overview import get_strategy_execution_overview
|
||||
except ImportError:
|
||||
pass
|
||||
if get_strategy_execution_overview is None:
|
||||
try:
|
||||
import sys
|
||||
backend_dir = Path(__file__).resolve().parent.parent.parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
from market_overview import get_strategy_execution_overview
|
||||
except ImportError:
|
||||
pass
|
||||
if get_strategy_execution_overview is not None:
|
||||
try:
|
||||
data["strategy_execution_overview"] = get_strategy_execution_overview()
|
||||
except Exception as e:
|
||||
data["strategy_execution_overview"] = {"sections": [{"title": "加载失败", "content": str(e)}]}
|
||||
else:
|
||||
data["strategy_execution_overview"] = {
|
||||
"sections": [{"title": "策略执行概览暂不可用", "content": "请确认后端已重启并已部署最新代码;若已重启仍无数据,请检查 backend/market_overview.py 与 config_manager 是否可正常导入。"}]
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/backend/status")
|
||||
async def backend_status(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
async def backend_status(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""
|
||||
查看后端服务状态(当前 uvicorn 进程)。
|
||||
|
||||
|
|
@ -876,7 +1338,6 @@ async def backend_status(x_admin_token: Optional[str] = Header(default=None, ali
|
|||
- pid 使用 os.getpid()(当前 FastAPI 进程)
|
||||
- last_restart 从 Redis 读取(若可用)
|
||||
"""
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
meta = _system_meta_read("backend:last_restart") or {}
|
||||
return {
|
||||
"running": True,
|
||||
|
|
@ -888,7 +1349,7 @@ async def backend_status(x_admin_token: Optional[str] = Header(default=None, ali
|
|||
|
||||
|
||||
@router.post("/backend/restart")
|
||||
async def backend_restart(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
async def backend_restart(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""
|
||||
重启后端服务(uvicorn)。
|
||||
|
||||
|
|
@ -901,8 +1362,6 @@ async def backend_restart(x_admin_token: Optional[str] = Header(default=None, al
|
|||
注意:
|
||||
- 为了让接口能先返回,这里会延迟 1s 再执行 restart.sh
|
||||
"""
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
|
||||
backend_dir = Path(__file__).parent.parent.parent # backend/
|
||||
restart_script = backend_dir / "restart.sh"
|
||||
if not restart_script.exists():
|
||||
|
|
@ -944,3 +1403,159 @@ async def backend_restart(x_admin_token: Optional[str] = Header(default=None, al
|
|||
"note": "重启期间接口可能短暂不可用,页面可等待 3-5 秒后刷新状态。",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/backend/stop")
|
||||
async def backend_stop(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""
|
||||
停止后端服务(uvicorn)。
|
||||
警告:停止后 API 将不可用,必须手动登录服务器启动!
|
||||
"""
|
||||
backend_dir = Path(__file__).parent.parent.parent # backend/
|
||||
stop_script = backend_dir / "stop.sh"
|
||||
if not stop_script.exists():
|
||||
raise HTTPException(status_code=500, detail=f"找不到停止脚本: {stop_script}")
|
||||
|
||||
cur_pid = os.getpid()
|
||||
|
||||
# 后台执行:sleep 1 后再停止,保证当前请求可以返回
|
||||
cmd = ["bash", "-lc", f"sleep 1; '{stop_script}'"]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(backend_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"启动停止脚本失败: {e}")
|
||||
|
||||
return {
|
||||
"message": "已发起后端停止(1s 后执行)",
|
||||
"pid_before": cur_pid,
|
||||
"script": str(stop_script),
|
||||
"warning": "后端服务停止后,Web 界面将无法访问,请手动在服务器启动!",
|
||||
}
|
||||
|
||||
|
||||
def _recommendations_process_running() -> Tuple[bool, Optional[int]]:
|
||||
"""检查推荐服务进程是否运行,返回 (running, pid)。兼容 pgrep/ps 及 supervisor 等启动方式。"""
|
||||
# 1. 优先 pgrep(Linux/macOS 常见)
|
||||
for pattern in ["trading_system.recommendations_main", "recommendations_main1", "recommendations_main"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", pattern],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
pids = [x for x in result.stdout.strip().split() if x.isdigit()]
|
||||
if pids:
|
||||
return True, int(pids[0])
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
|
||||
break
|
||||
|
||||
# 2. 回退:ps + grep(pgrep 不可用或匹配失败时)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sh", "-c", "ps aux 2>/dev/null | grep -E 'recommendations_main1|recommendations_main|trading_system.recommendations' | grep -v grep | head -1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
parts = result.stdout.strip().split()
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
return True, int(parts[1])
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
|
||||
pass
|
||||
return False, None
|
||||
|
||||
|
||||
@router.get("/recommendations/status")
|
||||
async def recommendations_status(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""查看推荐服务状态(recommendations_main 进程)"""
|
||||
running, pid = _recommendations_process_running()
|
||||
return {
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recommendations/restart")
|
||||
async def recommendations_restart(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""重启推荐服务(recommendations_main)"""
|
||||
backend_dir = Path(__file__).parent.parent.parent
|
||||
restart_script = backend_dir / "restart_recommendations.sh"
|
||||
if not restart_script.exists():
|
||||
raise HTTPException(status_code=500, detail=f"找不到重启脚本: {restart_script}")
|
||||
running_before, pid_before = _recommendations_process_running()
|
||||
cmd = ["bash", "-lc", f"sleep 1; '{restart_script}'"]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(backend_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"启动重启脚本失败: {e}")
|
||||
return {
|
||||
"message": "已发起推荐服务重启(1s 后执行)",
|
||||
"pid_before": pid_before,
|
||||
"running_before": running_before,
|
||||
"script": str(restart_script),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recommendations/stop")
|
||||
async def recommendations_stop(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""停止推荐服务"""
|
||||
backend_dir = Path(__file__).parent.parent.parent
|
||||
stop_script = backend_dir / "stop_recommendations.sh"
|
||||
if not stop_script.exists():
|
||||
raise HTTPException(status_code=500, detail=f"找不到停止脚本: {stop_script}")
|
||||
running_before, pid_before = _recommendations_process_running()
|
||||
cmd = ["bash", "-lc", f"sleep 1; '{stop_script}'"]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(backend_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"启动停止脚本失败: {e}")
|
||||
return {
|
||||
"message": "已发起推荐服务停止",
|
||||
"pid_before": pid_before,
|
||||
"running_before": running_before,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recommendations/start")
|
||||
async def recommendations_start(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""启动推荐服务(若已运行则跳过)"""
|
||||
running, pid = _recommendations_process_running()
|
||||
if running:
|
||||
return {"message": "推荐服务已在运行中", "pid": pid, "skipped": True}
|
||||
backend_dir = Path(__file__).parent.parent.parent
|
||||
start_script = backend_dir / "start_recommendations.sh"
|
||||
if not start_script.exists():
|
||||
raise HTTPException(status_code=500, detail=f"找不到启动脚本: {start_script}")
|
||||
cmd = ["bash", "-lc", f"sleep 1; '{start_script}'"]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(backend_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"启动脚本执行失败: {e}")
|
||||
return {"message": "已发起推荐服务启动(1s 后执行)", "script": str(start_script)}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
568
backend/api/supervisor_account.py
Normal file
568
backend/api/supervisor_account.py
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
"""
|
||||
Supervisor 多账号托管(宝塔插件兼容)
|
||||
|
||||
目标:
|
||||
- 根据 account_id 自动生成一个 supervisor program 配置文件(.ini)
|
||||
- 自动定位 supervisord.conf 的 include 目录(尽量不要求你手填路径)
|
||||
- 提供 supervisorctl 的常用调用封装(reread/update/status/start/stop/restart)
|
||||
|
||||
重要说明:
|
||||
- 本模块只写入“程序配置文件”,不包含任何 API Key/Secret
|
||||
- trading_system 进程通过 ATS_ACCOUNT_ID 选择自己的账号配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
|
||||
|
||||
DEFAULT_CANDIDATE_CONFS = [
|
||||
"/www/server/panel/plugin/supervisor/supervisord.conf",
|
||||
"/www/server/panel/plugin/supervisor/supervisor.conf",
|
||||
"/etc/supervisor/supervisord.conf",
|
||||
"/etc/supervisord.conf",
|
||||
]
|
||||
|
||||
# 常见 supervisorctl 路径候选
|
||||
DEFAULT_SUPERVISORCTL_CANDIDATES = [
|
||||
"/www/server/panel/pyenv/bin/supervisorctl",
|
||||
"/usr/bin/supervisorctl",
|
||||
"/usr/local/bin/supervisorctl",
|
||||
"/usr/local/python/bin/supervisorctl",
|
||||
]
|
||||
|
||||
def _detect_supervisorctl_path() -> str:
|
||||
"""
|
||||
探测 supervisorctl 可执行文件路径
|
||||
"""
|
||||
env_path = (os.getenv("SUPERVISORCTL_PATH") or "").strip()
|
||||
if env_path:
|
||||
return env_path
|
||||
|
||||
# 优先检查 PATH 中的 supervisorctl
|
||||
import shutil
|
||||
if shutil.which("supervisorctl"):
|
||||
return "supervisorctl"
|
||||
|
||||
# 检查常见绝对路径
|
||||
for p in DEFAULT_SUPERVISORCTL_CANDIDATES:
|
||||
try:
|
||||
if os.path.exists(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return "supervisorctl" # 兜底
|
||||
|
||||
|
||||
# 常见 supervisord 主日志路径候选(不同发行版/面板插件差异很大)
|
||||
DEFAULT_SUPERVISORD_LOG_CANDIDATES = [
|
||||
# aaPanel / 宝塔 supervisor 插件常见
|
||||
"/www/server/panel/plugin/supervisor/log/supervisord.log",
|
||||
"/www/server/panel/plugin/supervisor/log/supervisor.log",
|
||||
"/www/server/panel/plugin/supervisor/supervisord.log",
|
||||
"/www/server/panel/plugin/supervisor/supervisor.log",
|
||||
# 系统 supervisor 常见
|
||||
"/var/log/supervisor/supervisord.log",
|
||||
"/var/log/supervisor/supervisor.log",
|
||||
"/var/log/supervisord.log",
|
||||
"/var/log/supervisord/supervisord.log",
|
||||
"/tmp/supervisord.log",
|
||||
]
|
||||
|
||||
|
||||
def _get_project_root() -> Path:
|
||||
# backend/api/supervisor_account.py -> api -> backend -> project_root
|
||||
# 期望得到:<project_root>(例如 /www/wwwroot/autosys_new)
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _detect_supervisor_conf_path() -> Optional[Path]:
|
||||
p = (os.getenv("SUPERVISOR_CONF") or "").strip()
|
||||
if p:
|
||||
pp = Path(p)
|
||||
return pp if pp.exists() else pp # 允许不存在时也返回,便于报错信息
|
||||
for cand in DEFAULT_CANDIDATE_CONFS:
|
||||
try:
|
||||
cp = Path(cand)
|
||||
if cp.exists():
|
||||
return cp
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_include_dir_from_conf(conf_path: Path) -> Optional[Path]:
|
||||
"""
|
||||
尝试解析 supervisord.conf 的 [include] files=... 目录。
|
||||
常见格式:
|
||||
[include]
|
||||
files = /path/to/conf.d/*.ini
|
||||
"""
|
||||
try:
|
||||
text = conf_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
in_include = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith(";") or line.startswith("#"):
|
||||
continue
|
||||
if re.match(r"^\[include\]\s*$", line, flags=re.I):
|
||||
in_include = True
|
||||
continue
|
||||
if in_include and line.startswith("[") and line.endswith("]"):
|
||||
break
|
||||
if not in_include:
|
||||
continue
|
||||
m = re.match(r"^files\s*=\s*(.+)$", line, flags=re.I)
|
||||
if not m:
|
||||
continue
|
||||
val = (m.group(1) or "").strip().strip('"').strip("'")
|
||||
if not val:
|
||||
continue
|
||||
# 只取第一个 pattern(即使写了多个用空格分隔)
|
||||
first = val.split()[0]
|
||||
p = Path(first)
|
||||
if not p.is_absolute():
|
||||
p = (conf_path.parent / p).resolve()
|
||||
return p.parent
|
||||
return None
|
||||
|
||||
|
||||
def get_supervisor_program_dir() -> Path:
|
||||
"""
|
||||
获取 supervisor program 配置目录(优先级):
|
||||
1) SUPERVISOR_PROGRAM_DIR
|
||||
2) 从 supervisord.conf 的 [include] files= 解析
|
||||
3) 兜底:/www/server/panel/plugin/supervisor(你当前看到的目录)
|
||||
"""
|
||||
env_dir = (os.getenv("SUPERVISOR_PROGRAM_DIR") or "").strip()
|
||||
if env_dir:
|
||||
return Path(env_dir)
|
||||
|
||||
conf = _detect_supervisor_conf_path()
|
||||
if conf and conf.exists():
|
||||
inc = _parse_include_dir_from_conf(conf)
|
||||
if inc:
|
||||
return inc
|
||||
|
||||
return Path("/www/server/panel/plugin/supervisor")
|
||||
|
||||
|
||||
def program_name_for_account(account_id: int) -> str:
|
||||
tmpl = (os.getenv("SUPERVISOR_TRADING_PROGRAM_TEMPLATE") or "auto_sys_acc{account_id}").strip()
|
||||
try:
|
||||
return tmpl.format(account_id=int(account_id))
|
||||
except Exception:
|
||||
return f"auto_sys_acc{int(account_id)}"
|
||||
|
||||
|
||||
def ini_filename_for_program(program_name: str) -> str:
|
||||
safe = re.sub(r"[^a-zA-Z0-9_\-:.]+", "_", program_name).strip("_") or "auto_sys"
|
||||
return f"{safe}.ini"
|
||||
|
||||
|
||||
def render_program_ini(account_id: int, program_name: str) -> str:
|
||||
project_root = _get_project_root()
|
||||
# Python 可执行文件:
|
||||
# - 优先使用 TRADING_PYTHON_BIN(线上可显式指定 trading_system 的 venv)
|
||||
# - 否则尝试多种候选路径(避免 backend venv 未安装交易依赖导致启动失败)
|
||||
python_bin_env = (os.getenv("TRADING_PYTHON_BIN") or "").strip()
|
||||
candidates = []
|
||||
if python_bin_env:
|
||||
candidates.append(python_bin_env)
|
||||
# 当前进程 python(backend venv)
|
||||
candidates.append(sys.executable)
|
||||
# 常见 venv 位置
|
||||
candidates += [
|
||||
str(project_root / "backend" / ".venv" / "bin" / "python"),
|
||||
str(project_root / ".venv" / "bin" / "python"),
|
||||
str(project_root / "trading_system" / ".venv" / "bin" / "python"),
|
||||
"/usr/bin/python3",
|
||||
"/usr/local/bin/python3",
|
||||
]
|
||||
python_bin = None
|
||||
for c in candidates:
|
||||
try:
|
||||
p = Path(c)
|
||||
if p.exists() and os.access(str(p), os.X_OK):
|
||||
python_bin = str(p)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not python_bin:
|
||||
# 最后兜底:写 sys.executable,让错误能在日志里体现
|
||||
python_bin = sys.executable
|
||||
|
||||
# 日志目录可通过环境变量覆盖
|
||||
log_dir, out_log, err_log = expected_trading_log_paths(project_root, int(account_id))
|
||||
# supervisor 在 reread/update 时会校验 logfile 目录是否存在;这里提前创建避免 CANT_REREAD
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
# 最后兜底到 /tmp,确保一定存在
|
||||
log_dir = Path("/tmp") / "autosys_logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_log = log_dir / f"trading_{int(account_id)}.out.log"
|
||||
err_log = log_dir / f"trading_{int(account_id)}.err.log"
|
||||
|
||||
# 默认不自动启动,避免“创建账号=立刻下单”
|
||||
autostart = (os.getenv("TRADING_AUTOSTART_DEFAULT", "false") or "false").lower() == "true"
|
||||
run_user = (os.getenv("SUPERVISOR_RUN_USER") or "").strip()
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
f"[program:{program_name}]",
|
||||
f"directory={project_root}",
|
||||
f"command={python_bin} -m trading_system.main",
|
||||
"autostart=" + ("true" if autostart else "false"),
|
||||
# 更合理:仅在“非0退出”时重启;0 退出视为“正常结束”,不进入 FATAL 反复拉起
|
||||
"autorestart=unexpected",
|
||||
# 兼容:0/2 都视为“预期退出”(例如配置不完整/前置检查失败时主动退出)
|
||||
"exitcodes=0,2",
|
||||
"startsecs=0",
|
||||
"stopasgroup=true",
|
||||
"killasgroup=true",
|
||||
"stopsignal=TERM",
|
||||
"",
|
||||
# 关键:PYTHONPATH 指向项目根,确保 -m trading_system.main 可导入
|
||||
f'environment=ATS_ACCOUNT_ID="{int(account_id)}",PYTHONUNBUFFERED="1",PYTHONPATH="{project_root}"',
|
||||
(f"user={run_user}" if run_user else "").rstrip(),
|
||||
"",
|
||||
f"stdout_logfile={out_log}",
|
||||
f"stderr_logfile={err_log}",
|
||||
"stdout_logfile_maxbytes=20MB",
|
||||
"stdout_logfile_backups=5",
|
||||
"stderr_logfile_maxbytes=20MB",
|
||||
"stderr_logfile_backups=5",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def write_program_ini(program_dir: Path, filename: str, content: str) -> Path:
|
||||
program_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = program_dir / filename
|
||||
tmp = program_dir / (filename + ".tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
os.replace(str(tmp), str(target))
|
||||
return target
|
||||
|
||||
|
||||
def _build_supervisorctl_cmd(args: list[str]) -> list[str]:
|
||||
supervisorctl_path = _detect_supervisorctl_path()
|
||||
supervisor_conf = (os.getenv("SUPERVISOR_CONF") or "").strip()
|
||||
use_sudo = (os.getenv("SUPERVISOR_USE_SUDO", "false") or "false").lower() == "true"
|
||||
|
||||
if not supervisor_conf:
|
||||
conf = _detect_supervisor_conf_path()
|
||||
supervisor_conf = str(conf) if conf else ""
|
||||
|
||||
cmd: list[str] = []
|
||||
if use_sudo:
|
||||
cmd += ["sudo", "-n"]
|
||||
cmd += [supervisorctl_path]
|
||||
if supervisor_conf:
|
||||
cmd += ["-c", supervisor_conf]
|
||||
cmd += args
|
||||
return cmd
|
||||
|
||||
|
||||
def run_supervisorctl(args: list[str], timeout_sec: int = 10) -> str:
|
||||
cmd = _build_supervisorctl_cmd(args)
|
||||
try:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=int(timeout_sec))
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("supervisorctl 超时")
|
||||
except FileNotFoundError:
|
||||
# 明确提示找不到命令,帮助排查路径问题
|
||||
cmd_str = " ".join(cmd)
|
||||
raise RuntimeError(f"Command not found: {cmd[0]} (Full cmd: {cmd_str})")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"supervisorctl execution failed: {str(e)}")
|
||||
|
||||
out = (res.stdout or "").strip()
|
||||
|
||||
err = (res.stderr or "").strip()
|
||||
combined = "\n".join([s for s in [out, err] if s]).strip()
|
||||
# supervisorctl: status 在存在 STOPPED 等进程时可能返回 exit=3,但输出仍然有效
|
||||
ok_rc = {0}
|
||||
if args and args[0] == "status":
|
||||
ok_rc.add(3)
|
||||
if res.returncode not in ok_rc:
|
||||
raise RuntimeError(combined or f"supervisorctl failed (exit={res.returncode})")
|
||||
return combined or out
|
||||
|
||||
|
||||
def parse_supervisor_status(raw: str) -> Tuple[bool, Optional[int], str]:
|
||||
if "RUNNING" in raw:
|
||||
m = re.search(r"\bpid\s+(\d+)\b", raw)
|
||||
pid = int(m.group(1)) if m else None
|
||||
return True, pid, "RUNNING"
|
||||
for state in ["STOPPED", "FATAL", "EXITED", "BACKOFF", "STARTING", "UNKNOWN"]:
|
||||
if state in raw:
|
||||
return False, None, state
|
||||
return False, None, "UNKNOWN"
|
||||
|
||||
|
||||
def tail_supervisor(program: str, stream: str = "stderr", lines: int = 120) -> str:
|
||||
"""
|
||||
读取 supervisor 进程最近日志(stdout/stderr)。
|
||||
⚠️ 修复:优先直接读取日志文件,避免 XML-RPC 编码错误。
|
||||
如果 supervisorctl tail 失败(编码错误),回退到直接读取文件。
|
||||
"""
|
||||
s = (stream or "stderr").strip().lower()
|
||||
if s not in {"stdout", "stderr"}:
|
||||
s = "stderr"
|
||||
n = int(lines or 120)
|
||||
if n < 20:
|
||||
n = 20
|
||||
if n > 500:
|
||||
n = 500
|
||||
|
||||
# 优先尝试通过 supervisorctl tail(正常情况)
|
||||
try:
|
||||
return run_supervisorctl(["tail", f"-{n}", str(program), s])
|
||||
except Exception as e:
|
||||
# 如果 supervisorctl tail 失败(可能是编码错误),尝试直接读取日志文件
|
||||
error_msg = str(e)
|
||||
if "UnicodeDecodeError" in error_msg or "utf-8" in error_msg.lower() or "codec" in error_msg.lower():
|
||||
# 尝试从程序名解析 account_id(例如 auto_sys_acc4 -> 4)
|
||||
try:
|
||||
m = re.match(r"^auto_sys_acc(\d+)$", program)
|
||||
if m:
|
||||
account_id = int(m.group(1))
|
||||
project_root = _get_project_root()
|
||||
log_dir, out_log, err_log = expected_trading_log_paths(project_root, account_id)
|
||||
# 根据 stream 选择对应的日志文件
|
||||
log_file = out_log if s == "stdout" else err_log
|
||||
if log_file.exists():
|
||||
# 直接读取文件,使用宽松的编码处理
|
||||
return _tail_text_file(log_file, lines=n)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果所有尝试都失败,返回错误信息(但不要抛出异常,避免影响主流程)
|
||||
return f"[读取日志失败: {error_msg}]"
|
||||
|
||||
|
||||
def _tail_text_file(path: Path, lines: int = 200, max_bytes: int = 64 * 1024) -> str:
|
||||
"""
|
||||
读取文本文件末尾(用于 supervisor spawn error 等场景,program stderr 可能为空)。
|
||||
尽量只读最后 max_bytes,避免大文件占用内存。
|
||||
⚠️ 修复:使用更宽松的编码处理,支持中文等多字节字符。
|
||||
"""
|
||||
try:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return ""
|
||||
size = p.stat().st_size
|
||||
read_size = min(int(max_bytes), int(size))
|
||||
with p.open("rb") as f:
|
||||
if size > read_size:
|
||||
f.seek(-read_size, os.SEEK_END)
|
||||
data = f.read()
|
||||
|
||||
# ⚠️ 修复:尝试多种编码,优先 UTF-8,失败则尝试常见中文编码
|
||||
text = None
|
||||
encodings = ["utf-8", "gbk", "gb2312", "gb18030", "latin1"]
|
||||
for enc in encodings:
|
||||
try:
|
||||
text = data.decode(enc, errors="strict")
|
||||
break
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
|
||||
# 如果所有编码都失败,使用 errors="ignore" 强制解码(会丢失部分字符但不会报错)
|
||||
if text is None:
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
|
||||
# 仅保留最后 N 行
|
||||
parts = text.splitlines()
|
||||
if not parts:
|
||||
return ""
|
||||
n = int(lines or 200)
|
||||
if n < 20:
|
||||
n = 20
|
||||
if n > 500:
|
||||
n = 500
|
||||
return "\n".join(parts[-n:]).strip()
|
||||
except Exception:
|
||||
# 兜底:若启用 sudo(通常 backend 自己无权读 root 日志),尝试 sudo tail
|
||||
try:
|
||||
use_sudo = (os.getenv("SUPERVISOR_USE_SUDO", "false") or "false").lower() == "true"
|
||||
if not use_sudo:
|
||||
return ""
|
||||
n = int(lines or 200)
|
||||
if n < 20:
|
||||
n = 20
|
||||
if n > 500:
|
||||
n = 500
|
||||
res = subprocess.run(
|
||||
["sudo", "-n", "tail", "-n", str(n), str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
out = (res.stdout or "").strip()
|
||||
err = (res.stderr or "").strip()
|
||||
# 不强行报错:宁可空,也不要影响主流程
|
||||
return (out or err or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_supervisord_logfile_from_conf(conf_path: Path) -> Optional[Path]:
|
||||
"""
|
||||
解析 supervisord.conf 中 [supervisord] 的 logfile= 路径。
|
||||
"""
|
||||
try:
|
||||
text = conf_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return None
|
||||
in_section = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith(";") or line.startswith("#"):
|
||||
continue
|
||||
if re.match(r"^\[supervisord\]\s*$", line, flags=re.I):
|
||||
in_section = True
|
||||
continue
|
||||
if in_section and line.startswith("[") and line.endswith("]"):
|
||||
break
|
||||
if not in_section:
|
||||
continue
|
||||
m = re.match(r"^logfile\s*=\s*(.+)$", line, flags=re.I)
|
||||
if not m:
|
||||
continue
|
||||
val = (m.group(1) or "").strip().strip('"').strip("'")
|
||||
if not val:
|
||||
continue
|
||||
p = Path(val)
|
||||
if not p.is_absolute():
|
||||
p = (conf_path.parent / p).resolve()
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def tail_supervisord_log(lines: int = 200) -> str:
|
||||
"""
|
||||
读取 supervisord 主日志尾部(spawn error 的根因经常在这里)。
|
||||
可通过环境变量 SUPERVISOR_LOGFILE 指定。
|
||||
"""
|
||||
env_p = (os.getenv("SUPERVISOR_LOGFILE") or "").strip()
|
||||
if env_p:
|
||||
return _tail_text_file(Path(env_p), lines=lines)
|
||||
conf = _detect_supervisor_conf_path()
|
||||
if conf and conf.exists():
|
||||
lp = _parse_supervisord_logfile_from_conf(conf)
|
||||
if lp:
|
||||
return _tail_text_file(lp, lines=lines)
|
||||
# 最后兜底:尝试常见路径
|
||||
for cand in DEFAULT_SUPERVISORD_LOG_CANDIDATES:
|
||||
try:
|
||||
p = Path(cand)
|
||||
if p.exists():
|
||||
text = _tail_text_file(p, lines=lines)
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def expected_trading_log_paths(project_root: Path, account_id: int) -> Tuple[Path, Path, Path]:
|
||||
"""
|
||||
计算 trading program 的 stdout/stderr logfile 路径(需与 render_program_ini 保持一致)。
|
||||
返回 (log_dir, out_log, err_log)
|
||||
"""
|
||||
log_dir = Path(os.getenv("TRADING_LOG_DIR", str(project_root / "logs"))).expanduser()
|
||||
out_log = log_dir / f"trading_{int(account_id)}.out.log"
|
||||
err_log = log_dir / f"trading_{int(account_id)}.err.log"
|
||||
return log_dir, out_log, err_log
|
||||
|
||||
|
||||
def tail_trading_log_files(account_id: int, lines: int = 200) -> Dict[str, Any]:
|
||||
"""
|
||||
直接读取该账号 trading 进程的 stdout/stderr logfile 尾部(不依赖 supervisorctl tail)。
|
||||
返回 {out_log, err_log, stdout_tail, stderr_tail}
|
||||
"""
|
||||
project_root = _get_project_root()
|
||||
log_dir, out_log, err_log = expected_trading_log_paths(project_root, int(account_id))
|
||||
return {
|
||||
"log_dir": str(log_dir),
|
||||
"out_log": str(out_log),
|
||||
"err_log": str(err_log),
|
||||
"stdout_tail_file": _tail_text_file(out_log, lines=lines),
|
||||
"stderr_tail_file": _tail_text_file(err_log, lines=lines),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnsureProgramResult:
|
||||
ok: bool
|
||||
program: str
|
||||
ini_path: str
|
||||
program_dir: str
|
||||
supervisor_conf: str
|
||||
reread: str = ""
|
||||
update: str = ""
|
||||
error: str = ""
|
||||
|
||||
|
||||
def ensure_account_program(account_id: int) -> EnsureProgramResult:
|
||||
aid = int(account_id)
|
||||
program = program_name_for_account(aid)
|
||||
program_dir = get_supervisor_program_dir()
|
||||
ini_name = ini_filename_for_program(program)
|
||||
ini_text = render_program_ini(aid, program)
|
||||
conf = _detect_supervisor_conf_path()
|
||||
conf_s = str(conf) if conf else (os.getenv("SUPERVISOR_CONF") or "")
|
||||
|
||||
try:
|
||||
path = write_program_ini(program_dir, ini_name, ini_text)
|
||||
reread_out = ""
|
||||
update_out = ""
|
||||
try:
|
||||
reread_out = run_supervisorctl(["reread"])
|
||||
update_out = run_supervisorctl(["update"])
|
||||
except Exception as e:
|
||||
# 写文件成功但 supervisorctl 失败也要给出可诊断信息
|
||||
return EnsureProgramResult(
|
||||
ok=False,
|
||||
program=program,
|
||||
ini_path=str(path),
|
||||
program_dir=str(program_dir),
|
||||
supervisor_conf=conf_s,
|
||||
reread=reread_out,
|
||||
update=update_out,
|
||||
error=f"写入配置成功,但执行 supervisorctl reread/update 失败: {e}",
|
||||
)
|
||||
|
||||
return EnsureProgramResult(
|
||||
ok=True,
|
||||
program=program,
|
||||
ini_path=str(path),
|
||||
program_dir=str(program_dir),
|
||||
supervisor_conf=conf_s,
|
||||
reread=reread_out,
|
||||
update=update_out,
|
||||
)
|
||||
except Exception as e:
|
||||
return EnsureProgramResult(
|
||||
ok=False,
|
||||
program=program,
|
||||
ini_path="",
|
||||
program_dir=str(program_dir),
|
||||
supervisor_conf=conf_s,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
68
backend/check_db_symbols.py
Normal file
68
backend/check_db_symbols.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
# Add backend directory to sys.path
|
||||
backend_path = Path(__file__).parent
|
||||
sys.path.insert(0, str(backend_path))
|
||||
|
||||
try:
|
||||
from database.connection import db
|
||||
print("Database connection imported successfully.")
|
||||
except ImportError as e:
|
||||
print(f"Error importing database connection: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def is_ascii(s):
|
||||
return all(ord(c) < 128 for c in s)
|
||||
|
||||
def check_table(table_name, column_name):
|
||||
print(f"Checking table '{table_name}' column '{column_name}'...")
|
||||
try:
|
||||
query = f"SELECT DISTINCT {column_name} FROM {table_name}"
|
||||
rows = db.execute_query(query)
|
||||
|
||||
found_invalid = False
|
||||
for row in rows:
|
||||
symbol = row.get(column_name)
|
||||
if symbol and not is_ascii(symbol):
|
||||
print(f"!!! FOUND INVALID SYMBOL in {table_name}: '{symbol}'")
|
||||
found_invalid = True
|
||||
if symbol and "币安" in symbol:
|
||||
print(f"!!! FOUND '币安' in {table_name}: '{symbol}'")
|
||||
found_invalid = True
|
||||
|
||||
if not found_invalid:
|
||||
print(f"No invalid symbols found in {table_name}.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error querying {table_name}: {e}")
|
||||
|
||||
def check_config(table_name):
|
||||
print(f"Checking table '{table_name}' for '币安'...")
|
||||
try:
|
||||
query = f"SELECT config_key, config_value FROM {table_name}"
|
||||
rows = db.execute_query(query)
|
||||
|
||||
for row in rows:
|
||||
key = row.get('config_key')
|
||||
val = row.get('config_value')
|
||||
|
||||
if val and "币安" in str(val):
|
||||
# Ignore expected descriptions/comments if any (usually description is separate column)
|
||||
# But here we check config_value
|
||||
print(f"Found '币安' in {table_name} KEY='{key}': VALUE='{val}'")
|
||||
|
||||
if key and "币安" in str(key):
|
||||
print(f"Found '币安' in {table_name} KEY='{key}'")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error querying {table_name}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_table("trades", "symbol")
|
||||
check_table("trade_recommendations", "symbol")
|
||||
check_config("trading_config")
|
||||
check_config("global_strategy_config")
|
||||
46
backend/check_dependencies.sh
Executable file
46
backend/check_dependencies.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/bin/bash
|
||||
# 检查 backend 依赖是否完整安装
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== 检查 Backend 依赖 ==="
|
||||
echo ""
|
||||
|
||||
# 检查虚拟环境
|
||||
if [ -d "../.venv" ]; then
|
||||
echo "✓ 找到虚拟环境: ../.venv"
|
||||
source ../.venv/bin/activate
|
||||
elif [ -d ".venv" ]; then
|
||||
echo "✓ 找到虚拟环境: .venv"
|
||||
source .venv/bin/activate
|
||||
else
|
||||
echo "⚠ 未找到虚拟环境,使用系统 Python"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Python 路径: $(which python3)"
|
||||
echo "Python 版本: $(python3 --version)"
|
||||
echo ""
|
||||
|
||||
# 检查关键依赖
|
||||
echo "检查关键依赖..."
|
||||
python3 -c "import fastapi; print('✓ fastapi:', fastapi.__version__)" 2>&1 || echo "✗ fastapi 未安装"
|
||||
python3 -c "import uvicorn; print('✓ uvicorn:', uvicorn.__version__)" 2>&1 || echo "✗ uvicorn 未安装"
|
||||
python3 -c "from jose import jwt; print('✓ python-jose: 已安装')" 2>&1 || echo "✗ python-jose 未安装"
|
||||
python3 -c "import pymysql; print('✓ pymysql:', pymysql.__version__)" 2>&1 || echo "✗ pymysql 未安装"
|
||||
python3 -c "import redis; print('✓ redis:', redis.__version__)" 2>&1 || echo "✗ redis 未安装"
|
||||
python3 -c "from cryptography.fernet import Fernet; print('✓ cryptography: 已安装')" 2>&1 || echo "✗ cryptography 未安装"
|
||||
|
||||
echo ""
|
||||
echo "=== 尝试导入 api.main ==="
|
||||
python3 -c "import api.main; print('✓ api.main 导入成功')" 2>&1 || echo "✗ api.main 导入失败"
|
||||
|
||||
echo ""
|
||||
echo "=== 检查完成 ==="
|
||||
echo ""
|
||||
echo "如果缺少依赖,请运行:"
|
||||
echo " pip install -r backend/requirements.txt"
|
||||
echo ""
|
||||
echo "或者激活虚拟环境后运行:"
|
||||
echo " source .venv/bin/activate"
|
||||
echo " pip install -r backend/requirements.txt"
|
||||
|
|
@ -35,9 +35,10 @@ sys.path.insert(0, str(project_root))
|
|||
|
||||
# 延迟导入,避免在trading_system中导入时因为缺少依赖而失败
|
||||
try:
|
||||
from database.models import TradingConfig
|
||||
from database.models import TradingConfig, Account
|
||||
except ImportError as e:
|
||||
TradingConfig = None
|
||||
Account = None
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"无法导入TradingConfig: {e},配置管理器将无法使用数据库")
|
||||
|
|
@ -46,6 +47,39 @@ import logging
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 平台兜底:策略核心使用全局配置表(global_strategy_config),普通用户账号只允许调整“风险旋钮”
|
||||
# 执行策略合并顺序:普通用户(账号)配置优先,未设置时使用全局配置,允许用户简单控制自己的交易并只影响本人执行
|
||||
# - 风险旋钮:每个账号独立(仓位/频次等),账号有则用账号,无则用全局
|
||||
# - 其它策略参数:账号有则用账号,无则用全局(管理员可在全局配置设默认,用户可覆盖)
|
||||
# 注意:不再依赖account_id=1,全局配置存储在独立的global_strategy_config表中
|
||||
|
||||
_MISSING = object() # 用于区分“账号未设置”与“值为 None/0/False”
|
||||
|
||||
RISK_KNOBS_KEYS = {
|
||||
"MIN_MARGIN_USDT",
|
||||
"MIN_POSITION_PERCENT",
|
||||
"MAX_POSITION_PERCENT",
|
||||
"MAX_TOTAL_POSITION_PERCENT",
|
||||
"AUTO_TRADE_ENABLED",
|
||||
"MAX_OPEN_POSITIONS",
|
||||
"MAX_DAILY_ENTRIES",
|
||||
"SUNDAY_MAX_OPENS",
|
||||
"SUNDAY_MIN_SIGNAL_STRENGTH",
|
||||
"NIGHT_HOURS_NO_OPEN_ENABLED",
|
||||
"NIGHT_HOURS_START",
|
||||
"NIGHT_HOURS_END",
|
||||
# 2026-02-06 Added for Altcoin Strategy presets
|
||||
"TOP_N_SYMBOLS",
|
||||
"MIN_SIGNAL_STRENGTH",
|
||||
"MIN_VOLUME_24H",
|
||||
"MIN_VOLATILITY",
|
||||
"SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT",
|
||||
"EXCLUDE_MAJOR_COINS",
|
||||
# 2026-02-06 Added for User Customization
|
||||
"MAX_SCAN_SYMBOLS",
|
||||
"SCAN_INTERVAL",
|
||||
}
|
||||
|
||||
# 尝试导入同步Redis客户端(用于配置缓存)
|
||||
try:
|
||||
import redis
|
||||
|
|
@ -55,16 +89,242 @@ except ImportError:
|
|||
redis = None
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理器 - 优先从Redis缓存读取,其次从数据库读取,回退到环境变量和默认值"""
|
||||
class GlobalStrategyConfigManager:
|
||||
"""全局策略配置管理器(独立于账户,管理员专用)"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if hasattr(self, '_initialized'):
|
||||
return
|
||||
self._initialized = True
|
||||
self._cache = {}
|
||||
self._redis_client: Optional[redis.Redis] = None
|
||||
self._redis_connected = False
|
||||
self._redis_hash_key = "global_strategy_config_v5" # 独立的Redis键 (v5: 强制刷新缓存 - 2025-02-14)
|
||||
self._init_redis()
|
||||
self._load_from_db()
|
||||
|
||||
def _init_redis(self):
|
||||
"""初始化Redis客户端(同步)"""
|
||||
if not REDIS_SYNC_AVAILABLE:
|
||||
logger.debug("redis-py未安装,全局配置缓存将不使用Redis")
|
||||
return
|
||||
|
||||
try:
|
||||
redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379')
|
||||
redis_use_tls = os.getenv('REDIS_USE_TLS', 'False').lower() == 'true'
|
||||
redis_username = os.getenv('REDIS_USERNAME', None)
|
||||
redis_password = os.getenv('REDIS_PASSWORD', None)
|
||||
|
||||
if not redis_url or not isinstance(redis_url, str):
|
||||
redis_url = 'redis://localhost:6379'
|
||||
|
||||
if redis_use_tls and not redis_url.startswith('rediss://'):
|
||||
if redis_url.startswith('redis://'):
|
||||
redis_url = redis_url.replace('redis://', 'rediss://', 1)
|
||||
|
||||
connection_kwargs = {
|
||||
'username': redis_username,
|
||||
'password': redis_password,
|
||||
'decode_responses': True
|
||||
}
|
||||
|
||||
if redis_url.startswith('rediss://') or redis_use_tls:
|
||||
ssl_cert_reqs = os.getenv('REDIS_SSL_CERT_REQS', 'required')
|
||||
ssl_ca_certs = os.getenv('REDIS_SSL_CA_CERTS', None)
|
||||
connection_kwargs['select'] = int(os.getenv('REDIS_SELECT', 0))
|
||||
connection_kwargs['ssl_cert_reqs'] = ssl_cert_reqs
|
||||
if ssl_ca_certs:
|
||||
connection_kwargs['ssl_ca_certs'] = ssl_ca_certs
|
||||
if ssl_cert_reqs == 'none':
|
||||
connection_kwargs['ssl_check_hostname'] = False
|
||||
elif ssl_cert_reqs == 'required':
|
||||
connection_kwargs['ssl_check_hostname'] = True
|
||||
else:
|
||||
connection_kwargs['ssl_check_hostname'] = False
|
||||
|
||||
self._redis_client = redis.from_url(redis_url, **connection_kwargs)
|
||||
self._redis_client.ping()
|
||||
self._redis_connected = True
|
||||
logger.info("✓ 全局策略配置Redis缓存连接成功")
|
||||
except Exception as e:
|
||||
logger.debug(f"全局策略配置Redis缓存连接失败: {e},将使用数据库缓存")
|
||||
self._redis_client = None
|
||||
self._redis_connected = False
|
||||
|
||||
def _get_from_redis(self, key: str) -> Optional[Any]:
|
||||
"""从Redis获取全局配置值"""
|
||||
if not self._redis_connected or not self._redis_client:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = self._redis_client.hget(self._redis_hash_key, key)
|
||||
if value is not None and value != '':
|
||||
return ConfigManager._coerce_redis_value(value)
|
||||
except Exception as e:
|
||||
logger.debug(f"从Redis获取全局配置失败 {key}: {e}")
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
self._redis_connected = True
|
||||
except:
|
||||
self._redis_connected = False
|
||||
|
||||
return None
|
||||
|
||||
def _set_to_redis(self, key: str, value: Any):
|
||||
"""设置全局配置到Redis"""
|
||||
if not self._redis_connected or not self._redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
if isinstance(value, (dict, list, bool, int, float)):
|
||||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
self._redis_client.hset(self._redis_hash_key, key, value_str)
|
||||
self._redis_client.expire(self._redis_hash_key, 3600)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"设置全局配置到Redis失败 {key}: {e}")
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
self._redis_connected = True
|
||||
except:
|
||||
self._redis_connected = False
|
||||
return False
|
||||
|
||||
def _load_from_db(self):
|
||||
"""从数据库加载全局配置"""
|
||||
try:
|
||||
from database.models import GlobalStrategyConfig
|
||||
except ImportError:
|
||||
logger.warning("GlobalStrategyConfig未导入,无法从数据库加载全局配置")
|
||||
self._cache = {}
|
||||
return
|
||||
|
||||
try:
|
||||
# 先尝试从Redis加载
|
||||
if self._redis_connected and self._redis_client:
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
redis_configs = self._redis_client.hgetall(self._redis_hash_key)
|
||||
if redis_configs and len(redis_configs) > 0:
|
||||
for key, value_str in redis_configs.items():
|
||||
self._cache[key] = ConfigManager._coerce_redis_value(value_str)
|
||||
logger.info(f"从Redis加载了 {len(self._cache)} 个全局配置项")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"从Redis加载全局配置失败: {e},回退到数据库")
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
except:
|
||||
self._redis_connected = False
|
||||
|
||||
# 从数据库加载
|
||||
configs = GlobalStrategyConfig.get_all()
|
||||
for config in configs:
|
||||
key = config['config_key']
|
||||
# 使用TradingConfig的转换方法(GlobalStrategyConfig复用)
|
||||
from database.models import TradingConfig
|
||||
value = TradingConfig._convert_value(
|
||||
config['config_value'],
|
||||
config['config_type']
|
||||
)
|
||||
self._cache[key] = value
|
||||
self._set_to_redis(key, value)
|
||||
|
||||
logger.info(f"从数据库加载了 {len(self._cache)} 个全局配置项,已同步到Redis")
|
||||
except Exception as e:
|
||||
logger.warning(f"从数据库加载全局配置失败,使用默认配置: {e}")
|
||||
self._cache = {}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""获取全局配置值"""
|
||||
# 1. 优先从Redis缓存读取
|
||||
if self._redis_connected and self._redis_client:
|
||||
redis_value = self._get_from_redis(key)
|
||||
if redis_value is not None:
|
||||
self._cache[key] = redis_value
|
||||
return redis_value
|
||||
|
||||
# 2. 从本地缓存读取
|
||||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
|
||||
# 3. 从数据库读取
|
||||
try:
|
||||
from database.models import GlobalStrategyConfig
|
||||
db_value = GlobalStrategyConfig.get_value(key)
|
||||
if db_value is not None:
|
||||
self._cache[key] = db_value
|
||||
self._set_to_redis(key, db_value)
|
||||
return db_value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. 从环境变量读取
|
||||
env_value = os.getenv(key)
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
|
||||
# 5. 返回默认值
|
||||
return default
|
||||
|
||||
def reload_from_redis(self):
|
||||
"""强制从Redis重新加载全局配置"""
|
||||
if not self._redis_connected or not self._redis_client:
|
||||
return
|
||||
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis连接不可用: {e},跳过从Redis重新加载")
|
||||
self._redis_connected = False
|
||||
return
|
||||
|
||||
try:
|
||||
redis_configs = self._redis_client.hgetall(self._redis_hash_key)
|
||||
if redis_configs and len(redis_configs) > 0:
|
||||
self._cache = {}
|
||||
for key, value_str in redis_configs.items():
|
||||
self._cache[key] = ConfigManager._coerce_redis_value(value_str)
|
||||
logger.debug(f"从Redis重新加载了 {len(self._cache)} 个全局配置项")
|
||||
except Exception as e:
|
||||
logger.debug(f"从Redis重新加载全局配置失败: {e},保持现有缓存")
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理器 - 优先从Redis缓存读取,其次从数据库读取,回退到环境变量和默认值"""
|
||||
|
||||
_instances = {}
|
||||
|
||||
def __init__(self, account_id: int = 1):
|
||||
self.account_id = int(account_id or 1)
|
||||
self._cache = {}
|
||||
self._redis_client: Optional[redis.Redis] = None
|
||||
self._redis_connected = False
|
||||
self._redis_hash_key = f"trading_config:{self.account_id}"
|
||||
self._legacy_hash_key = "trading_config" if self.account_id == 1 else None
|
||||
self._init_redis()
|
||||
self._load_from_db()
|
||||
|
||||
@classmethod
|
||||
def for_account(cls, account_id: int):
|
||||
aid = int(account_id or 1)
|
||||
inst = cls._instances.get(aid)
|
||||
if inst:
|
||||
return inst
|
||||
inst = cls(account_id=aid)
|
||||
cls._instances[aid] = inst
|
||||
return inst
|
||||
|
||||
def _init_redis(self):
|
||||
"""初始化Redis客户端(同步)"""
|
||||
if not REDIS_SYNC_AVAILABLE:
|
||||
|
|
@ -115,6 +375,12 @@ class ConfigManager:
|
|||
ssl_cert_reqs = os.getenv('REDIS_SSL_CERT_REQS', 'required')
|
||||
ssl_ca_certs = os.getenv('REDIS_SSL_CA_CERTS', None)
|
||||
|
||||
connection_kwargs['select'] = os.getenv('REDIS_SELECT', 0)
|
||||
if connection_kwargs['select'] is not None:
|
||||
connection_kwargs['select'] = int(connection_kwargs['select'])
|
||||
else:
|
||||
connection_kwargs['select'] = 0
|
||||
logger.info(f"使用 Redis 数据库: {connection_kwargs['select']}")
|
||||
# 设置SSL参数
|
||||
connection_kwargs['ssl_cert_reqs'] = ssl_cert_reqs
|
||||
if ssl_ca_certs:
|
||||
|
|
@ -151,8 +417,10 @@ class ConfigManager:
|
|||
return None
|
||||
|
||||
try:
|
||||
# 使用Hash存储所有配置,键为 trading_config:{key}
|
||||
value = self._redis_client.hget('trading_config', key)
|
||||
# 使用账号维度 Hash 存储所有配置
|
||||
value = self._redis_client.hget(self._redis_hash_key, key)
|
||||
if (value is None or value == '') and self._legacy_hash_key:
|
||||
value = self._redis_client.hget(self._legacy_hash_key, key)
|
||||
if value is not None and value != '':
|
||||
return self._coerce_redis_value(value)
|
||||
except Exception as e:
|
||||
|
|
@ -217,21 +485,22 @@ class ConfigManager:
|
|||
return s
|
||||
|
||||
def _set_to_redis(self, key: str, value: Any):
|
||||
"""设置配置到Redis"""
|
||||
"""设置配置到Redis(账号维度 + legacy兼容)"""
|
||||
if not self._redis_connected or not self._redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 使用Hash存储所有配置,键为 trading_config:{key}
|
||||
# 将值序列化:复杂类型/基础类型使用 JSON,避免 bool 被写成 "False" 字符串后逻辑误判
|
||||
if isinstance(value, (dict, list, bool, int, float)):
|
||||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
self._redis_client.hset('trading_config', key, value_str)
|
||||
# 设置整个Hash的过期时间为7天(配置不会频繁变化,但需要定期刷新)
|
||||
self._redis_client.expire('trading_config', 7 * 24 * 3600)
|
||||
self._redis_client.hset(self._redis_hash_key, key, value_str)
|
||||
self._redis_client.expire(self._redis_hash_key, 3600)
|
||||
if self._legacy_hash_key:
|
||||
self._redis_client.hset(self._legacy_hash_key, key, value_str)
|
||||
self._redis_client.expire(self._legacy_hash_key, 3600)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"设置配置到Redis失败 {key}: {e}")
|
||||
|
|
@ -244,8 +513,11 @@ class ConfigManager:
|
|||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
value_str = str(value)
|
||||
self._redis_client.hset('trading_config', key, value_str)
|
||||
self._redis_client.expire('trading_config', 7 * 24 * 3600)
|
||||
self._redis_client.hset(self._redis_hash_key, key, value_str)
|
||||
self._redis_client.expire(self._redis_hash_key, 3600)
|
||||
if self._legacy_hash_key:
|
||||
self._redis_client.hset(self._legacy_hash_key, key, value_str)
|
||||
self._redis_client.expire(self._legacy_hash_key, 3600)
|
||||
return True
|
||||
except:
|
||||
self._redis_connected = False
|
||||
|
|
@ -257,15 +529,23 @@ class ConfigManager:
|
|||
return
|
||||
|
||||
try:
|
||||
# 批量设置所有配置到Redis
|
||||
# 批量设置所有配置到Redis(账号维度)
|
||||
pipe = self._redis_client.pipeline()
|
||||
for key, value in self._cache.items():
|
||||
if isinstance(value, (dict, list, bool, int, float)):
|
||||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
value_str = str(value)
|
||||
pipe.hset('trading_config', key, value_str)
|
||||
pipe.expire('trading_config', 7 * 24 * 3600)
|
||||
pipe.hset(self._redis_hash_key, key, value_str)
|
||||
pipe.expire(self._redis_hash_key, 3600)
|
||||
if self._legacy_hash_key:
|
||||
for key, value in self._cache.items():
|
||||
if isinstance(value, (dict, list, bool, int, float)):
|
||||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
value_str = str(value)
|
||||
pipe.hset(self._legacy_hash_key, key, value_str)
|
||||
pipe.expire(self._legacy_hash_key, 3600)
|
||||
pipe.execute()
|
||||
logger.debug(f"已将 {len(self._cache)} 个配置项同步到Redis")
|
||||
except Exception as e:
|
||||
|
|
@ -284,7 +564,9 @@ class ConfigManager:
|
|||
try:
|
||||
# 测试连接是否真正可用
|
||||
self._redis_client.ping()
|
||||
redis_configs = self._redis_client.hgetall('trading_config')
|
||||
redis_configs = self._redis_client.hgetall(self._redis_hash_key)
|
||||
if (not redis_configs) and self._legacy_hash_key:
|
||||
redis_configs = self._redis_client.hgetall(self._legacy_hash_key)
|
||||
if redis_configs and len(redis_configs) > 0:
|
||||
# 解析Redis中的配置
|
||||
for key, value_str in redis_configs.items():
|
||||
|
|
@ -303,7 +585,7 @@ class ConfigManager:
|
|||
self._redis_connected = False
|
||||
|
||||
# 从数据库加载配置(仅在Redis不可用或Redis中没有数据时)
|
||||
configs = TradingConfig.get_all()
|
||||
configs = TradingConfig.get_all(account_id=self.account_id)
|
||||
for config in configs:
|
||||
key = config['config_key']
|
||||
value = TradingConfig._convert_value(
|
||||
|
|
@ -321,6 +603,29 @@ class ConfigManager:
|
|||
|
||||
def get(self, key, default=None):
|
||||
"""获取配置值"""
|
||||
# 账号私有:API Key/Secret/Testnet 从 accounts 表读取(不走 trading_config)
|
||||
if key in ("BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET") and Account is not None:
|
||||
try:
|
||||
api_key, api_secret, use_testnet, status = Account.get_credentials(self.account_id)
|
||||
logger.debug(f"ConfigManager.get({key}, account_id={self.account_id}): api_key存在={bool(api_key)}, api_secret存在={bool(api_secret)}, status={status}")
|
||||
if key == "BINANCE_API_KEY":
|
||||
# 如果 api_key 为空字符串,返回 None 而不是 default(避免返回 'your_api_key_here')
|
||||
if not api_key or api_key.strip() == "":
|
||||
logger.warning(f"ConfigManager.get(BINANCE_API_KEY, account_id={self.account_id}): API密钥为空字符串")
|
||||
return None # 返回 None,让调用方知道密钥未配置
|
||||
return api_key
|
||||
if key == "BINANCE_API_SECRET":
|
||||
# 如果 api_secret 为空字符串,返回 None 而不是 default(避免返回 'your_api_secret_here')
|
||||
if not api_secret or api_secret.strip() == "":
|
||||
logger.warning(f"ConfigManager.get(BINANCE_API_SECRET, account_id={self.account_id}): API密钥Secret为空字符串")
|
||||
return None # 返回 None,让调用方知道密钥未配置
|
||||
return api_secret
|
||||
return bool(use_testnet)
|
||||
except Exception as e:
|
||||
# 回退到后续逻辑(旧数据/无表)
|
||||
logger.warning(f"ConfigManager.get({key}, account_id={self.account_id}): Account.get_credentials 失败: {e}")
|
||||
pass
|
||||
|
||||
# 1. 优先从Redis缓存读取(最新)
|
||||
# 注意:只在Redis连接正常时尝试读取,避免频繁连接失败
|
||||
if self._redis_connected and self._redis_client:
|
||||
|
|
@ -334,7 +639,18 @@ class ConfigManager:
|
|||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
|
||||
# 3. 从环境变量读取
|
||||
# 3. 从全局策略配置读取(如果账号未设置)
|
||||
# API密钥等敏感信息不走全局配置
|
||||
if key not in ("BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"):
|
||||
try:
|
||||
# GlobalStrategyConfigManager是单例,开销很小
|
||||
global_val = GlobalStrategyConfigManager().get(key)
|
||||
if global_val is not None:
|
||||
return global_val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. 从环境变量读取
|
||||
env_value = os.getenv(key)
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
|
|
@ -344,6 +660,21 @@ class ConfigManager:
|
|||
|
||||
def set(self, key, value, config_type='string', category='general', description=None):
|
||||
"""设置配置(同时更新数据库、Redis缓存和本地缓存)"""
|
||||
# 账号私有:API Key/Secret/Testnet 写入 accounts 表
|
||||
if key in ("BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET") and Account is not None:
|
||||
try:
|
||||
if key == "BINANCE_API_KEY":
|
||||
Account.update_credentials(self.account_id, api_key=str(value or ""))
|
||||
elif key == "BINANCE_API_SECRET":
|
||||
Account.update_credentials(self.account_id, api_secret=str(value or ""))
|
||||
else:
|
||||
Account.update_credentials(self.account_id, use_testnet=bool(value))
|
||||
self._cache[key] = value
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"更新账号API配置失败: {e}")
|
||||
raise
|
||||
|
||||
if TradingConfig is None:
|
||||
logger.warning("TradingConfig未导入,无法更新数据库配置")
|
||||
self._cache[key] = value
|
||||
|
|
@ -353,7 +684,7 @@ class ConfigManager:
|
|||
|
||||
try:
|
||||
# 1. 更新数据库
|
||||
TradingConfig.set(key, value, config_type, category, description)
|
||||
TradingConfig.set(key, value, config_type, category, description, account_id=self.account_id)
|
||||
|
||||
# 2. 更新本地缓存
|
||||
self._cache[key] = value
|
||||
|
|
@ -387,7 +718,9 @@ class ConfigManager:
|
|||
return
|
||||
|
||||
try:
|
||||
redis_configs = self._redis_client.hgetall('trading_config')
|
||||
redis_configs = self._redis_client.hgetall(self._redis_hash_key)
|
||||
if (not redis_configs) and self._legacy_hash_key:
|
||||
redis_configs = self._redis_client.hgetall(self._legacy_hash_key)
|
||||
if redis_configs and len(redis_configs) > 0:
|
||||
self._cache = {} # 清空缓存
|
||||
for key, value_str in redis_configs.items():
|
||||
|
|
@ -406,77 +739,312 @@ class ConfigManager:
|
|||
|
||||
def get_trading_config(self):
|
||||
"""获取交易配置字典(兼容原有config.py的TRADING_CONFIG)"""
|
||||
return {
|
||||
# 全局策略配置管理器(从独立的global_strategy_config表读取)
|
||||
global_config_mgr = GlobalStrategyConfigManager()
|
||||
try:
|
||||
global_config_mgr.reload_from_redis()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def eff_get(key: str, default: Any):
|
||||
"""
|
||||
执行策略合并:账号(用户)配置优先,未设置时使用全局配置,只影响该账号的交易执行。
|
||||
- API key/secret/testnet 仅账号,无全局兜底。
|
||||
- 其余项:先读账号,有则用;无则用全局,再无则用 default。
|
||||
"""
|
||||
value_from_account = False
|
||||
if key in ("BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"):
|
||||
value = self.get(key, default)
|
||||
value_from_account = True
|
||||
else:
|
||||
account_val = self.get(key, _MISSING)
|
||||
if account_val is not _MISSING:
|
||||
value = account_val
|
||||
value_from_account = True
|
||||
else:
|
||||
try:
|
||||
value = global_config_mgr.get(key, default)
|
||||
except Exception:
|
||||
value = default
|
||||
|
||||
# ⚠️ 临时兼容性处理:百分比配置值格式转换
|
||||
# 如果配置值是百分比形式(>1),转换为比例形式(除以100)
|
||||
# 兼容数据库中可能存在的旧数据(百分比形式,如30表示30%)
|
||||
# 数据迁移完成后,可以移除此逻辑
|
||||
# 统一格式:数据库、前端、后端都使用比例形式(0.30表示30%)
|
||||
if isinstance(value, (int, float)) and value is not None:
|
||||
# 需要转换的百分比配置项
|
||||
percent_keys = [
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'LOCK_PROFIT_STAGE1_TRIGGER_PCT',
|
||||
'LOCK_PROFIT_STAGE1_PCT',
|
||||
'LOCK_PROFIT_STAGE2_TRIGGER_PCT',
|
||||
'LOCK_PROFIT_STAGE2_PCT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'TAKE_PROFIT_1_PERCENT', # 分步止盈第一目标(默认15%)
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT',
|
||||
]
|
||||
|
||||
if key in percent_keys:
|
||||
# 如果值>1,认为是百分比形式(旧数据),转换为比例形式
|
||||
# 静默转换,不输出警告(用户已确认数据库应存储小数形式)
|
||||
if value > 1:
|
||||
value = value / 100.0
|
||||
# 静默更新缓存:值来自账号则写回账号,否则写回全局
|
||||
try:
|
||||
if value_from_account:
|
||||
self._set_to_redis(key, value)
|
||||
self._cache[key] = value
|
||||
else:
|
||||
global_config_mgr._set_to_redis(key, value)
|
||||
global_config_mgr._cache[key] = value
|
||||
except Exception as e:
|
||||
logger.debug(f"更新Redis缓存失败(不影响使用): {key} = {e}")
|
||||
|
||||
return value
|
||||
|
||||
# 交易预设:控制一组参数的“默认性格”
|
||||
profile = str(eff_get('TRADING_PROFILE', 'conservative') or 'conservative').lower()
|
||||
is_fast = profile in ('fast', 'fast_test', 'aggressive')
|
||||
|
||||
max_daily_default = 30 if is_fast else 8
|
||||
scan_interval_default = 900 if is_fast else 1800
|
||||
min_signal_default = 7 if is_fast else 8 # 2026-01-29优化:稳健模式从9降到8(平衡胜率和交易频率)
|
||||
cooldown_default = 900 if is_fast else 1800
|
||||
allow_neutral_default = True if is_fast else False
|
||||
short_filter_default = False if is_fast else True
|
||||
max_trend_move_default = 0.08 if is_fast else 0.05
|
||||
|
||||
result = {
|
||||
# 仓位控制
|
||||
'MAX_POSITION_PERCENT': self.get('MAX_POSITION_PERCENT', 0.08), # 提高单笔仓位到8%
|
||||
'MAX_TOTAL_POSITION_PERCENT': self.get('MAX_TOTAL_POSITION_PERCENT', 0.40), # 提高总仓位到40%
|
||||
'MIN_POSITION_PERCENT': self.get('MIN_POSITION_PERCENT', 0.02), # 提高最小仓位到2%
|
||||
'MIN_MARGIN_USDT': self.get('MIN_MARGIN_USDT', 5.0), # 提高最小保证金到5美元
|
||||
'MAX_POSITION_PERCENT': eff_get('MAX_POSITION_PERCENT', 0.12), # 单笔最大保证金占比(12%,加大单笔盈利空间)
|
||||
'MAX_TOTAL_POSITION_PERCENT': eff_get('MAX_TOTAL_POSITION_PERCENT', 0.40), # 总保证金占比上限
|
||||
'MIN_POSITION_PERCENT': eff_get('MIN_POSITION_PERCENT', 0.02), # 最小保证金占比
|
||||
'MIN_MARGIN_USDT': eff_get('MIN_MARGIN_USDT', 2.0), # 最小保证金(USDT)
|
||||
|
||||
# 用户风险旋钮:自动交易开关/频次控制
|
||||
'AUTO_TRADE_ENABLED': eff_get('AUTO_TRADE_ENABLED', True),
|
||||
'MAX_OPEN_POSITIONS': eff_get('MAX_OPEN_POSITIONS', 3),
|
||||
'MAX_DAILY_ENTRIES': eff_get('MAX_DAILY_ENTRIES', max_daily_default),
|
||||
'SUNDAY_MAX_OPENS': eff_get('SUNDAY_MAX_OPENS', 3), # 周日开仓上限,0=不限制
|
||||
'SUNDAY_MIN_SIGNAL_STRENGTH': eff_get('SUNDAY_MIN_SIGNAL_STRENGTH', 8), # 周日最低信号强度,0=不提高
|
||||
'NIGHT_HOURS_NO_OPEN_ENABLED': eff_get('NIGHT_HOURS_NO_OPEN_ENABLED', True),
|
||||
'NIGHT_HOURS_START': eff_get('NIGHT_HOURS_START', 21),
|
||||
'NIGHT_HOURS_END': eff_get('NIGHT_HOURS_END', 6),
|
||||
'NIGHT_HOURS_ONLY_SUNDAY': eff_get('NIGHT_HOURS_ONLY_SUNDAY', True),
|
||||
'NO_OPEN_HOURS_BJ': (eff_get('NO_OPEN_HOURS_BJ', '') or '').strip(), # 禁止开仓小时(北京),逗号分隔如 "17,19,22",空则不限制
|
||||
|
||||
# 同步/系统单标识(全局配置,账号可覆盖)
|
||||
'ONLY_AUTO_TRADE_CREATES_RECORDS': eff_get('ONLY_AUTO_TRADE_CREATES_RECORDS', True), # True=不补建「仅币安有仓」;False 时配合 SYNC_RECOVER 可补建
|
||||
'SYNC_RECOVER_MISSING_POSITIONS': eff_get('SYNC_RECOVER_MISSING_POSITIONS', True),
|
||||
'SYNC_RECOVER_ONLY_WHEN_HAS_SLTP': eff_get('SYNC_RECOVER_ONLY_WHEN_HAS_SLTP', True),
|
||||
'SYSTEM_ORDER_ID_PREFIX': eff_get('SYSTEM_ORDER_ID_PREFIX', 'SYS') or '',
|
||||
|
||||
# 涨跌幅阈值
|
||||
'MIN_CHANGE_PERCENT': self.get('MIN_CHANGE_PERCENT', 2.0),
|
||||
'TOP_N_SYMBOLS': self.get('TOP_N_SYMBOLS', 10),
|
||||
'MIN_CHANGE_PERCENT': eff_get('MIN_CHANGE_PERCENT', 2.0),
|
||||
|
||||
# 风险控制
|
||||
'STOP_LOSS_PERCENT': self.get('STOP_LOSS_PERCENT', 0.10), # 默认10%
|
||||
'TAKE_PROFIT_PERCENT': self.get('TAKE_PROFIT_PERCENT', 0.30), # 默认30%(盈亏比3:1)
|
||||
'MIN_STOP_LOSS_PRICE_PCT': self.get('MIN_STOP_LOSS_PRICE_PCT', 0.02), # 默认2%
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT': self.get('MIN_TAKE_PROFIT_PRICE_PCT', 0.03), # 默认3%
|
||||
'USE_ATR_STOP_LOSS': self.get('USE_ATR_STOP_LOSS', True), # 是否使用ATR动态止损
|
||||
'ATR_STOP_LOSS_MULTIPLIER': self.get('ATR_STOP_LOSS_MULTIPLIER', 1.8), # ATR止损倍数(1.5-2倍)
|
||||
'ATR_TAKE_PROFIT_MULTIPLIER': self.get('ATR_TAKE_PROFIT_MULTIPLIER', 3.0), # ATR止盈倍数(3倍ATR)
|
||||
'RISK_REWARD_RATIO': self.get('RISK_REWARD_RATIO', 3.0), # 盈亏比(止损距离的倍数)
|
||||
'ATR_PERIOD': self.get('ATR_PERIOD', 14), # ATR计算周期
|
||||
'USE_DYNAMIC_ATR_MULTIPLIER': self.get('USE_DYNAMIC_ATR_MULTIPLIER', False), # 是否根据波动率动态调整ATR倍数
|
||||
'ATR_MULTIPLIER_MIN': self.get('ATR_MULTIPLIER_MIN', 1.5), # 动态ATR倍数最小值
|
||||
'ATR_MULTIPLIER_MAX': self.get('ATR_MULTIPLIER_MAX', 2.5), # 动态ATR倍数最大值
|
||||
# ⚠️ 2026-01-29优化:放宽止损,减少被正常波动扫出
|
||||
# - 提高ATR倍数(从1.5到2.0),给市场波动更多空间
|
||||
# - 提高最小价格变动百分比(从2%到2.5%),避免止损过紧
|
||||
'STOP_LOSS_PERCENT': eff_get('STOP_LOSS_PERCENT', 0.12), # 默认12%(保证金百分比)
|
||||
'TAKE_PROFIT_PERCENT': eff_get('TAKE_PROFIT_PERCENT', 0.30), # 默认30%(第二目标/单目标止盈)
|
||||
'TAKE_PROFIT_1_PERCENT': eff_get('TAKE_PROFIT_1_PERCENT', 0.20), # 默认20%(2026-02-12优化:拉高第一目标改善盈亏比)
|
||||
'MIN_STOP_LOSS_PRICE_PCT': eff_get('MIN_STOP_LOSS_PRICE_PCT', 0.025), # 默认2.5%(2026-01-29优化:从2%提高到2.5%,给波动更多空间)
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT': eff_get('MIN_TAKE_PROFIT_PRICE_PCT', 0.02), # 默认2%(防止ATR过小时计算出不切实际的微小止盈距离)
|
||||
'USE_ATR_STOP_LOSS': eff_get('USE_ATR_STOP_LOSS', True), # 是否使用ATR动态止损
|
||||
'ATR_STOP_LOSS_MULTIPLIER': eff_get('ATR_STOP_LOSS_MULTIPLIER', 3.0), # ATR止损倍数3.0(2026-02-12优化:减少噪音止损,配合止盈拉远)
|
||||
'ATR_TAKE_PROFIT_MULTIPLIER': eff_get('ATR_TAKE_PROFIT_MULTIPLIER', 2.0), # ATR止盈倍数2.0(2026-01-27优化:降低止盈目标,更容易触发)
|
||||
'RISK_REWARD_RATIO': eff_get('RISK_REWARD_RATIO', 3.0), # 盈亏比3:1(2026-01-27优化:降低,更容易触发,保证胜率)
|
||||
'ATR_PERIOD': eff_get('ATR_PERIOD', 14), # ATR计算周期
|
||||
'USE_DYNAMIC_ATR_MULTIPLIER': eff_get('USE_DYNAMIC_ATR_MULTIPLIER', False), # 是否根据波动率动态调整ATR倍数
|
||||
'ATR_MULTIPLIER_MIN': eff_get('ATR_MULTIPLIER_MIN', 1.5), # 动态ATR倍数最小值
|
||||
'ATR_MULTIPLIER_MAX': eff_get('ATR_MULTIPLIER_MAX', 2.5), # 动态ATR倍数最大值
|
||||
|
||||
# 市场扫描(1小时主周期)
|
||||
'SCAN_INTERVAL': self.get('SCAN_INTERVAL', 3600), # 1小时
|
||||
'TOP_N_SYMBOLS': self.get('TOP_N_SYMBOLS', 10), # 每次扫描后处理的交易对数量
|
||||
'MAX_SCAN_SYMBOLS': self.get('MAX_SCAN_SYMBOLS', 500), # 扫描的最大交易对数量(0表示扫描所有)
|
||||
'KLINE_INTERVAL': self.get('KLINE_INTERVAL', '1h'),
|
||||
'PRIMARY_INTERVAL': self.get('PRIMARY_INTERVAL', '1h'),
|
||||
'CONFIRM_INTERVAL': self.get('CONFIRM_INTERVAL', '4h'),
|
||||
'ENTRY_INTERVAL': self.get('ENTRY_INTERVAL', '15m'),
|
||||
# 固定风险百分比仓位计算(凯利公式)
|
||||
'USE_FIXED_RISK_SIZING': eff_get('USE_FIXED_RISK_SIZING', True), # 使用固定风险百分比计算仓位
|
||||
'FIXED_RISK_PERCENT': eff_get('FIXED_RISK_PERCENT', 0.02), # 每笔单子承受的风险(2%)
|
||||
# 仓位放大系数:1.0=正常,1.2=+20% 仓位,上限 2.0,仍受 MAX_POSITION_PERCENT 约束(盈利时适度放大用)
|
||||
'POSITION_SCALE_FACTOR': eff_get('POSITION_SCALE_FACTOR', 1.0),
|
||||
|
||||
# 市场扫描(30分钟主周期)
|
||||
'SCAN_INTERVAL': eff_get('SCAN_INTERVAL', scan_interval_default), # 30分钟(增加交易机会)
|
||||
'TOP_N_SYMBOLS': eff_get('TOP_N_SYMBOLS', 20), # 每次扫描后优先处理的交易对数量
|
||||
'SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT': eff_get('SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT', 15), # 智能补单:多返回的候选数量,冷却时仍可尝试后续交易对
|
||||
'MAX_SCAN_SYMBOLS': eff_get('MAX_SCAN_SYMBOLS', 500), # 扫描的最大交易对数量(增加到500)
|
||||
'EXCLUDE_MAJOR_COINS': eff_get('EXCLUDE_MAJOR_COINS', True), # 是否排除主流币(BTC、ETH、BNB等),专注于山寨币
|
||||
'KLINE_INTERVAL': eff_get('KLINE_INTERVAL', '1h'),
|
||||
'PRIMARY_INTERVAL': eff_get('PRIMARY_INTERVAL', '1h'),
|
||||
'CONFIRM_INTERVAL': eff_get('CONFIRM_INTERVAL', '4h'),
|
||||
'ENTRY_INTERVAL': eff_get('ENTRY_INTERVAL', '15m'),
|
||||
|
||||
# 过滤条件
|
||||
'MIN_VOLUME_24H': self.get('MIN_VOLUME_24H', 10000000),
|
||||
'MIN_VOLATILITY': self.get('MIN_VOLATILITY', 0.02),
|
||||
'MIN_VOLUME_24H': eff_get('MIN_VOLUME_24H', 10000000),
|
||||
'MIN_VOLATILITY': eff_get('MIN_VOLATILITY', 0.02),
|
||||
|
||||
# 高胜率策略参数
|
||||
'MIN_SIGNAL_STRENGTH': self.get('MIN_SIGNAL_STRENGTH', 5),
|
||||
'LEVERAGE': self.get('LEVERAGE', 10),
|
||||
'USE_DYNAMIC_LEVERAGE': self.get('USE_DYNAMIC_LEVERAGE', True),
|
||||
'MAX_LEVERAGE': self.get('MAX_LEVERAGE', 15), # 降低到15,更保守,配合更大的保证金
|
||||
'USE_TRAILING_STOP': self.get('USE_TRAILING_STOP', True),
|
||||
'TRAILING_STOP_ACTIVATION': self.get('TRAILING_STOP_ACTIVATION', 0.10), # 默认10%(给趋势更多空间)
|
||||
'TRAILING_STOP_PROTECT': self.get('TRAILING_STOP_PROTECT', 0.05), # 默认5%(保护更多利润)
|
||||
# ⚠️ 2026-01-29优化:提高信号强度门槛(稳健模式从9到8),减少低质量信号,提升胜率
|
||||
'MIN_SIGNAL_STRENGTH': eff_get('MIN_SIGNAL_STRENGTH', min_signal_default), # 默认值随 profile 调整(快速模式7,稳健模式8)
|
||||
'LEVERAGE': eff_get('LEVERAGE', 10),
|
||||
'USE_DYNAMIC_LEVERAGE': eff_get('USE_DYNAMIC_LEVERAGE', True),
|
||||
'MAX_LEVERAGE': eff_get('MAX_LEVERAGE', 20), # 动态杠杆上限 20,配合单笔仓位提高收益
|
||||
'MIN_LEVERAGE': eff_get('MIN_LEVERAGE', 8), # 动态杠杆下限,不低于此值(之前盈利阶段多为 8x,避免被压到 2–4x 导致单笔盈利过少)
|
||||
'MAX_LEVERAGE_SMALL_CAP': eff_get('MAX_LEVERAGE_SMALL_CAP', 8), # 高波动/小众币最大杠杆,默认 8 与之前盈利阶段一致
|
||||
# 盈利保护总开关与保本:关闭后不执行保本、不执行移动止损
|
||||
'PROFIT_PROTECTION_ENABLED': eff_get('PROFIT_PROTECTION_ENABLED', True), # True=启用保本+移动止损,False=全部关闭
|
||||
'LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT': eff_get('LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT', 0.03), # 盈利达保证金比例时移至保本(0.03=3%,0=关闭)
|
||||
'LOCK_PROFIT_STAGE1_TRIGGER_PCT': eff_get('LOCK_PROFIT_STAGE1_TRIGGER_PCT', 0.08), # 盈利达该比例后进入第一层锁盈
|
||||
'LOCK_PROFIT_STAGE1_PCT': eff_get('LOCK_PROFIT_STAGE1_PCT', 0.02), # 第一层锁住的利润比例
|
||||
'LOCK_PROFIT_STAGE2_TRIGGER_PCT': eff_get('LOCK_PROFIT_STAGE2_TRIGGER_PCT', 0.15), # 盈利达该比例后进入第二层锁盈
|
||||
'LOCK_PROFIT_STAGE2_PCT': eff_get('LOCK_PROFIT_STAGE2_PCT', 0.05), # 第二层锁住的利润比例
|
||||
# 移动止损
|
||||
'USE_TRAILING_STOP': eff_get('USE_TRAILING_STOP', True), # 默认启用(2026-01-27优化:启用移动止损,保护利润)
|
||||
'TRAILING_STOP_ACTIVATION': eff_get('TRAILING_STOP_ACTIVATION', 0.05), # 默认5%(2026-01-27优化:更早保护利润,避免回吐)
|
||||
'TRAILING_STOP_PROTECT': eff_get('TRAILING_STOP_PROTECT', 0.025), # 默认2.5%(2026-01-27优化:给回撤足够空间,避免被震荡扫出)
|
||||
|
||||
# 最小持仓时间锁(强制波段持仓纪律,避免分钟级平仓)
|
||||
'MIN_HOLD_TIME_SEC': eff_get('MIN_HOLD_TIME_SEC', 1800), # 默认30分钟(1800秒)
|
||||
|
||||
# 自动交易过滤(用于提升胜率/控频)
|
||||
# 说明:这两个 key 需要出现在 TRADING_CONFIG 中,否则 trading_system 在每次 reload_from_redis 后会丢失它们,
|
||||
# 导致始终按默认值拦截自动交易(用户在配置页怎么开都没用)。
|
||||
'AUTO_TRADE_ONLY_TRENDING': self.get('AUTO_TRADE_ONLY_TRENDING', True),
|
||||
'AUTO_TRADE_ALLOW_4H_NEUTRAL': self.get('AUTO_TRADE_ALLOW_4H_NEUTRAL', False),
|
||||
'AUTO_TRADE_ONLY_TRENDING': eff_get('AUTO_TRADE_ONLY_TRENDING', True),
|
||||
'AUTO_TRADE_ALLOW_RANGING': eff_get('AUTO_TRADE_ALLOW_RANGING', False),
|
||||
'AUTO_TRADE_ALLOW_UNKNOWN': eff_get('AUTO_TRADE_ALLOW_UNKNOWN', False),
|
||||
'AUTO_TRADE_ALLOW_4H_NEUTRAL': eff_get('AUTO_TRADE_ALLOW_4H_NEUTRAL', allow_neutral_default),
|
||||
'RANGING_MARKET_SIGNAL_BOOST': eff_get('RANGING_MARKET_SIGNAL_BOOST', 2),
|
||||
|
||||
# 智能入场/限价偏移(部分逻辑会直接读取 TRADING_CONFIG)
|
||||
'LIMIT_ORDER_OFFSET_PCT': self.get('LIMIT_ORDER_OFFSET_PCT', 0.5),
|
||||
'SMART_ENTRY_ENABLED': self.get('SMART_ENTRY_ENABLED', False),
|
||||
'SMART_ENTRY_STRONG_SIGNAL': self.get('SMART_ENTRY_STRONG_SIGNAL', 8),
|
||||
'ENTRY_SYMBOL_COOLDOWN_SEC': self.get('ENTRY_SYMBOL_COOLDOWN_SEC', 120),
|
||||
'ENTRY_TIMEOUT_SEC': self.get('ENTRY_TIMEOUT_SEC', 180),
|
||||
'ENTRY_STEP_WAIT_SEC': self.get('ENTRY_STEP_WAIT_SEC', 15),
|
||||
'ENTRY_CHASE_MAX_STEPS': self.get('ENTRY_CHASE_MAX_STEPS', 4),
|
||||
'ENTRY_MARKET_FALLBACK_AFTER_SEC': self.get('ENTRY_MARKET_FALLBACK_AFTER_SEC', 45),
|
||||
'ENTRY_CONFIRM_TIMEOUT_SEC': self.get('ENTRY_CONFIRM_TIMEOUT_SEC', 30),
|
||||
'ENTRY_MAX_DRIFT_PCT_TRENDING': self.get('ENTRY_MAX_DRIFT_PCT_TRENDING', 0.6),
|
||||
'ENTRY_MAX_DRIFT_PCT_RANGING': self.get('ENTRY_MAX_DRIFT_PCT_RANGING', 0.3),
|
||||
'LIMIT_ORDER_OFFSET_PCT': eff_get('LIMIT_ORDER_OFFSET_PCT', 0.5),
|
||||
'SMART_ENTRY_ENABLED': eff_get('SMART_ENTRY_ENABLED', False),
|
||||
'SMART_ENTRY_STRONG_SIGNAL': eff_get('SMART_ENTRY_STRONG_SIGNAL', min_signal_default),
|
||||
'ENTRY_SYMBOL_COOLDOWN_SEC': eff_get('ENTRY_SYMBOL_COOLDOWN_SEC', cooldown_default),
|
||||
'ENTRY_TIMEOUT_SEC': eff_get('ENTRY_TIMEOUT_SEC', 180),
|
||||
'ENTRY_STEP_WAIT_SEC': eff_get('ENTRY_STEP_WAIT_SEC', 15),
|
||||
'ENTRY_CHASE_MAX_STEPS': eff_get('ENTRY_CHASE_MAX_STEPS', 4),
|
||||
'ENTRY_MARKET_FALLBACK_AFTER_SEC': eff_get('ENTRY_MARKET_FALLBACK_AFTER_SEC', 45),
|
||||
'ENTRY_CONFIRM_TIMEOUT_SEC': eff_get('ENTRY_CONFIRM_TIMEOUT_SEC', 30),
|
||||
'ENTRY_MAX_DRIFT_PCT_TRENDING': eff_get('ENTRY_MAX_DRIFT_PCT_TRENDING', 0.006),
|
||||
'ENTRY_MAX_DRIFT_PCT_RANGING': eff_get('ENTRY_MAX_DRIFT_PCT_RANGING', 0.3),
|
||||
# Algo 条件单(止损/止盈)单次请求超时(秒),币安接口高负载时易超时,网络不稳可调大至 60
|
||||
'ALGO_ORDER_TIMEOUT_SEC': eff_get('ALGO_ORDER_TIMEOUT_SEC', 45),
|
||||
|
||||
# 持仓详细监控日志开关(用于排查问题时观察每次检查的当前价/目标价/ROE 等)
|
||||
'POSITION_DETAILED_LOG_ENABLED': eff_get('POSITION_DETAILED_LOG_ENABLED', False),
|
||||
|
||||
# 动态过滤优化
|
||||
'BETA_FILTER_ENABLED': eff_get('BETA_FILTER_ENABLED', True), # 大盘共振过滤:BTC/ETH下跌时屏蔽多单
|
||||
'BETA_FILTER_THRESHOLD': eff_get('BETA_FILTER_THRESHOLD', -0.005), # -0.5%(2026-01-27优化:更敏感地过滤大盘风险,15分钟内跌幅超过0.5%即屏蔽多单)
|
||||
# RSI / 24h 涨跌幅过滤(避免追高杀跌)
|
||||
'MAX_RSI_FOR_LONG': eff_get('MAX_RSI_FOR_LONG', 65), # 做多时 RSI 超过此值则不开多(2026-02-12:65 避免追高)
|
||||
'MAX_CHANGE_PERCENT_FOR_LONG': eff_get('MAX_CHANGE_PERCENT_FOR_LONG', 25), # 做多时 24h 涨跌幅超过此值则不开多
|
||||
'MIN_RSI_FOR_SHORT': eff_get('MIN_RSI_FOR_SHORT', 30), # 做空时 RSI 低于此值则不做空
|
||||
'MAX_CHANGE_PERCENT_FOR_SHORT': eff_get('MAX_CHANGE_PERCENT_FOR_SHORT', 10), # 做空时 24h 涨跌幅超过此值则不做空
|
||||
# RSI 极限反转(与盈利期对齐:关闭可避免趋势里逆势止损)
|
||||
'RSI_EXTREME_REVERSE_ENABLED': eff_get('RSI_EXTREME_REVERSE_ENABLED', False),
|
||||
'RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H': eff_get('RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H', True),
|
||||
# 止盈/止损按保证金封顶(避免 TP 过远、SL 过宽扛单)
|
||||
'USE_MARGIN_CAP_FOR_TP': eff_get('USE_MARGIN_CAP_FOR_TP', True),
|
||||
'USE_MARGIN_CAP_FOR_SL': eff_get('USE_MARGIN_CAP_FOR_SL', True),
|
||||
|
||||
# 趋势尾部入场过滤 & 15m 短周期方向过滤开关(由 profile 控制默认值)
|
||||
'ENTRY_SHORT_INTERVAL': eff_get('ENTRY_SHORT_INTERVAL', '15m'),
|
||||
'ENTRY_SHORT_TREND_FILTER_ENABLED': eff_get('ENTRY_SHORT_TREND_FILTER_ENABLED', short_filter_default),
|
||||
'ENTRY_SHORT_TREND_MIN_PCT': eff_get('ENTRY_SHORT_TREND_MIN_PCT', 0.003),
|
||||
'ENTRY_SHORT_CONFIRM_CANDLES': eff_get('ENTRY_SHORT_CONFIRM_CANDLES', 3),
|
||||
'USE_TREND_ENTRY_FILTER': eff_get('USE_TREND_ENTRY_FILTER', True),
|
||||
# ⚠️ 2026-01-29优化:收紧趋势尾部过滤(稳健模式从0.05到0.04),更严格避免追高杀跌
|
||||
'MAX_TREND_MOVE_BEFORE_ENTRY': eff_get('MAX_TREND_MOVE_BEFORE_ENTRY', max_trend_move_default), # 快速模式0.08,稳健模式0.04
|
||||
'TREND_STATE_TTL_SEC': eff_get('TREND_STATE_TTL_SEC', 3600),
|
||||
'RECO_USE_TREND_ENTRY_FILTER': eff_get('RECO_USE_TREND_ENTRY_FILTER', True),
|
||||
'RECO_MAX_TREND_MOVE_BEFORE_ENTRY': eff_get('RECO_MAX_TREND_MOVE_BEFORE_ENTRY', 0.04),
|
||||
|
||||
# 当前交易预设(让 trading_system 能知道是哪种模式)
|
||||
'TRADING_PROFILE': profile,
|
||||
|
||||
# ⚠️ 2026-01-29新增:同一交易对连续亏损过滤(避免连续亏损后继续交易)
|
||||
'SYMBOL_LOSS_COOLDOWN_ENABLED': eff_get('SYMBOL_LOSS_COOLDOWN_ENABLED', True),
|
||||
'SYMBOL_MAX_CONSECUTIVE_LOSSES': eff_get('SYMBOL_MAX_CONSECUTIVE_LOSSES', 2),
|
||||
'SYMBOL_LOSS_COOLDOWN_SEC': eff_get('SYMBOL_LOSS_COOLDOWN_SEC', 3600),
|
||||
# 第一目标止盈最小盈亏比(相对止损距离)
|
||||
'MIN_RR_FOR_TP1': eff_get('MIN_RR_FOR_TP1', 1.5), # 2026-02-12:保证 TP1 至少 1.5 倍止损距离,改善盈亏比
|
||||
|
||||
# 市场状态方案(便于在不同行情间切换)
|
||||
'MARKET_SCHEME': str(eff_get('MARKET_SCHEME', 'normal') or 'normal').lower(),
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': eff_get('BLOCK_LONG_WHEN_4H_DOWN', False), # 4H 下跌时禁止开多(熊市/保守用)
|
||||
'BLOCK_SHORT_WHEN_4H_UP': eff_get('BLOCK_SHORT_WHEN_4H_UP', True), # 4H 上涨时禁止开空(默认 True,避免逆势做空)
|
||||
# 全局市场方案下禁空/禁多:牛市不推空单、熊市不推多单
|
||||
'BLOCK_SHORT_WHEN_BULL_MARKET': eff_get('BLOCK_SHORT_WHEN_BULL_MARKET', True), # 市场方案=牛市时禁止开空
|
||||
'BLOCK_LONG_WHEN_BEAR_MARKET': eff_get('BLOCK_LONG_WHEN_BEAR_MARKET', True), # 市场方案=熊市时禁止开多
|
||||
}
|
||||
|
||||
# 根据市场方案覆盖关键参数(便于快速切换熊市/牛市/保守等预设)
|
||||
_SCHEME_PRESETS = {
|
||||
'normal': {
|
||||
'MIN_STOP_LOSS_PRICE_PCT': 0.03,
|
||||
'MAX_POSITION_PERCENT': 0.12,
|
||||
'ATR_STOP_LOSS_MULTIPLIER': 2.5,
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': False,
|
||||
'BLOCK_SHORT_WHEN_4H_UP': True, # 4H 上涨不开空
|
||||
},
|
||||
'bear': {
|
||||
'MIN_STOP_LOSS_PRICE_PCT': 0.05, # 放宽止损约 -5%
|
||||
'MAX_POSITION_PERCENT': 0.08, # 单仓 ≤ 8%
|
||||
'ATR_STOP_LOSS_MULTIPLIER': 2.5,
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': True, # 4H 下跌不开多
|
||||
'BLOCK_SHORT_WHEN_4H_UP': True, # 4H 上涨不开空
|
||||
'BETA_FILTER_ENABLED': True,
|
||||
},
|
||||
'bull': {
|
||||
'MIN_STOP_LOSS_PRICE_PCT': 0.03,
|
||||
'MAX_POSITION_PERCENT': 0.12,
|
||||
'ATR_STOP_LOSS_MULTIPLIER': 2.0,
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': False,
|
||||
'BLOCK_SHORT_WHEN_4H_UP': True, # 4H 上涨不开空(牛市尤需)
|
||||
},
|
||||
'conservative': {
|
||||
'MIN_STOP_LOSS_PRICE_PCT': 0.06, # 最宽松止损
|
||||
'MAX_POSITION_PERCENT': 0.06, # 最小仓位
|
||||
'ATR_STOP_LOSS_MULTIPLIER': 2.5,
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': True,
|
||||
'BLOCK_SHORT_WHEN_4H_UP': True,
|
||||
'BETA_FILTER_ENABLED': True,
|
||||
},
|
||||
}
|
||||
scheme = result.get('MARKET_SCHEME', 'normal') or 'normal'
|
||||
if scheme in _SCHEME_PRESETS:
|
||||
for k, v in _SCHEME_PRESETS[scheme].items():
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
# 全局配置管理器实例
|
||||
config_manager = ConfigManager()
|
||||
def _sync_to_redis(self):
|
||||
"""将配置同步到Redis缓存(账号维度)"""
|
||||
if not self._redis_connected or not self._redis_client:
|
||||
return
|
||||
try:
|
||||
payload = {k: json.dumps(v) for k, v in self._cache.items()}
|
||||
self._redis_client.hset(self._redis_hash_key, mapping=payload)
|
||||
self._redis_client.expire(self._redis_hash_key, 3600)
|
||||
if self._legacy_hash_key:
|
||||
self._redis_client.hset(self._legacy_hash_key, mapping=payload)
|
||||
self._redis_client.expire(self._legacy_hash_key, 3600)
|
||||
except Exception as e:
|
||||
logger.debug(f"同步配置到Redis失败: {e}")
|
||||
|
||||
# 全局配置管理器实例(默认账号;trading_system 进程可通过 ATS_ACCOUNT_ID 指定)
|
||||
try:
|
||||
_default_account_id = int(os.getenv("ATS_ACCOUNT_ID") or os.getenv("ACCOUNT_ID") or 1)
|
||||
except Exception:
|
||||
_default_account_id = 1
|
||||
config_manager = ConfigManager.for_account(_default_account_id)
|
||||
|
||||
# 兼容原有config.py的接口
|
||||
def get_config(key, default=None):
|
||||
|
|
|
|||
31
backend/database/add_auth.sql
Normal file
31
backend/database/add_auth.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
-- 登录与权限系统迁移脚本(在已有库上执行一次)
|
||||
-- 目标:
|
||||
-- 1) 新增 users 表(管理员/普通用户)
|
||||
-- 2) 新增 user_account_memberships 表(用户可访问哪些交易账号)
|
||||
--
|
||||
-- 执行前建议备份数据库。
|
||||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`username` VARCHAR(64) NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL DEFAULT 'user' COMMENT 'admin, user',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active, disabled',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录用户';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_account_memberships` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`account_id` INT NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL DEFAULT 'viewer' COMMENT 'owner, viewer',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_user_account` (`user_id`, `account_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_account_id` (`account_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户-交易账号授权';
|
||||
|
||||
56
backend/database/add_binance_sync_tables.sql
Normal file
56
backend/database/add_binance_sync_tables.sql
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
-- 币安订单/成交同步表,供定时任务拉取后存储,数据管理从 DB 查询分析
|
||||
-- 执行: mysql -u user -p db_name < add_binance_sync_tables.sql
|
||||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
-- 币安成交记录(userTrades)
|
||||
CREATE TABLE IF NOT EXISTS `binance_trades` (
|
||||
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
`account_id` INT UNSIGNED NOT NULL,
|
||||
`symbol` VARCHAR(32) NOT NULL,
|
||||
`trade_id` BIGINT UNSIGNED NOT NULL COMMENT '币安 trade id',
|
||||
`order_id` BIGINT UNSIGNED NOT NULL,
|
||||
`side` VARCHAR(10) NOT NULL,
|
||||
`position_side` VARCHAR(10) DEFAULT NULL,
|
||||
`price` DECIMAL(24, 8) NOT NULL,
|
||||
`qty` DECIMAL(24, 8) NOT NULL,
|
||||
`quote_qty` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`realized_pnl` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`commission` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`commission_asset` VARCHAR(20) DEFAULT NULL,
|
||||
`buyer` TINYINT(1) DEFAULT NULL,
|
||||
`maker` TINYINT(1) DEFAULT NULL,
|
||||
`trade_time` BIGINT UNSIGNED NOT NULL COMMENT '成交时间戳毫秒',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_account_trade` (`account_id`, `trade_id`),
|
||||
INDEX `idx_account_time` (`account_id`, `trade_time`),
|
||||
INDEX `idx_symbol_time` (`account_id`, `symbol`, `trade_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='币安成交记录(定时同步)';
|
||||
|
||||
-- 币安订单记录(allOrders)
|
||||
CREATE TABLE IF NOT EXISTS `binance_orders` (
|
||||
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
`account_id` INT UNSIGNED NOT NULL,
|
||||
`symbol` VARCHAR(32) NOT NULL,
|
||||
`order_id` BIGINT UNSIGNED NOT NULL,
|
||||
`client_order_id` VARCHAR(64) DEFAULT NULL,
|
||||
`side` VARCHAR(10) NOT NULL,
|
||||
`type` VARCHAR(32) DEFAULT NULL,
|
||||
`orig_type` VARCHAR(32) DEFAULT NULL,
|
||||
`status` VARCHAR(32) NOT NULL,
|
||||
`price` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`avg_price` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`orig_qty` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`executed_qty` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`cum_qty` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`cum_quote` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`stop_price` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`reduce_only` TINYINT(1) DEFAULT NULL,
|
||||
`position_side` VARCHAR(10) DEFAULT NULL,
|
||||
`order_time` BIGINT UNSIGNED NOT NULL COMMENT '下单时间戳毫秒',
|
||||
`update_time` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新时间戳毫秒',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_account_order` (`account_id`, `order_id`),
|
||||
INDEX `idx_account_time` (`account_id`, `order_time`),
|
||||
INDEX `idx_symbol_time` (`account_id`, `symbol`, `order_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='币安订单记录(定时同步)';
|
||||
5
backend/database/add_client_order_id.sql
Normal file
5
backend/database/add_client_order_id.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- 为 trades 表增加「自定义订单号」字段,用于存储币安 clientOrderId,便于在订单记录中核对系统单
|
||||
-- 若已存在该列可跳过本句
|
||||
ALTER TABLE trades ADD COLUMN client_order_id VARCHAR(64) NULL COMMENT '币安自定义订单号 clientOrderId(系统单格式: 前缀_时间戳_随机)' AFTER entry_order_id;
|
||||
-- 可选:为按自定义订单号查询建索引(若已存在可跳过)
|
||||
-- CREATE INDEX idx_client_order_id ON trades (client_order_id);
|
||||
20
backend/database/add_created_at_to_trades.sql
Normal file
20
backend/database/add_created_at_to_trades.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- 为 trades 表增加 created_at(创建时间)字段(仅当不存在时)
|
||||
-- 用于持仓/订单展示「开仓时间」时至少有创建时间可显示;与 init.sql 中定义一致。
|
||||
|
||||
-- MySQL 5.7+:通过 procedure 判断后添加,避免重复执行报错
|
||||
DELIMITER //
|
||||
DROP PROCEDURE IF EXISTS add_created_at_to_trades_if_missing//
|
||||
CREATE PROCEDURE add_created_at_to_trades_if_missing()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'trades' AND COLUMN_NAME = 'created_at') = 0 THEN
|
||||
ALTER TABLE trades
|
||||
ADD COLUMN created_at INT UNSIGNED NULL COMMENT '创建时间(Unix时间戳秒数)' AFTER status;
|
||||
UPDATE trades SET created_at = COALESCE(entry_time, UNIX_TIMESTAMP()) WHERE created_at IS NULL;
|
||||
ALTER TABLE trades
|
||||
MODIFY COLUMN created_at INT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()) COMMENT '创建时间(Unix时间戳秒数)';
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
CALL add_created_at_to_trades_if_missing();
|
||||
DROP PROCEDURE IF EXISTS add_created_at_to_trades_if_missing;
|
||||
21
backend/database/add_entry_context.sql
Normal file
21
backend/database/add_entry_context.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- 为 trades 表添加「入场思路/过程」字段,便于事后分析策略执行效果
|
||||
-- 存储 JSON:signal_strength, market_regime, trend_4h, change_percent, rsi, reason, volume_confirmed 等
|
||||
|
||||
-- 使用动态 SQL 检查列是否存在(兼容已有库)
|
||||
SET @column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'trades'
|
||||
AND column_name = 'entry_context'
|
||||
);
|
||||
|
||||
SET @sql = IF(@column_exists = 0,
|
||||
'ALTER TABLE `trades` ADD COLUMN `entry_context` JSON NULL COMMENT ''入场时的思路与过程(信号强度、市场状态、趋势、过滤通过情况等),便于综合分析策略执行效果'' AFTER `entry_reason`',
|
||||
'SELECT "entry_context 列已存在,跳过添加" AS message'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SELECT 'Migration completed: entry_context added to trades (if not exists).' AS result;
|
||||
45
backend/database/add_global_strategy_config.sql
Normal file
45
backend/database/add_global_strategy_config.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
-- 创建全局策略配置表(独立于账户)
|
||||
-- 全局配置不依赖任何account_id,由管理员统一管理
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `global_strategy_config` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`config_key` VARCHAR(100) NOT NULL,
|
||||
`config_value` TEXT NOT NULL,
|
||||
`config_type` VARCHAR(50) NOT NULL COMMENT 'string, number, boolean, json',
|
||||
`category` VARCHAR(50) NOT NULL COMMENT 'strategy, risk, scan',
|
||||
`description` TEXT,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(50) COMMENT '更新人(用户名)',
|
||||
INDEX `idx_category` (`category`),
|
||||
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全局策略配置表(管理员专用)';
|
||||
|
||||
-- 迁移现有account_id=1的核心策略配置到全局配置表
|
||||
-- 注意:只迁移非风险旋钮的配置
|
||||
INSERT INTO `global_strategy_config` (`config_key`, `config_value`, `config_type`, `category`, `description`)
|
||||
SELECT
|
||||
`config_key`,
|
||||
`config_value`,
|
||||
`config_type`,
|
||||
`category`,
|
||||
`description`
|
||||
FROM `trading_config`
|
||||
WHERE `account_id` = 1
|
||||
AND `config_key` NOT IN (
|
||||
'MIN_MARGIN_USDT',
|
||||
'MIN_POSITION_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'AUTO_TRADE_ENABLED',
|
||||
'MAX_OPEN_POSITIONS',
|
||||
'MAX_DAILY_ENTRIES',
|
||||
'BINANCE_API_KEY',
|
||||
'BINANCE_API_SECRET',
|
||||
'USE_TESTNET'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`config_value` = VALUES(`config_value`),
|
||||
`config_type` = VALUES(`config_type`),
|
||||
`category` = VALUES(`category`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = CURRENT_TIMESTAMP;
|
||||
13
backend/database/add_market_cache.sql
Normal file
13
backend/database/add_market_cache.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- 市场缓存表:存放较固定的交易所数据(交易对信息、资金费率规则等),减少 API 调用
|
||||
-- 执行: mysql -u root -p auto_trade_sys < add_market_cache.sql
|
||||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `market_cache` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`cache_key` VARCHAR(128) NOT NULL COMMENT '如 exchange_info, funding_info',
|
||||
`cache_value` LONGTEXT NOT NULL COMMENT 'JSON 内容',
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_cache_key` (`cache_key`),
|
||||
INDEX `idx_updated_at` (`updated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场数据缓存(交易对/费率规则等)';
|
||||
91
backend/database/add_multi_account.sql
Normal file
91
backend/database/add_multi_account.sql
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
-- 多账号迁移脚本(在已有库上执行一次)
|
||||
-- 目标:
|
||||
-- 1) 新增 accounts 表(存加密后的 API KEY/SECRET)
|
||||
-- 2) trading_config/trades/account_snapshots 增加 account_id(默认=1)
|
||||
-- 3) trading_config 的唯一约束从 config_key 改为 (account_id, config_key)
|
||||
--
|
||||
-- ⚠️ 注意:
|
||||
-- - 不同 MySQL 版本对 "ADD COLUMN IF NOT EXISTS" 支持不一致,因此这里用 INFORMATION_SCHEMA + 动态SQL。
|
||||
-- - 执行前建议先备份数据库。
|
||||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
-- 1) accounts 表
|
||||
CREATE TABLE IF NOT EXISTS `accounts` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`status` VARCHAR(20) DEFAULT 'active' COMMENT 'active, disabled',
|
||||
`api_key_enc` TEXT NULL COMMENT '加密后的 API KEY(enc:v1:...)',
|
||||
`api_secret_enc` TEXT NULL COMMENT '加密后的 API SECRET(enc:v1:...)',
|
||||
`use_testnet` BOOLEAN DEFAULT FALSE,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号表(多账号)';
|
||||
|
||||
INSERT INTO `accounts` (`id`, `name`, `status`, `use_testnet`)
|
||||
VALUES (1, 'default', 'active', false)
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- 2) trading_config.account_id
|
||||
SET @has_col := (
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trading_config'
|
||||
AND COLUMN_NAME = 'account_id'
|
||||
);
|
||||
SET @sql := IF(@has_col = 0, 'ALTER TABLE trading_config ADD COLUMN account_id INT NOT NULL DEFAULT 1 AFTER id', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 3) trades.account_id
|
||||
SET @has_col := (
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trades'
|
||||
AND COLUMN_NAME = 'account_id'
|
||||
);
|
||||
SET @sql := IF(@has_col = 0, 'ALTER TABLE trades ADD COLUMN account_id INT NOT NULL DEFAULT 1 AFTER id', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 4) account_snapshots.account_id
|
||||
SET @has_col := (
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'account_snapshots'
|
||||
AND COLUMN_NAME = 'account_id'
|
||||
);
|
||||
SET @sql := IF(@has_col = 0, 'ALTER TABLE account_snapshots ADD COLUMN account_id INT NOT NULL DEFAULT 1 AFTER id', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 5) trading_config 唯一键:改为 (account_id, config_key)
|
||||
-- 尝试删除旧 UNIQUE(config_key)(名字可能是 config_key 或其他)
|
||||
SET @idx_name := (
|
||||
SELECT INDEX_NAME
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trading_config'
|
||||
AND NON_UNIQUE = 0
|
||||
AND COLUMN_NAME = 'config_key'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @sql := IF(@idx_name IS NOT NULL, CONCAT('ALTER TABLE trading_config DROP INDEX ', @idx_name), 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 添加新唯一键(如果不存在)
|
||||
SET @has_uk := (
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trading_config'
|
||||
AND INDEX_NAME = 'uk_account_config_key'
|
||||
);
|
||||
SET @sql := IF(@has_uk = 0, 'ALTER TABLE trading_config ADD UNIQUE KEY uk_account_config_key (account_id, config_key)', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 6) 索引(可选,老版本 MySQL 不支持 IF NOT EXISTS,可忽略报错后手动检查)
|
||||
-- 如果你看到 “Duplicate key name” 可直接忽略。
|
||||
CREATE INDEX idx_trades_account_id ON trades(account_id);
|
||||
CREATE INDEX idx_account_snapshots_account_id ON account_snapshots(account_id);
|
||||
|
||||
11
backend/database/add_order_type_fields.sql
Normal file
11
backend/database/add_order_type_fields.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- 可选:订单类型字段,便于统计与策略分析(开仓/平仓方式)
|
||||
-- 执行前请确认表已存在;若列已存在可跳过
|
||||
|
||||
-- 开仓订单类型:LIMIT / MARKET 等(来自币安订单 type)
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS entry_order_type VARCHAR(32) NULL COMMENT '开仓订单类型 LIMIT/MARKET' AFTER client_order_id;
|
||||
|
||||
-- 平仓订单类型:MARKET / STOP_MARKET / TAKE_PROFIT_MARKET 等(便于区分市价平、止损、止盈)
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS exit_order_type VARCHAR(32) NULL COMMENT '平仓订单类型' AFTER exit_order_id;
|
||||
|
||||
-- 来源口径:仅自动下单入 DB 时可固定为 auto_trade,预留便于扩展
|
||||
-- ALTER TABLE trades ADD COLUMN IF NOT EXISTS source VARCHAR(32) NULL DEFAULT 'auto_trade' COMMENT '记录来源 auto_trade' AFTER entry_reason;
|
||||
42
backend/database/add_partial_profit_exit_reasons.sql
Normal file
42
backend/database/add_partial_profit_exit_reasons.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
-- 分步止盈状态细分:添加新的exit_reason值支持
|
||||
-- 执行时间:2026-01-27
|
||||
|
||||
-- 1. 更新exit_reason字段注释,说明新的状态值
|
||||
ALTER TABLE `trades` MODIFY COLUMN `exit_reason` VARCHAR(50)
|
||||
COMMENT '平仓原因: manual(手动), stop_loss(止损), take_profit(单次止盈), trailing_stop(移动止损), sync(同步), take_profit_partial_then_take_profit(第一目标止盈后第二目标止盈), take_profit_partial_then_stop(第一目标止盈后剩余仓位止损), take_profit_partial_then_trailing_stop(第一目标止盈后剩余仓位移动止损)';
|
||||
|
||||
-- 2. 验证字段长度是否足够(VARCHAR(50)应该足够)
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
COLUMN_COMMENT
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trades'
|
||||
AND COLUMN_NAME = 'exit_reason';
|
||||
|
||||
-- 3. 查看当前exit_reason的分布情况(用于验证)
|
||||
SELECT
|
||||
exit_reason,
|
||||
COUNT(*) as count,
|
||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM trades WHERE status = 'closed'), 2) as percentage
|
||||
FROM
|
||||
trades
|
||||
WHERE
|
||||
status = 'closed'
|
||||
GROUP BY
|
||||
exit_reason
|
||||
ORDER BY
|
||||
count DESC;
|
||||
|
||||
-- 说明:
|
||||
-- 新的状态值:
|
||||
-- - take_profit_partial_then_take_profit: 第一目标止盈(50%仓位)后,剩余仓位第二目标止盈
|
||||
-- - take_profit_partial_then_stop: 第一目标止盈(50%仓位)后,剩余仓位止损(保本)
|
||||
-- - take_profit_partial_then_trailing_stop: 第一目标止盈(50%仓位)后,剩余仓位移动止损
|
||||
--
|
||||
-- 这些状态用于更准确地统计胜率和盈亏比:
|
||||
-- - 第一目标止盈后剩余仓位止损,应该算作"部分成功"(第一目标已达成)
|
||||
-- - 第一目标止盈后剩余仓位第二目标止盈,应该算作"完整成功"
|
||||
4
backend/database/add_realized_pnl_columns.sql
Normal file
4
backend/database/add_realized_pnl_columns.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS realized_pnl DECIMAL(20, 8) DEFAULT NULL COMMENT '币安实际结算盈亏(包含资金费率等)';
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS commission DECIMAL(20, 8) DEFAULT NULL COMMENT '交易手续费(USDT计价)';
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS commission_asset VARCHAR(10) DEFAULT NULL COMMENT '手续费币种(BNB/USDT)';
|
||||
23
backend/database/cleanup_non_system_trades.sql
Normal file
23
backend/database/cleanup_non_system_trades.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- 清理「非交易系统下单」的交易记录(无开仓订单号的记录)
|
||||
-- 本系统开仓会在成交后保存 entry_order_id;无该字段或为 0 的为同步补录/其它来源,可安全删除。
|
||||
-- 执行前请先备份数据库或至少备份 trades 表。
|
||||
-- 若表结构较旧、没有 entry_order_id 列,请先执行 add_order_ids.sql 或跳过本脚本。
|
||||
|
||||
-- 1) 查看将要删除的记录数(按账号)
|
||||
SELECT account_id, status, COUNT(*) AS cnt
|
||||
FROM trades
|
||||
WHERE entry_order_id IS NULL OR entry_order_id = 0
|
||||
GROUP BY account_id, status
|
||||
ORDER BY account_id, status;
|
||||
|
||||
-- 2) 查看将要删除的总数
|
||||
SELECT COUNT(*) AS will_delete FROM trades
|
||||
WHERE entry_order_id IS NULL OR entry_order_id = 0;
|
||||
|
||||
-- 3) 确认无误后执行删除(建议先备份:mysqldump -u user -p db_name trades > trades_backup.sql)
|
||||
-- DELETE FROM trades
|
||||
-- WHERE entry_order_id IS NULL OR entry_order_id = 0;
|
||||
|
||||
-- 若只清理指定账号,可加上条件,例如:
|
||||
-- DELETE FROM trades
|
||||
-- WHERE (entry_order_id IS NULL OR entry_order_id = 0) AND account_id = 1;
|
||||
|
|
@ -6,6 +6,7 @@ from contextlib import contextmanager
|
|||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine, pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -41,46 +42,103 @@ except Exception as e:
|
|||
|
||||
|
||||
class Database:
|
||||
"""数据库连接类"""
|
||||
"""数据库连接类(使用SQLAlchemy连接池)"""
|
||||
|
||||
_engine = None
|
||||
|
||||
def __init__(self):
|
||||
self.host = os.getenv('DB_HOST', 'localhost')
|
||||
self.port = int(os.getenv('DB_PORT', 3306))
|
||||
self.user = os.getenv('DB_USER', 'root')
|
||||
self.password = os.getenv('DB_PASSWORD', '')
|
||||
self.database = os.getenv('DB_NAME', 'auto_trade_sys')
|
||||
self.database = os.getenv('DB_NAME', 'auto_trade_sys_new')
|
||||
|
||||
# 记录配置信息(不显示密码)
|
||||
logger.debug(f"数据库配置: host={self.host}, port={self.port}, user={self.user}, database={self.database}")
|
||||
|
||||
# 初始化连接池
|
||||
self._init_engine()
|
||||
|
||||
def _init_engine(self):
|
||||
"""初始化SQLAlchemy引擎和连接池"""
|
||||
if Database._engine is None:
|
||||
# 构建数据库URL
|
||||
# 注意:密码中如果有特殊字符需要转义,这里简单处理
|
||||
from urllib.parse import quote_plus
|
||||
encoded_password = quote_plus(self.password)
|
||||
db_url = f"mysql+pymysql://{self.user}:{encoded_password}@{self.host}:{self.port}/{self.database}?charset=utf8mb4"
|
||||
|
||||
try:
|
||||
Database._engine = create_engine(
|
||||
db_url,
|
||||
pool_size=20, # 基础连接池大小
|
||||
max_overflow=30, # 最大溢出连接数
|
||||
pool_recycle=3600, # 连接回收时间(秒)
|
||||
pool_timeout=30, # 获取连接超时时间(秒)
|
||||
pool_pre_ping=True, # 预检测连接是否可用
|
||||
connect_args={
|
||||
# 'cursorclass': pymysql.cursors.DictCursor, # Removed to prevent KeyError: 0 in SQLAlchemy init
|
||||
'autocommit': False
|
||||
}
|
||||
)
|
||||
logger.info("数据库连接池初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接池初始化失败: {e}")
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def get_connection(self):
|
||||
"""获取数据库连接(上下文管理器)"""
|
||||
"""获取数据库连接(从连接池)"""
|
||||
conn = None
|
||||
try:
|
||||
conn = pymysql.connect(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
database=self.database,
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=False
|
||||
)
|
||||
# 获取原始pymysql连接
|
||||
conn = Database._engine.raw_connection()
|
||||
|
||||
# Explicitly set cursor class to DictCursor since we removed it from create_engine
|
||||
# We need to set it on the underlying DBAPI connection
|
||||
try:
|
||||
if hasattr(conn, 'driver_connection'):
|
||||
# SQLAlchemy 2.0+
|
||||
conn.driver_connection.cursorclass = pymysql.cursors.DictCursor
|
||||
elif hasattr(conn, 'connection'):
|
||||
# Older SQLAlchemy
|
||||
conn.connection.cursorclass = pymysql.cursors.DictCursor
|
||||
else:
|
||||
# Fallback
|
||||
conn.cursorclass = pymysql.cursors.DictCursor
|
||||
except Exception as e:
|
||||
logger.warning(f"设置DictCursor失败: {e}")
|
||||
|
||||
# 设置时区为北京时间(UTC+8)
|
||||
# 注意:raw_connection可能不自动应用connect_args中的autocommit,需确认
|
||||
# SQLAlchemy的raw_connection通常返回DBAPI连接,autocommit行为取决于驱动
|
||||
# 这里显式关闭autocommit以保持兼容性
|
||||
try:
|
||||
conn.autocommit(False)
|
||||
except AttributeError:
|
||||
# 某些旧版本pymysql或wrapper可能不支持方法调用,尝试属性赋值
|
||||
pass
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SET time_zone = '+08:00'")
|
||||
# 注意:不在这里commit,除非是只读操作。调用者负责commit/rollback
|
||||
# 但原代码在yield前commit了时区设置?
|
||||
# 原代码:cursor.execute(...); conn.commit(); yield conn
|
||||
# SET time_zone 不需要 commit,但为了保险起见保留原行为
|
||||
conn.commit()
|
||||
|
||||
yield conn
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
try:
|
||||
conn.rollback()
|
||||
except:
|
||||
pass
|
||||
logger.error(f"数据库连接错误: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
conn.close() # 归还给连接池
|
||||
|
||||
def execute_query(self, query, params=None):
|
||||
"""执行查询,返回所有结果"""
|
||||
|
|
|
|||
57
backend/database/dedupe_trades_by_entry_order.sql
Normal file
57
backend/database/dedupe_trades_by_entry_order.sql
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
-- 按 entry_order_id + symbol 去重:同一开仓订单只保留一条(保留 id 最小的,即最早创建的)
|
||||
-- 使用前请先备份 trades 表;建议先执行「1. 查看重复」确认后再执行「2. 删除重复」
|
||||
-- 说明:仅处理 entry_order_id 非空的重复;无开仓订单号的重复记录(如 sync_recovered 脏数据)需人工按 symbol/时间判断后删除。
|
||||
|
||||
-- ========== 1. 查看重复(只读,不写库)==========
|
||||
-- 列出所有 (entry_order_id, symbol) 出现多于一次的组,以及每组中的记录
|
||||
SELECT
|
||||
t.entry_order_id,
|
||||
t.symbol,
|
||||
COUNT(*) AS cnt,
|
||||
GROUP_CONCAT(t.id ORDER BY t.id) AS ids,
|
||||
GROUP_CONCAT(CONCAT(t.id, '(', t.status, ',entry=', FROM_UNIXTIME(t.entry_time), ',exit=', IFNULL(FROM_UNIXTIME(t.exit_time), 'NULL'), ')') ORDER BY t.id SEPARATOR ' | ') AS detail
|
||||
FROM trades t
|
||||
WHERE t.entry_order_id IS NOT NULL
|
||||
GROUP BY t.entry_order_id, t.symbol
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 若有多账号,按 account_id 也分组查看(可选):
|
||||
-- SELECT account_id, entry_order_id, symbol, COUNT(*) AS cnt, GROUP_CONCAT(id ORDER BY id) AS ids
|
||||
-- FROM trades WHERE entry_order_id IS NOT NULL
|
||||
-- GROUP BY account_id, entry_order_id, symbol HAVING COUNT(*) > 1;
|
||||
|
||||
|
||||
-- ========== 2. 删除重复(保留每组 id 最小的那条,删除同组其余行)==========
|
||||
-- 执行前请确认上面查询结果符合预期;建议先备份: CREATE TABLE trades_backup_YYYYMMDD AS SELECT * FROM trades;
|
||||
|
||||
DELETE t
|
||||
FROM trades t
|
||||
INNER JOIN (
|
||||
SELECT entry_order_id, symbol, MIN(id) AS keep_id
|
||||
FROM trades
|
||||
WHERE entry_order_id IS NOT NULL
|
||||
GROUP BY entry_order_id, symbol
|
||||
HAVING COUNT(*) > 1
|
||||
) g ON t.entry_order_id = g.entry_order_id AND t.symbol = g.symbol AND t.id <> g.keep_id;
|
||||
|
||||
-- 若有多账号,按 account_id 去重(取消下面注释并注释掉上面的 DELETE):
|
||||
/*
|
||||
DELETE t
|
||||
FROM trades t
|
||||
INNER JOIN (
|
||||
SELECT account_id, entry_order_id, symbol, MIN(id) AS keep_id
|
||||
FROM trades
|
||||
WHERE entry_order_id IS NOT NULL
|
||||
GROUP BY account_id, entry_order_id, symbol
|
||||
HAVING COUNT(*) > 1
|
||||
) g ON t.account_id = g.account_id AND t.entry_order_id = g.entry_order_id AND t.symbol = g.symbol AND t.id <> g.keep_id;
|
||||
*/
|
||||
|
||||
|
||||
-- ========== 3. 再次检查(应无重复)==========
|
||||
SELECT entry_order_id, symbol, COUNT(*) AS cnt
|
||||
FROM trades
|
||||
WHERE entry_order_id IS NOT NULL
|
||||
GROUP BY entry_order_id, symbol
|
||||
HAVING COUNT(*) > 1;
|
||||
-- 期望结果:空
|
||||
|
|
@ -4,22 +4,69 @@ CREATE DATABASE IF NOT EXISTS `auto_trade_sys` DEFAULT CHARACTER SET utf8mb4 COL
|
|||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
-- 用户表(登录用户:管理员/普通用户)
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`username` VARCHAR(64) NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL DEFAULT 'user' COMMENT 'admin, user',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active, disabled',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录用户';
|
||||
|
||||
-- 用户-交易账号授权关系
|
||||
CREATE TABLE IF NOT EXISTS `user_account_memberships` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`account_id` INT NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL DEFAULT 'viewer' COMMENT 'owner, viewer',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_user_account` (`user_id`, `account_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_account_id` (`account_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户-交易账号授权';
|
||||
|
||||
-- 账号表(多账号)
|
||||
CREATE TABLE IF NOT EXISTS `accounts` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`status` VARCHAR(20) DEFAULT 'active' COMMENT 'active, disabled',
|
||||
`api_key_enc` TEXT NULL COMMENT '加密后的 API KEY(enc:v1:...)',
|
||||
`api_secret_enc` TEXT NULL COMMENT '加密后的 API SECRET(enc:v1:...)',
|
||||
`use_testnet` BOOLEAN DEFAULT FALSE,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号表(多账号)';
|
||||
|
||||
-- 默认账号(兼容单账号)
|
||||
INSERT INTO `accounts` (`id`, `name`, `status`, `use_testnet`)
|
||||
VALUES (1, 'default', 'active', false)
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- 配置表
|
||||
CREATE TABLE IF NOT EXISTS `trading_config` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`config_key` VARCHAR(100) UNIQUE NOT NULL,
|
||||
`account_id` INT NOT NULL DEFAULT 1,
|
||||
`config_key` VARCHAR(100) NOT NULL,
|
||||
`config_value` TEXT NOT NULL,
|
||||
`config_type` VARCHAR(50) NOT NULL COMMENT 'string, number, boolean, json',
|
||||
`category` VARCHAR(50) NOT NULL COMMENT 'position, risk, scan, strategy, api',
|
||||
`description` TEXT,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(50),
|
||||
INDEX `idx_category` (`category`)
|
||||
INDEX `idx_category` (`category`),
|
||||
INDEX `idx_account_id` (`account_id`),
|
||||
UNIQUE KEY `uk_account_config_key` (`account_id`, `config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易配置表';
|
||||
|
||||
-- 注意:多账号需要 (account_id, config_key) 唯一。旧库升级请跑迁移脚本(见 add_multi_account.sql)。
|
||||
|
||||
-- 交易记录表
|
||||
CREATE TABLE IF NOT EXISTS `trades` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`account_id` INT NOT NULL DEFAULT 1,
|
||||
`symbol` VARCHAR(20) NOT NULL,
|
||||
`side` VARCHAR(10) NOT NULL COMMENT 'BUY, SELL',
|
||||
`quantity` DECIMAL(20, 8) NOT NULL,
|
||||
|
|
@ -45,6 +92,7 @@ CREATE TABLE IF NOT EXISTS `trades` (
|
|||
`take_profit_2` DECIMAL(20, 8) NULL COMMENT '第二目标止盈价(用于展示与分步止盈)',
|
||||
`status` VARCHAR(20) DEFAULT 'open' COMMENT 'open, closed, cancelled',
|
||||
`created_at` INT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()) COMMENT '创建时间(Unix时间戳秒数)',
|
||||
INDEX `idx_account_id` (`account_id`),
|
||||
INDEX `idx_symbol` (`symbol`),
|
||||
INDEX `idx_entry_time` (`entry_time`),
|
||||
INDEX `idx_status` (`status`),
|
||||
|
|
@ -57,12 +105,14 @@ CREATE TABLE IF NOT EXISTS `trades` (
|
|||
-- 账户快照表
|
||||
CREATE TABLE IF NOT EXISTS `account_snapshots` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`account_id` INT NOT NULL DEFAULT 1,
|
||||
`total_balance` DECIMAL(20, 8) NOT NULL,
|
||||
`available_balance` DECIMAL(20, 8) NOT NULL,
|
||||
`total_position_value` DECIMAL(20, 8) DEFAULT 0,
|
||||
`total_pnl` DECIMAL(20, 8) DEFAULT 0,
|
||||
`open_positions` INT DEFAULT 0,
|
||||
`snapshot_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX `idx_account_id` (`account_id`),
|
||||
INDEX `idx_snapshot_time` (`snapshot_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户快照表';
|
||||
|
||||
|
|
@ -158,11 +208,11 @@ INSERT INTO `trading_config` (`config_key`, `config_value`, `config_type`, `cate
|
|||
('STOP_LOSS_PERCENT', '0.10', 'number', 'risk', '止损:10%(相对于保证金)'),
|
||||
('TAKE_PROFIT_PERCENT', '0.30', 'number', 'risk', '止盈:30%(相对于保证金,盈亏比3:1)'),
|
||||
('MIN_STOP_LOSS_PRICE_PCT', '0.02', 'number', 'risk', '最小止损价格变动:2%(防止止损过紧)'),
|
||||
('MIN_TAKE_PROFIT_PRICE_PCT', '0.03', 'number', 'risk', '最小止盈价格变动:3%(防止止盈过紧)'),
|
||||
('MIN_TAKE_PROFIT_PRICE_PCT', '0.02', 'number', 'risk', '最小止盈价格变动:2%(防止ATR过小时计算出不切实际的微小止盈距离)'),
|
||||
('USE_ATR_STOP_LOSS', 'true', 'boolean', 'risk', '是否使用ATR动态止损(优先于固定百分比)'),
|
||||
('ATR_STOP_LOSS_MULTIPLIER', '1.8', 'number', 'risk', 'ATR止损倍数(1.5-2倍ATR,默认1.8)'),
|
||||
('ATR_TAKE_PROFIT_MULTIPLIER', '3.0', 'number', 'risk', 'ATR止盈倍数(3倍ATR,对应3:1盈亏比)'),
|
||||
('RISK_REWARD_RATIO', '3.0', 'number', 'risk', '盈亏比(止损距离的倍数,用于计算止盈)'),
|
||||
('ATR_TAKE_PROFIT_MULTIPLIER', '1.5', 'number', 'risk', 'ATR止盈倍数(从4.5降至1.5,将盈亏比从3:1降至更现实、可达成的1.5:1,提升止盈触发率)'),
|
||||
('RISK_REWARD_RATIO', '1.5', 'number', 'risk', '盈亏比(止损距离的倍数,用于计算止盈,从3.0降至1.5,更容易达成)'),
|
||||
('ATR_PERIOD', '14', 'number', 'risk', 'ATR计算周期(默认14)'),
|
||||
('USE_DYNAMIC_ATR_MULTIPLIER', 'false', 'boolean', 'risk', '是否根据波动率动态调整ATR倍数'),
|
||||
('ATR_MULTIPLIER_MIN', '1.5', 'number', 'risk', '动态ATR倍数最小值'),
|
||||
|
|
@ -184,6 +234,8 @@ INSERT INTO `trading_config` (`config_key`, `config_value`, `config_type`, `cate
|
|||
('LEVERAGE', '10', 'number', 'strategy', '基础杠杆倍数'),
|
||||
('USE_DYNAMIC_LEVERAGE', 'true', 'boolean', 'strategy', '是否启用动态杠杆(根据信号强度调整杠杆倍数)'),
|
||||
('MAX_LEVERAGE', '15', 'number', 'strategy', '最大杠杆倍数(动态杠杆上限,降低到15更保守)'),
|
||||
('PROFIT_PROTECTION_ENABLED', 'true', 'boolean', 'strategy', '盈利保护总开关:启用保本+移动止损'),
|
||||
('LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT', '0.03', 'number', 'strategy', '盈利达保证金比例时移至保本(0.03=3%,0=关闭)'),
|
||||
('USE_TRAILING_STOP', 'true', 'boolean', 'strategy', '是否使用移动止损'),
|
||||
('TRAILING_STOP_ACTIVATION', '0.10', 'number', 'strategy', '移动止损激活阈值(盈利10%后激活,给趋势更多空间)'),
|
||||
('TRAILING_STOP_PROTECT', '0.05', 'number', 'strategy', '移动止损保护利润(保护5%利润,更合理)'),
|
||||
|
|
@ -195,6 +247,12 @@ INSERT INTO `trading_config` (`config_key`, `config_value`, `config_type`, `cate
|
|||
-- API配置
|
||||
('BINANCE_API_KEY', '', 'string', 'api', '币安API密钥'),
|
||||
('BINANCE_API_SECRET', '', 'string', 'api', '币安API密钥'),
|
||||
('USE_TESTNET', 'false', 'boolean', 'api', '是否使用测试网')
|
||||
('USE_TESTNET', 'false', 'boolean', 'api', '是否使用测试网'),
|
||||
|
||||
-- 与盈利期对齐(2026-02-15)
|
||||
('RSI_EXTREME_REVERSE_ENABLED', 'false', 'boolean', 'strategy', '关闭RSI极限反转,与盈利期一致'),
|
||||
('RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H', 'true', 'boolean', 'strategy', '若开启反向仅允许4H中性'),
|
||||
('USE_MARGIN_CAP_FOR_TP', 'true', 'boolean', 'risk', '止盈按保证金封顶,避免过远'),
|
||||
('USE_MARGIN_CAP_FOR_SL', 'true', 'boolean', 'risk', '止损按保证金封顶,避免扛单')
|
||||
|
||||
ON DUPLICATE KEY UPDATE `config_value` = VALUES(`config_value`);
|
||||
|
|
|
|||
130
backend/database/migrate_percent_configs_to_ratio.sql
Normal file
130
backend/database/migrate_percent_configs_to_ratio.sql
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
-- ============================================================
|
||||
-- 配置值格式统一迁移脚本
|
||||
-- 将百分比形式(>1)转换为比例形式(除以100)
|
||||
-- 执行时间:2026-01-26
|
||||
-- ============================================================
|
||||
|
||||
-- 说明:
|
||||
-- 此脚本将数据库中的百分比配置项从百分比形式(如30表示30%)
|
||||
-- 转换为比例形式(如0.30表示30%),以统一数据格式。
|
||||
|
||||
-- ⚠️ 重要:执行前请备份数据库!
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 备份表(强烈推荐)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS trading_config_backup_20260126 AS
|
||||
SELECT * FROM trading_config;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_strategy_config_backup_20260126 AS
|
||||
SELECT * FROM global_strategy_config;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 迁移 trading_config 表
|
||||
-- ============================================================
|
||||
UPDATE trading_config
|
||||
SET config_value = CAST(config_value AS DECIMAL(10, 4)) / 100.0
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 迁移 global_strategy_config 表
|
||||
-- ============================================================
|
||||
UPDATE global_strategy_config
|
||||
SET config_value = CAST(config_value AS DECIMAL(10, 4)) / 100.0
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 验证迁移结果
|
||||
-- ============================================================
|
||||
-- 检查是否还有>1的百分比配置项(应该返回0行)
|
||||
SELECT 'trading_config' as table_name, config_key, config_value, account_id
|
||||
FROM trading_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1
|
||||
UNION ALL
|
||||
SELECT 'global_strategy_config' as table_name, config_key, config_value, NULL as account_id
|
||||
FROM global_strategy_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 查看迁移结果(可选)
|
||||
-- ============================================================
|
||||
-- 查看迁移后的配置值
|
||||
SELECT config_key, config_value, account_id
|
||||
FROM trading_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
ORDER BY config_key, account_id;
|
||||
|
||||
SELECT config_key, config_value
|
||||
FROM global_strategy_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
ORDER BY config_key;
|
||||
File diff suppressed because it is too large
Load Diff
15
backend/database/update_config_profitable_alignment.sql
Normal file
15
backend/database/update_config_profitable_alignment.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- 与盈利期对齐:RSI 关闭反向 + 止盈/止损封顶(2026-02-15)
|
||||
-- 【重要】只更新【全局配置】表 global_strategy_config(无 account_id,策略只读此表)。
|
||||
-- 不修改 trading_config(个人/账号配置);个人用不到,请用本脚本或前端「全局配置」页,不要改个人配置。
|
||||
|
||||
INSERT INTO `global_strategy_config` (`config_key`, `config_value`, `config_type`, `category`, `description`) VALUES
|
||||
('RSI_EXTREME_REVERSE_ENABLED', 'false', 'boolean', 'strategy', '关闭RSI极限反转,与盈利期一致'),
|
||||
('RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H', 'true', 'boolean', 'strategy', '若开启反向仅允许4H中性'),
|
||||
('USE_MARGIN_CAP_FOR_TP', 'true', 'boolean', 'risk', '止盈按保证金封顶,避免过远'),
|
||||
('USE_MARGIN_CAP_FOR_SL', 'true', 'boolean', 'risk', '止损按保证金封顶,避免扛单')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`config_value` = VALUES(`config_value`),
|
||||
`config_type` = VALUES(`config_type`),
|
||||
`category` = VALUES(`category`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = CURRENT_TIMESTAMP;
|
||||
341
backend/market_overview.py
Normal file
341
backend/market_overview.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"""
|
||||
市场行情概览 - 用于全局配置页展示
|
||||
拉取 Binance 公开接口(无需 API Key),与策略过滤逻辑对应的数据。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINANCE_FUTURES_BASE = "https://fapi.binance.com"
|
||||
BINANCE_FUTURES_DATA = "https://fapi.binance.com/futures/data"
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
|
||||
def _http_get(url: str, params: Optional[dict] = None) -> Optional[Any]:
|
||||
"""发起 GET 请求,返回 JSON 或 None。"""
|
||||
if params:
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
url = f"{url}?{qs}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
ctx = ssl.create_default_context()
|
||||
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
logger.debug("market_overview HTTP GET 失败 %s: %s", url[:80], e)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_klines(symbol: str, interval: str, limit: int) -> Optional[list]:
|
||||
"""获取 K 线数据。"""
|
||||
data = _http_get(
|
||||
f"{BINANCE_FUTURES_BASE}/fapi/v1/klines",
|
||||
{"symbol": symbol, "interval": interval, "limit": limit},
|
||||
)
|
||||
return data if isinstance(data, list) else None
|
||||
|
||||
|
||||
def _compute_change_from_klines(klines: list, periods: int) -> Optional[float]:
|
||||
"""根据 K 线计算最近 N 根的总涨跌幅(比例,如 -0.0167 表示 -1.67%)。"""
|
||||
if not klines or len(klines) < periods + 1:
|
||||
return None
|
||||
first_close = float(klines[0][4])
|
||||
last_close = float(klines[-1][4])
|
||||
return (last_close - first_close) / first_close if first_close else None
|
||||
|
||||
|
||||
def fetch_symbol_change_period(symbol: str, interval: str, periods: int) -> Optional[float]:
|
||||
"""获取指定交易对在指定周期内的涨跌幅(比例)。"""
|
||||
klines = _fetch_klines(symbol, interval, periods + 1)
|
||||
return _compute_change_from_klines(klines, periods) if klines else None
|
||||
|
||||
|
||||
def fetch_ticker_24h(symbol: str) -> Optional[Dict]:
|
||||
"""获取 24h ticker。"""
|
||||
data = _http_get(f"{BINANCE_FUTURES_BASE}/fapi/v1/ticker/24hr", {"symbol": symbol})
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def fetch_premium_index(symbol: str) -> Optional[Dict]:
|
||||
"""获取资金费率等。"""
|
||||
data = _http_get(f"{BINANCE_FUTURES_BASE}/fapi/v1/premiumIndex", {"symbol": symbol})
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def fetch_long_short_ratio(symbol: str = "BTCUSDT", period: str = "1d", limit: int = 1) -> Optional[float]:
|
||||
"""获取大户多空比。"""
|
||||
data = _http_get(
|
||||
f"{BINANCE_FUTURES_DATA}/topLongShortPositionRatio",
|
||||
{"symbol": symbol, "period": period, "limit": limit},
|
||||
)
|
||||
if not isinstance(data, list) or len(data) == 0:
|
||||
return None
|
||||
try:
|
||||
return float(data[-1].get("longShortRatio", 1))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def get_market_overview() -> Dict[str, Any]:
|
||||
"""
|
||||
获取市场行情概览,与策略过滤逻辑对应的数据。
|
||||
供全局配置页展示,帮助用户确认当前策略方案是否匹配市场。
|
||||
"""
|
||||
result = {
|
||||
"btc_24h_change_pct": None,
|
||||
"eth_24h_change_pct": None,
|
||||
"btc_15m_change_pct": None,
|
||||
"btc_1h_change_pct": None,
|
||||
"eth_15m_change_pct": None,
|
||||
"eth_1h_change_pct": None,
|
||||
"btc_funding_rate": None,
|
||||
"eth_funding_rate": None,
|
||||
"btc_long_short_ratio": None,
|
||||
"btc_trend_4h": None,
|
||||
"market_regime": None,
|
||||
"beta_filter_triggered": None,
|
||||
}
|
||||
|
||||
# 24h 涨跌幅
|
||||
btc_ticker = fetch_ticker_24h("BTCUSDT")
|
||||
if btc_ticker is not None:
|
||||
try:
|
||||
result["btc_24h_change_pct"] = round(float(btc_ticker.get("priceChangePercent", 0)), 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
eth_ticker = fetch_ticker_24h("ETHUSDT")
|
||||
if eth_ticker is not None:
|
||||
try:
|
||||
result["eth_24h_change_pct"] = round(float(eth_ticker.get("priceChangePercent", 0)), 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 15m / 1h 涨跌幅(大盘共振过滤用)
|
||||
btc_15m = fetch_symbol_change_period("BTCUSDT", "15m", 5)
|
||||
btc_1h = fetch_symbol_change_period("BTCUSDT", "1h", 3)
|
||||
eth_15m = fetch_symbol_change_period("ETHUSDT", "15m", 5)
|
||||
eth_1h = fetch_symbol_change_period("ETHUSDT", "1h", 3)
|
||||
if btc_15m is not None:
|
||||
result["btc_15m_change_pct"] = round(btc_15m * 100, 2)
|
||||
if btc_1h is not None:
|
||||
result["btc_1h_change_pct"] = round(btc_1h * 100, 2)
|
||||
if eth_15m is not None:
|
||||
result["eth_15m_change_pct"] = round(eth_15m * 100, 2)
|
||||
if eth_1h is not None:
|
||||
result["eth_1h_change_pct"] = round(eth_1h * 100, 2)
|
||||
|
||||
# 资金费率
|
||||
btc_prem = fetch_premium_index("BTCUSDT")
|
||||
if btc_prem is not None:
|
||||
try:
|
||||
result["btc_funding_rate"] = round(float(btc_prem.get("lastFundingRate", 0)), 6)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
eth_prem = fetch_premium_index("ETHUSDT")
|
||||
if eth_prem is not None:
|
||||
try:
|
||||
result["eth_funding_rate"] = round(float(eth_prem.get("lastFundingRate", 0)), 6)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 大户多空比
|
||||
lsr = fetch_long_short_ratio("BTCUSDT", "1d", 1)
|
||||
if lsr is not None:
|
||||
result["btc_long_short_ratio"] = round(lsr, 4)
|
||||
|
||||
# 4H 趋势
|
||||
klines_4h = _fetch_klines("BTCUSDT", "4h", 60)
|
||||
if klines_4h and len(klines_4h) >= 21:
|
||||
try:
|
||||
from trading_system.market_regime_detector import compute_trend_4h_from_klines
|
||||
result["btc_trend_4h"] = compute_trend_4h_from_klines(klines_4h)
|
||||
except Exception:
|
||||
result["btc_trend_4h"] = _simple_trend_4h(klines_4h)
|
||||
|
||||
# 市场状态(bull/bear/normal)
|
||||
try:
|
||||
from trading_system.market_regime_detector import detect_market_regime
|
||||
regime, details = detect_market_regime()
|
||||
result["market_regime"] = regime
|
||||
result["market_regime_details"] = details
|
||||
except Exception as e:
|
||||
logger.debug("market_overview 获取市场状态失败: %s", e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _simple_trend_4h(klines: list) -> str:
|
||||
"""简化 4H 趋势:价格 vs 最近一根 K 线前 20 根均价。"""
|
||||
if len(klines) < 21:
|
||||
return "neutral"
|
||||
closes = [float(k[4]) for k in klines]
|
||||
price = closes[-1]
|
||||
avg20 = sum(closes[-21:-1]) / 20
|
||||
if price > avg20 * 1.002:
|
||||
return "up"
|
||||
if price < avg20 * 0.998:
|
||||
return "down"
|
||||
return "neutral"
|
||||
|
||||
|
||||
def _g(key: str, default: Any, cfg: dict) -> Any:
|
||||
"""从配置字典取键,支持 bool/数字/字符串。"""
|
||||
v = cfg.get(key, default)
|
||||
if v is None:
|
||||
return default
|
||||
if isinstance(default, bool):
|
||||
return str(v).lower() in ("true", "1", "yes")
|
||||
return v
|
||||
|
||||
|
||||
def get_strategy_execution_overview() -> Dict[str, Any]:
|
||||
"""
|
||||
生成「策略执行概览」:当前执行方案、配置项执行情况,用易读文字描述整体策略执行标准与机制。
|
||||
供全局配置页「策略执行概览」展示。
|
||||
返回格式:{ "sections": [ { "title": "小节标题", "content": "正文" } ] }
|
||||
"""
|
||||
sections = []
|
||||
cfg = {}
|
||||
try:
|
||||
from config_manager import GlobalStrategyConfigManager
|
||||
mgr = GlobalStrategyConfigManager()
|
||||
mgr.reload_from_redis()
|
||||
for key in (
|
||||
"AUTO_TRADE_ENABLED", "AUTO_TRADE_ONLY_TRENDING", "AUTO_TRADE_ALLOW_4H_NEUTRAL",
|
||||
"MIN_SIGNAL_STRENGTH", "LOW_VOLATILITY_MIN_SIGNAL_STRENGTH", "MARKET_REGIME_AUTO",
|
||||
"TOP_N_SYMBOLS", "SCAN_INTERVAL", "PRIMARY_INTERVAL", "CONFIRM_INTERVAL",
|
||||
"MAX_OPEN_POSITIONS", "MAX_DAILY_ENTRIES", "FIXED_RISK_PERCENT", "USE_FIXED_RISK_SIZING",
|
||||
"BETA_FILTER_ENABLED", "BETA_FILTER_THRESHOLD", "MARKET_SCHEME",
|
||||
"USE_ATR_STOP_LOSS", "ATR_STOP_LOSS_MULTIPLIER", "STOP_LOSS_PERCENT",
|
||||
"TAKE_PROFIT_1_PERCENT", "TAKE_PROFIT_PERCENT", "USE_TRAILING_STOP",
|
||||
"TRAILING_STOP_ACTIVATION", "TRAILING_STOP_PROTECT", "PROFIT_PROTECTION_ENABLED",
|
||||
"SMART_ENTRY_ENABLED", "USE_TREND_ENTRY_FILTER", "MAX_TREND_MOVE_BEFORE_ENTRY",
|
||||
"MAX_RSI_FOR_LONG", "MIN_RSI_FOR_SHORT", "MAX_CHANGE_PERCENT_FOR_LONG", "MAX_CHANGE_PERCENT_FOR_SHORT",
|
||||
"MIN_VOLUME_24H", "MIN_VOLATILITY", "MIN_HOLD_TIME_SEC",
|
||||
):
|
||||
cfg[key] = mgr.get(key)
|
||||
except Exception as e:
|
||||
logger.debug("get_strategy_execution_overview 加载配置失败: %s", e)
|
||||
|
||||
def pct(x):
|
||||
if x is None:
|
||||
return "—"
|
||||
try:
|
||||
f = float(x)
|
||||
if abs(f) < 1 and abs(f) > 0:
|
||||
return f"{f * 100:.2f}%"
|
||||
return f"{f}%"
|
||||
except (TypeError, ValueError):
|
||||
return str(x)
|
||||
|
||||
# ---------- 1. 总开关与自动交易条件 ----------
|
||||
auto_on = _g("AUTO_TRADE_ENABLED", True, cfg)
|
||||
only_trending = _g("AUTO_TRADE_ONLY_TRENDING", True, cfg)
|
||||
allow_4h_neutral = _g("AUTO_TRADE_ALLOW_4H_NEUTRAL", False, cfg)
|
||||
min_strength = _g("MIN_SIGNAL_STRENGTH", 8, cfg)
|
||||
low_vol_strength = _g("LOW_VOLATILITY_MIN_SIGNAL_STRENGTH", 9, cfg)
|
||||
regime_auto = _g("MARKET_REGIME_AUTO", True, cfg)
|
||||
|
||||
c1 = []
|
||||
c1.append("自动交易总开关:" + ("开启" if auto_on else "关闭"))
|
||||
if not auto_on:
|
||||
c1.append("关闭时仅生成推荐,不会自动下单。")
|
||||
else:
|
||||
c1.append("自动下单条件(需同时满足):")
|
||||
c1.append("• 信号强度 ≥ " + str(min_strength) + "(技术指标综合评分);低波动期自动提高至 " + str(low_vol_strength) + "(" + ("已开启" if regime_auto else "未开启") + "市场节奏识别)。")
|
||||
c1.append("• 市场状态:仅当「仅做趋势市」开启时,要求市场状态为 trending 才下单;ranging/unknown 只生成推荐、不自动下单。当前「仅做趋势市」=" + ("是" if only_trending else "否") + "。")
|
||||
c1.append("• 4H 趋势:允许 4H 中性时自动交易 = " + ("是" if allow_4h_neutral else "否") + ";为否时 4H 为中性会跳过自动下单。")
|
||||
sections.append({
|
||||
"title": "一、总开关与自动交易条件",
|
||||
"content": "\n".join(c1),
|
||||
})
|
||||
|
||||
# ---------- 2. 扫描与候选池 ----------
|
||||
top_n = _g("TOP_N_SYMBOLS", 30, cfg)
|
||||
scan_interval = _g("SCAN_INTERVAL", 900, cfg)
|
||||
primary = _g("PRIMARY_INTERVAL", "4h", cfg)
|
||||
confirm = _g("CONFIRM_INTERVAL", "1d", cfg)
|
||||
min_vol = _g("MIN_VOLUME_24H", 30000000, cfg)
|
||||
min_vol_str = f"{min_vol / 1e6:.0f} 万 USDT" if isinstance(min_vol, (int, float)) and min_vol >= 1e6 else str(min_vol)
|
||||
vol_pct = _g("MIN_VOLATILITY", 0.03, cfg)
|
||||
vol_pct_str = f"{float(vol_pct) * 100:.1f}%" if isinstance(vol_pct, (int, float)) else str(vol_pct)
|
||||
|
||||
c2 = []
|
||||
c2.append("每次扫描取涨跌幅最大的前 " + str(top_n) + " 个交易对进行详细分析;扫描间隔 " + str(scan_interval) + " 秒。")
|
||||
c2.append("主周期 " + str(primary) + ",确认周期 " + str(confirm) + ";24h 成交额 ≥ " + min_vol_str + ",最小波动率 " + vol_pct_str + "。")
|
||||
sections.append({
|
||||
"title": "二、扫描与候选池",
|
||||
"content": "\n".join(c2),
|
||||
})
|
||||
|
||||
# ---------- 3. 仓位与风控 ----------
|
||||
max_pos = _g("MAX_OPEN_POSITIONS", 4, cfg)
|
||||
max_daily = _g("MAX_DAILY_ENTRIES", 15, cfg)
|
||||
fixed_risk = _g("USE_FIXED_RISK_SIZING", True, cfg)
|
||||
risk_pct = _g("FIXED_RISK_PERCENT", 0.01, cfg)
|
||||
risk_pct_str = pct(risk_pct) if isinstance(risk_pct, (int, float)) and risk_pct <= 1 else f"{float(risk_pct)}%"
|
||||
|
||||
c3 = []
|
||||
c3.append("同时持仓上限 " + str(max_pos) + " 个,每日最多开仓 " + str(max_daily) + " 笔。")
|
||||
c3.append("固定风险 sizing:" + ("开启" if fixed_risk else "关闭") + ";每笔最大亏损 " + risk_pct_str + " 账户资金。")
|
||||
sections.append({
|
||||
"title": "三、仓位与风控",
|
||||
"content": "\n".join(c3),
|
||||
})
|
||||
|
||||
# ---------- 4. 大盘与市场方案 ----------
|
||||
beta_on = _g("BETA_FILTER_ENABLED", True, cfg)
|
||||
beta_th = _g("BETA_FILTER_THRESHOLD", -0.005, cfg)
|
||||
scheme = str(_g("MARKET_SCHEME", "normal", cfg) or "normal")
|
||||
|
||||
c4 = []
|
||||
c4.append("大盘共振过滤:" + ("开启" if beta_on else "关闭") + ";BTC/ETH 短周期跌逾 " + pct(beta_th) + " 时屏蔽多单。")
|
||||
c4.append("当前市场方案:" + scheme + "(用于参数预设)。")
|
||||
sections.append({
|
||||
"title": "四、大盘与市场方案",
|
||||
"content": "\n".join(c4),
|
||||
})
|
||||
|
||||
# ---------- 5. 止损止盈与保护 ----------
|
||||
use_atr = _g("USE_ATR_STOP_LOSS", True, cfg)
|
||||
atr_mult = _g("ATR_STOP_LOSS_MULTIPLIER", 2.0, cfg)
|
||||
sl_pct = _g("STOP_LOSS_PERCENT", 0.05, cfg)
|
||||
tp1 = _g("TAKE_PROFIT_1_PERCENT", 0.12, cfg)
|
||||
tp2 = _g("TAKE_PROFIT_PERCENT", 0.25, cfg)
|
||||
trail = _g("USE_TRAILING_STOP", True, cfg)
|
||||
trail_act = _g("TRAILING_STOP_ACTIVATION", 0.10, cfg)
|
||||
trail_prot = _g("TRAILING_STOP_PROTECT", 0.02, cfg)
|
||||
profit_prot = _g("PROFIT_PROTECTION_ENABLED", True, cfg)
|
||||
|
||||
c5 = []
|
||||
c5.append("止损:ATR 动态止损 " + ("开启" if use_atr else "关闭") + (",倍数 " + str(atr_mult) if use_atr else "") + ";固定止损 " + pct(sl_pct) + "。")
|
||||
c5.append("止盈:第一目标 " + pct(tp1) + ",第二目标 " + pct(tp2) + "。")
|
||||
c5.append("盈利保护总开关:" + ("开启" if profit_prot else "关闭") + ";移动止损 " + ("开启" if trail else "关闭") + (",盈利 " + pct(trail_act) + " 激活、保护 " + pct(trail_prot) + " 利润" if trail else "") + "。")
|
||||
sections.append({
|
||||
"title": "五、止损止盈与保护",
|
||||
"content": "\n".join(c5),
|
||||
})
|
||||
|
||||
# ---------- 6. 入场与过滤 ----------
|
||||
smart = _g("SMART_ENTRY_ENABLED", True, cfg)
|
||||
trend_filter = _g("USE_TREND_ENTRY_FILTER", True, cfg)
|
||||
max_trend = _g("MAX_TREND_MOVE_BEFORE_ENTRY", 0.04, cfg)
|
||||
max_rsi_long = _g("MAX_RSI_FOR_LONG", 65, cfg)
|
||||
min_rsi_short = _g("MIN_RSI_FOR_SHORT", 30, cfg)
|
||||
max_ch_long = _g("MAX_CHANGE_PERCENT_FOR_LONG", 25, cfg)
|
||||
max_ch_short = _g("MAX_CHANGE_PERCENT_FOR_SHORT", 10, cfg)
|
||||
|
||||
c6 = []
|
||||
c6.append("智能入场(限价+追价+市价兜底):" + ("开启" if smart else "关闭") + "。")
|
||||
c6.append("趋势入场过滤:" + ("开启" if trend_filter else "关闭") + ";信号方向已走超 " + pct(max_trend) + " 则不再入场。")
|
||||
c6.append("做多:RSI ≤ " + str(max_rsi_long) + ",24h 涨跌幅 ≤ " + str(max_ch_long) + "%。做空:RSI ≥ " + str(min_rsi_short) + ",24h 涨跌幅 ≤ " + str(max_ch_short) + "%。")
|
||||
sections.append({
|
||||
"title": "六、入场与过滤",
|
||||
"content": "\n".join(c6),
|
||||
})
|
||||
|
||||
return {"sections": sections}
|
||||
|
|
@ -24,3 +24,9 @@ aiohttp==3.9.1
|
|||
redis>=4.2.0
|
||||
# 保留aioredis作为备选(如果某些代码仍在使用aioredis接口)
|
||||
aioredis==2.0.1
|
||||
|
||||
# 安全:加密存储敏感字段(API KEY/SECRET)
|
||||
cryptography>=42.0.0
|
||||
|
||||
# 登录鉴权:JWT
|
||||
python-jose[cryptography]>=3.3.0
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@
|
|||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 查找运行中的uvicorn进程
|
||||
PID=$(ps aux | grep "uvicorn api.main:app" | grep -v grep | awk '{print $2}')
|
||||
# 查找运行中的uvicorn进程 (优先使用 lsof 查找端口占用)
|
||||
PID=$(lsof -t -i:8001)
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
# 回退到 ps 查找 (如果 lsof 没找到或不可用)
|
||||
PID=$(ps aux | grep "uvicorn api.main:app" | grep -v grep | awk '{print $2}')
|
||||
fi
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo "未找到运行中的后端服务"
|
||||
|
|
@ -16,8 +21,8 @@ else
|
|||
kill $PID
|
||||
sleep 2
|
||||
|
||||
# 检查是否成功停止
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
# 检查是否成功停止 (使用 kill -0 检查进程是否存在,替代 ps -p)
|
||||
if kill -0 $PID > /dev/null 2>&1; then
|
||||
echo "强制停止服务..."
|
||||
kill -9 $PID
|
||||
sleep 1
|
||||
|
|
|
|||
22
backend/restart_recommendations.sh
Executable file
22
backend/restart_recommendations.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/bash
|
||||
# 重启推荐服务
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 查找 recommendations_main 进程
|
||||
PID=$(ps aux | grep "trading_system.recommendations_main" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo "未找到运行中的推荐服务,直接启动..."
|
||||
./start_recommendations.sh
|
||||
else
|
||||
echo "找到推荐服务,PID: $PID,正在重启..."
|
||||
kill $PID 2>/dev/null || true
|
||||
sleep 2
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
kill -9 $PID 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
echo "正在启动新服务..."
|
||||
./start_recommendations.sh
|
||||
fi
|
||||
4
backend/security/__init__.py
Normal file
4
backend/security/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
安全相关工具(加密/解密等)
|
||||
"""
|
||||
|
||||
119
backend/security/crypto.py
Normal file
119
backend/security/crypto.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""
|
||||
对称加密工具(用于存储 API Key/Secret 等敏感字段)
|
||||
|
||||
说明:
|
||||
- 使用 AES-GCM(需要 cryptography 依赖)
|
||||
- master key 来自环境变量:
|
||||
- ATS_MASTER_KEY(推荐):32字节 key 的 base64(urlsafe) 或 hex
|
||||
- AUTO_TRADE_SYS_MASTER_KEY(兼容)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _load_master_key_bytes() -> Optional[bytes]:
|
||||
raw = (
|
||||
os.getenv("ATS_MASTER_KEY")
|
||||
or os.getenv("AUTO_TRADE_SYS_MASTER_KEY")
|
||||
or os.getenv("MASTER_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# 1) hex
|
||||
try:
|
||||
b = bytes.fromhex(raw)
|
||||
if len(b) == 32:
|
||||
return b
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) urlsafe base64
|
||||
try:
|
||||
padded = raw + ("=" * (-len(raw) % 4))
|
||||
b = base64.urlsafe_b64decode(padded.encode("utf-8"))
|
||||
if len(b) == 32:
|
||||
return b
|
||||
except binascii.Error:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _aesgcm():
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM # type: ignore
|
||||
|
||||
return AESGCM
|
||||
except Exception as e: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"缺少加密依赖 cryptography,无法安全存储敏感字段。请安装 cryptography 并设置 ATS_MASTER_KEY。"
|
||||
) from e
|
||||
|
||||
|
||||
def encrypt_str(plaintext: str) -> str:
|
||||
"""
|
||||
加密字符串,返回带版本前缀的密文:
|
||||
enc:v1:<b64(nonce)>:<b64(ciphertext)>
|
||||
"""
|
||||
if plaintext is None:
|
||||
plaintext = ""
|
||||
s = str(plaintext)
|
||||
if s == "":
|
||||
return ""
|
||||
|
||||
key = _load_master_key_bytes()
|
||||
if not key:
|
||||
# 允许降级:不加密直接存(避免线上因缺KEY彻底不可用),但强烈建议尽快配置 master key
|
||||
return s
|
||||
|
||||
import os as _os
|
||||
|
||||
AESGCM = _aesgcm()
|
||||
nonce = _os.urandom(12)
|
||||
aes = AESGCM(key)
|
||||
ct = aes.encrypt(nonce, s.encode("utf-8"), None)
|
||||
return "enc:v1:{}:{}".format(
|
||||
base64.urlsafe_b64encode(nonce).decode("utf-8").rstrip("="),
|
||||
base64.urlsafe_b64encode(ct).decode("utf-8").rstrip("="),
|
||||
)
|
||||
|
||||
|
||||
def decrypt_str(ciphertext: str) -> str:
|
||||
"""
|
||||
解密 encrypt_str 的输出;若不是 enc:v1 前缀,则视为明文原样返回(兼容旧数据)。
|
||||
"""
|
||||
if ciphertext is None:
|
||||
return ""
|
||||
s = str(ciphertext)
|
||||
if s == "":
|
||||
return ""
|
||||
if not s.startswith("enc:v1:"):
|
||||
return s
|
||||
|
||||
key = _load_master_key_bytes()
|
||||
if not key:
|
||||
raise RuntimeError("密文存在但未配置 ATS_MASTER_KEY,无法解密敏感字段。")
|
||||
|
||||
parts = s.split(":")
|
||||
if len(parts) != 4:
|
||||
raise ValueError("密文格式不正确")
|
||||
|
||||
b64_nonce = parts[2] + ("=" * (-len(parts[2]) % 4))
|
||||
b64_ct = parts[3] + ("=" * (-len(parts[3]) % 4))
|
||||
nonce = base64.urlsafe_b64decode(b64_nonce.encode("utf-8"))
|
||||
ct = base64.urlsafe_b64decode(b64_ct.encode("utf-8"))
|
||||
|
||||
AESGCM = _aesgcm()
|
||||
aes = AESGCM(key)
|
||||
pt = aes.decrypt(nonce, ct, None)
|
||||
return pt.decode("utf-8")
|
||||
|
||||
280
backend/spot_scanner.py
Normal file
280
backend/spot_scanner.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""
|
||||
现货推荐扫描:拉取币安现货行情,仅做多信号,写入 Redis 供 /api/recommendations/spot 使用。
|
||||
使用公开 API,无需 API Key。定时任务调用 run_spot_scan_and_cache()。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
# 可选的 Redis 写入(与 recommendations 路由共用连接方式)
|
||||
try:
|
||||
import redis.asyncio as redis_async
|
||||
except Exception:
|
||||
redis_async = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINANCE_SPOT_BASE = "https://api.binance.com"
|
||||
SPOT_KLINES_LIMIT = 60
|
||||
SPOT_TOP_N = 80
|
||||
SPOT_MIN_STRENGTH = 4
|
||||
SPOT_MAX_RECS = 30
|
||||
|
||||
|
||||
def _beijing_now_iso() -> str:
|
||||
from datetime import timedelta
|
||||
return datetime.now(tz=timezone(timedelta(hours=8))).isoformat()
|
||||
|
||||
|
||||
async def _http_get(session: aiohttp.ClientSession, url: str, params: Optional[Dict] = None) -> Optional[Any]:
|
||||
try:
|
||||
async with session.get(url, params=params or {}, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
return await resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("spot_scanner _http_get %s: %s", url[:60], e)
|
||||
return None
|
||||
|
||||
|
||||
def _technical_indicators():
|
||||
"""延迟导入 trading_system.indicators,避免 backend 强依赖 trading_system 路径。"""
|
||||
project_root = __import__("pathlib").Path(__file__).resolve().parent.parent
|
||||
trading_system = project_root / "trading_system"
|
||||
if str(trading_system) not in sys.path:
|
||||
sys.path.insert(0, str(trading_system))
|
||||
try:
|
||||
from indicators import TechnicalIndicators
|
||||
return TechnicalIndicators
|
||||
except ImportError:
|
||||
from trading_system.indicators import TechnicalIndicators
|
||||
return TechnicalIndicators
|
||||
|
||||
|
||||
async def _fetch_spot_symbols(session: aiohttp.ClientSession) -> List[str]:
|
||||
"""获取所有 USDT 现货交易对(status=TRADING)。"""
|
||||
data = await _http_get(session, f"{BINANCE_SPOT_BASE}/api/v3/exchangeInfo")
|
||||
if not data or "symbols" not in data:
|
||||
return []
|
||||
symbols = []
|
||||
for s in data["symbols"]:
|
||||
if s.get("status") != "TRADING":
|
||||
continue
|
||||
if s.get("quoteAsset") != "USDT":
|
||||
continue
|
||||
sym = s.get("symbol")
|
||||
if sym:
|
||||
symbols.append(sym)
|
||||
return symbols
|
||||
|
||||
|
||||
async def _fetch_spot_ticker_24h(session: aiohttp.ClientSession) -> List[Dict]:
|
||||
"""获取 24h ticker,返回 list of dict (symbol, lastPrice, priceChangePercent, volume, ...)。"""
|
||||
data = await _http_get(session, f"{BINANCE_SPOT_BASE}/api/v3/ticker/24hr")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return data
|
||||
|
||||
|
||||
async def _fetch_spot_klines(session: aiohttp.ClientSession, symbol: str, interval: str = "15m", limit: int = 60) -> Optional[List[List]]:
|
||||
"""现货 K 线,格式与合约一致 [open_time, o, h, l, c, volume, ...]。"""
|
||||
data = await _http_get(
|
||||
session,
|
||||
f"{BINANCE_SPOT_BASE}/api/v3/klines",
|
||||
{"symbol": symbol, "interval": interval, "limit": limit},
|
||||
)
|
||||
return data if isinstance(data, list) else None
|
||||
|
||||
|
||||
def _compute_spot_signal(klines: List[List], ticker: Dict, TechnicalIndicators) -> Optional[Dict]:
|
||||
"""
|
||||
基于 K 线计算只做多信号。返回 None 或 { direction: 'BUY', strength: int, ... }。
|
||||
"""
|
||||
if not klines or len(klines) < 50:
|
||||
return None
|
||||
closes = [float(k[4]) for k in klines]
|
||||
highs = [float(k[2]) for k in klines]
|
||||
lows = [float(k[3]) for k in klines]
|
||||
current_price = closes[-1]
|
||||
|
||||
rsi = TechnicalIndicators.calculate_rsi(closes, period=14)
|
||||
macd = TechnicalIndicators.calculate_macd(closes)
|
||||
bollinger = TechnicalIndicators.calculate_bollinger_bands(closes, period=20)
|
||||
ema20 = TechnicalIndicators.calculate_ema(closes, period=20)
|
||||
ema50 = TechnicalIndicators.calculate_ema(closes, period=50)
|
||||
|
||||
strength = 0
|
||||
# 只做多:RSI 超卖、价格在下轨附近、MACD 金叉、价格在均线上方等
|
||||
if rsi is not None and rsi < 35:
|
||||
strength += 3
|
||||
elif rsi is not None and rsi < 50:
|
||||
strength += 1
|
||||
if bollinger and current_price <= bollinger["lower"] * 1.002:
|
||||
strength += 3
|
||||
elif bollinger and current_price < bollinger["middle"]:
|
||||
strength += 1
|
||||
if macd and macd["histogram"] > 0 and macd["macd"] > macd["signal"]:
|
||||
strength += 2
|
||||
if ema20 and ema50 and current_price > ema20 > ema50:
|
||||
strength += 2
|
||||
elif ema20 and current_price > ema20:
|
||||
strength += 1
|
||||
|
||||
strength = max(0, min(strength, 10))
|
||||
if strength < SPOT_MIN_STRENGTH:
|
||||
return None
|
||||
return {
|
||||
"direction": "BUY",
|
||||
"strength": strength,
|
||||
"rsi": rsi,
|
||||
"current_price": current_price,
|
||||
}
|
||||
|
||||
|
||||
def _build_spot_recommendation(
|
||||
symbol: str,
|
||||
ticker: Dict,
|
||||
signal: Dict,
|
||||
) -> Dict[str, Any]:
|
||||
"""构造单条现货推荐(与合约推荐结构兼容,便于前端复用)。"""
|
||||
current_price = float(ticker.get("lastPrice") or signal.get("current_price") or 0)
|
||||
change_percent = float(ticker.get("priceChangePercent") or 0)
|
||||
ts = time.time()
|
||||
entry = current_price * 0.995
|
||||
stop_pct = 0.05
|
||||
tp1_pct = 0.08
|
||||
tp2_pct = 0.15
|
||||
if current_price <= 0:
|
||||
return None
|
||||
stop_loss = entry * (1 - stop_pct)
|
||||
tp1 = entry * (1 + tp1_pct)
|
||||
tp2 = entry * (1 + tp2_pct)
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"direction": "BUY",
|
||||
"market": "spot",
|
||||
"current_price": current_price,
|
||||
"signal_strength": signal.get("strength", 0),
|
||||
"change_percent": change_percent,
|
||||
"suggested_limit_price": entry,
|
||||
"planned_entry_price": entry,
|
||||
"suggested_stop_loss": stop_loss,
|
||||
"suggested_take_profit_1": tp1,
|
||||
"suggested_take_profit_2": tp2,
|
||||
"suggested_position_percent": 0.05,
|
||||
"recommendation_time": _beijing_now_iso(),
|
||||
"timestamp": ts,
|
||||
"recommendation_reason": "现货做多信号(RSI/布林带/MACD/均线)",
|
||||
"user_guide": f"现货建议在 {entry:.4f} USDT 附近买入,止损 {stop_loss:.4f},目标1 {tp1:.4f},目标2 {tp2:.4f}。仅供参考,请自行判断。",
|
||||
}
|
||||
|
||||
|
||||
async def run_spot_scan() -> List[Dict[str, Any]]:
|
||||
"""执行一次现货扫描,返回推荐列表(不写 Redis)。"""
|
||||
TechnicalIndicators = _technical_indicators()
|
||||
recommendations = []
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
symbols = await _fetch_spot_symbols(session)
|
||||
if not symbols:
|
||||
logger.warning("spot_scanner: 未获取到现货交易对")
|
||||
return []
|
||||
|
||||
tickers = await _fetch_spot_ticker_24h(session)
|
||||
ticker_map = {t["symbol"]: t for t in tickers if isinstance(t.get("symbol"), str)}
|
||||
|
||||
# 按 24h 成交量排序,取前 SPOT_TOP_N 再按涨跌幅取部分
|
||||
def volume_key(t):
|
||||
try:
|
||||
return float(t.get("volume") or 0) * float(t.get("lastPrice") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
sorted_tickers = sorted(
|
||||
[t for t in tickers if t.get("symbol") in symbols],
|
||||
key=volume_key,
|
||||
reverse=True,
|
||||
)[: SPOT_TOP_N * 2]
|
||||
|
||||
# 按涨跌幅取前 N 个(偏强势或超跌反弹)
|
||||
with_change = [(t, float(t.get("priceChangePercent") or 0)) for t in sorted_tickers]
|
||||
with_change.sort(key=lambda x: -abs(x[1]))
|
||||
to_scan = [t[0]["symbol"] for t in with_change[: SPOT_TOP_N]]
|
||||
|
||||
for symbol in to_scan:
|
||||
try:
|
||||
klines = await _fetch_spot_klines(session, symbol, "15m", SPOT_KLINES_LIMIT)
|
||||
ticker = ticker_map.get(symbol, {})
|
||||
if not klines or not ticker:
|
||||
continue
|
||||
signal = _compute_spot_signal(klines, ticker, TechnicalIndicators)
|
||||
if not signal:
|
||||
continue
|
||||
rec = _build_spot_recommendation(symbol, ticker, signal)
|
||||
if rec:
|
||||
recommendations.append(rec)
|
||||
if len(recommendations) >= SPOT_MAX_RECS:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("spot_scanner %s: %s", symbol, e)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
recommendations.sort(key=lambda x: x.get("signal_strength", 0), reverse=True)
|
||||
return recommendations[: SPOT_MAX_RECS]
|
||||
|
||||
|
||||
def _redis_connection_kwargs():
|
||||
redis_url = (os.getenv("REDIS_URL", "") or "").strip() or "redis://localhost:6379"
|
||||
kwargs = {"decode_responses": True}
|
||||
if os.getenv("REDIS_USERNAME"):
|
||||
kwargs["username"] = os.getenv("REDIS_USERNAME")
|
||||
if os.getenv("REDIS_PASSWORD"):
|
||||
kwargs["password"] = os.getenv("REDIS_PASSWORD")
|
||||
if redis_url.startswith("rediss://") or os.getenv("REDIS_USE_TLS", "").lower() == "true":
|
||||
if redis_url.startswith("redis://"):
|
||||
redis_url = redis_url.replace("redis://", "rediss://", 1)
|
||||
kwargs.setdefault("ssl_cert_reqs", os.getenv("REDIS_SSL_CERT_REQS", "required"))
|
||||
if os.getenv("REDIS_SSL_CA_CERTS"):
|
||||
kwargs["ssl_ca_certs"] = os.getenv("REDIS_SSL_CA_CERTS")
|
||||
return redis_url, kwargs
|
||||
|
||||
|
||||
async def run_spot_scan_and_cache(ttl_sec: int = 900) -> int:
|
||||
"""
|
||||
执行现货扫描并写入 Redis。返回写入的推荐数量。
|
||||
Redis key: recommendations:spot:snapshot
|
||||
"""
|
||||
items = await run_spot_scan()
|
||||
now_ms = int(time.time() * 1000)
|
||||
payload = {
|
||||
"items": items,
|
||||
"generated_at": _beijing_now_iso(),
|
||||
"generated_at_ms": now_ms,
|
||||
"ttl_sec": ttl_sec,
|
||||
"count": len(items),
|
||||
}
|
||||
|
||||
if redis_async is None:
|
||||
logger.warning("spot_scanner: redis 不可用,跳过写入")
|
||||
return len(items)
|
||||
|
||||
redis_url, kwargs = _redis_connection_kwargs()
|
||||
try:
|
||||
client = redis_async.from_url(redis_url, **kwargs)
|
||||
await client.ping()
|
||||
key = "recommendations:spot:snapshot"
|
||||
await client.setex(key, ttl_sec, json.dumps(payload, ensure_ascii=False))
|
||||
logger.info("spot_scanner: 已写入 %d 条现货推荐到 %s", len(items), key)
|
||||
await client.aclose()
|
||||
return len(items)
|
||||
except Exception as e:
|
||||
logger.warning("spot_scanner: Redis 写入失败 %s", e)
|
||||
return len(items)
|
||||
39
backend/start_recommendations.sh
Executable file
39
backend/start_recommendations.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/bin/bash
|
||||
# 启动推荐服务(recommendations_main)
|
||||
# 优先使用 trading_system/.venv(与服务器实际部署一致),其次 backend/.venv
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
BACKEND_DIR="$(pwd)"
|
||||
PROJECT_ROOT="$(cd .. && pwd)"
|
||||
TRADING_VENV="${PROJECT_ROOT}/trading_system/.venv"
|
||||
|
||||
# 激活虚拟环境:优先 trading_system/.venv,其次 backend 下
|
||||
if [ -d "${TRADING_VENV}" ]; then
|
||||
source "${TRADING_VENV}/bin/activate"
|
||||
elif [ -d ".venv" ]; then
|
||||
source .venv/bin/activate
|
||||
elif [ -d "../.venv" ]; then
|
||||
source ../.venv/bin/activate
|
||||
else
|
||||
echo "错误: 找不到虚拟环境(trading_system/.venv、.venv 或 ../.venv)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 设置环境变量
|
||||
export PYTHONPATH="${PROJECT_ROOT}"
|
||||
export DB_HOST=${DB_HOST:-localhost}
|
||||
export DB_PORT=${DB_PORT:-3306}
|
||||
export DB_USER=${DB_USER:-autosys}
|
||||
export DB_PASSWORD=${DB_PASSWORD:-}
|
||||
export DB_NAME=${DB_NAME:-auto_trade_sys}
|
||||
export LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
|
||||
# 创建日志目录
|
||||
mkdir -p "${PROJECT_ROOT}/logs"
|
||||
|
||||
# 启动推荐服务(后台运行)
|
||||
cd "${PROJECT_ROOT}"
|
||||
nohup python -m trading_system.recommendations_main > logs/recommendations.log 2>&1 &
|
||||
PID=$!
|
||||
echo "推荐服务已启动,PID: $PID"
|
||||
echo "日志: tail -f ${PROJECT_ROOT}/logs/recommendations.log"
|
||||
23
backend/stop.sh
Normal file
23
backend/stop.sh
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/bin/bash
|
||||
# 停止后端服务脚本
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 查找运行中的uvicorn进程
|
||||
PID=$(ps aux | grep "uvicorn api.main:app" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo "未找到运行中的后端服务"
|
||||
else
|
||||
echo "找到运行中的后端服务,PID: $PID"
|
||||
echo "正在停止服务..."
|
||||
kill $PID
|
||||
sleep 1
|
||||
|
||||
# 检查是否成功停止
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "停止失败,尝试强制停止..."
|
||||
kill -9 $PID
|
||||
fi
|
||||
echo "后端服务已停止"
|
||||
fi
|
||||
21
backend/stop_recommendations.sh
Executable file
21
backend/stop_recommendations.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
#!/bin/bash
|
||||
# 停止推荐服务(recommendations_main)
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 查找 recommendations_main 进程
|
||||
PID=$(ps aux | grep "trading_system.recommendations_main" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo "未找到运行中的推荐服务"
|
||||
else
|
||||
echo "找到推荐服务,PID: $PID"
|
||||
echo "正在停止..."
|
||||
kill $PID 2>/dev/null || true
|
||||
sleep 2
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "尝试强制停止..."
|
||||
kill -9 $PID 2>/dev/null || true
|
||||
fi
|
||||
echo "推荐服务已停止"
|
||||
fi
|
||||
115
backend/sync_global_config_defaults.py
Normal file
115
backend/sync_global_config_defaults.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
将“缺省全局配置项”同步到数据库 global_strategy_config 表。
|
||||
|
||||
- 已在 UI 保存过的项不会覆盖(只插入缺失的 key)。
|
||||
- 用于新上线配置项(如 MAX_RSI_FOR_LONG、MIN_RSI_FOR_SHORT 等)一次性写入默认值,
|
||||
便于在数据库中可见、可备份,且不依赖“先在页面改一次再保存”。
|
||||
|
||||
使用方式(在项目根目录):
|
||||
cd backend && python sync_global_config_defaults.py
|
||||
或
|
||||
python backend/sync_global_config_defaults.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保 backend 在路径中
|
||||
backend_dir = Path(__file__).resolve().parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
# 需要同步的缺省项(仅插入数据库中不存在的 key)
|
||||
DEFAULTS_TO_SYNC = [
|
||||
{"config_key": "MAX_RSI_FOR_LONG", "config_value": "65", "config_type": "number", "category": "strategy",
|
||||
"description": "做多时 RSI 超过此值则不开多(2026-02-12:65 避免追高)。"},
|
||||
{"config_key": "MAX_CHANGE_PERCENT_FOR_LONG", "config_value": "25", "config_type": "number", "category": "strategy",
|
||||
"description": "做多时 24h 涨跌幅超过此值则不开多(避免追大涨)。单位:百分比数值,如 25 表示 25%。2026-01-31新增。"},
|
||||
{"config_key": "MIN_RSI_FOR_SHORT", "config_value": "30", "config_type": "number", "category": "strategy",
|
||||
"description": "做空时 RSI 低于此值则不做空(避免深超卖反弹)。2026-01-31新增。"},
|
||||
{"config_key": "MAX_CHANGE_PERCENT_FOR_SHORT", "config_value": "10", "config_type": "number", "category": "strategy",
|
||||
"description": "做空时 24h 涨跌幅超过此值则不做空(24h 仍大涨时不做空)。单位:百分比数值。2026-01-31新增。"},
|
||||
{"config_key": "TAKE_PROFIT_1_PERCENT", "config_value": "0.3", "config_type": "number", "category": "strategy",
|
||||
"description": "分步止盈第一目标(保证金百分比,如 0.2=20%)。2026-02-12 提高以改善盈亏比。"},
|
||||
{"config_key": "MIN_RR_FOR_TP1", "config_value": "1.5", "config_type": "number", "category": "strategy",
|
||||
"description": "第一目标止盈相对止损的最小盈亏比(TP1 至少为止损距离的 1.5 倍)。2026-02-12 新增。"},
|
||||
{"config_key": "SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT", "config_value": "8", "config_type": "number", "category": "scan",
|
||||
"description": "智能补单:多返回的候选数量。当前 TOP_N 中部分因冷却等被跳过时,仍会尝试这批额外候选,避免无单可下。"},
|
||||
{"config_key": "BETA_FILTER_ENABLED", "config_value": "true", "config_type": "boolean", "category": "strategy",
|
||||
"description": "大盘共振过滤:BTC/ETH 下跌时屏蔽多单。"},
|
||||
{"config_key": "BETA_FILTER_THRESHOLD", "config_value": "-0.005", "config_type": "number", "category": "strategy",
|
||||
"description": "大盘共振阈值(比例,如 -0.005 表示 -0.5%)。"},
|
||||
{"config_key": "POSITION_SCALE_FACTOR", "config_value": "1.0", "config_type": "number", "category": "risk",
|
||||
"description": "仓位放大系数:1.0=正常,1.2=+20%,上限2.0。盈利时适度调高可扩大收益。"},
|
||||
{"config_key": "USE_FIXED_RISK_SIZING", "config_value": "true", "config_type": "boolean", "category": "risk",
|
||||
"description": "是否启用固定风险仓位计算(推荐)。若启用,则忽略 MAX_POSITION_PERCENT,改用 FIXED_RISK_PERCENT 计算仓位。"},
|
||||
{"config_key": "FIXED_RISK_PERCENT", "config_value": "0.03", "config_type": "number", "category": "risk",
|
||||
"description": "每笔交易风险占总账户的百分比(如 0.025=2.5%)。配合止损距离计算仓位,风险可控。"},
|
||||
{"config_key": "MIN_MARGIN_USDT", "config_value": "10.0", "config_type": "number", "category": "risk",
|
||||
"description": "最小保证金(USDT)。2026-02-13 提高到 10.0 USDT 以避免无效小单。"},
|
||||
# 盈利期对齐(2026-02-15):仅当 key 不存在时插入,不覆盖已有值
|
||||
{"config_key": "RSI_EXTREME_REVERSE_ENABLED", "config_value": "false", "config_type": "boolean", "category": "strategy",
|
||||
"description": "关闭RSI极限反转,与盈利期一致"},
|
||||
{"config_key": "RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H", "config_value": "true", "config_type": "boolean", "category": "strategy",
|
||||
"description": "若开启反向仅允许4H中性"},
|
||||
{"config_key": "USE_MARGIN_CAP_FOR_TP", "config_value": "true", "config_type": "boolean", "category": "risk",
|
||||
"description": "止盈按保证金封顶,避免过远"},
|
||||
{"config_key": "USE_MARGIN_CAP_FOR_SL", "config_value": "true", "config_type": "boolean", "category": "risk",
|
||||
"description": "止损按保证金封顶,避免扛单"},
|
||||
# 市场状态方案(2026-02 三项优化 + 方案切换)
|
||||
{"config_key": "MARKET_SCHEME", "config_value": "normal", "config_type": "string", "category": "strategy",
|
||||
"description": "市场方案:normal / bear / bull / conservative。切换后自动覆盖止损、仓位、趋势过滤等参数。"},
|
||||
{"config_key": "BLOCK_LONG_WHEN_4H_DOWN", "config_value": "false", "config_type": "boolean", "category": "strategy",
|
||||
"description": "4H 趋势下跌时禁止开多。bear / conservative 方案下自动为 true。"},
|
||||
{"config_key": "BLOCK_SHORT_WHEN_4H_UP", "config_value": "true", "config_type": "boolean", "category": "strategy",
|
||||
"description": "4H 趋势上涨时禁止开空。默认 true,避免逆势做空导致止损。"},
|
||||
{"config_key": "AUTO_MARKET_SCHEME_ENABLED", "config_value": "false", "config_type": "boolean", "category": "strategy",
|
||||
"description": "开启后,crontab 定时运行 scripts/update_market_scheme.py --apply 时自动更新 MARKET_SCHEME(根据 BTC 行情识别牛/熊/正常)。"},
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
from database.models import GlobalStrategyConfig
|
||||
from database.connection import db
|
||||
except ImportError as e:
|
||||
print(f"无法导入数据库模块,请确保在 backend 目录或设置 PYTHONPATH: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def _table_has_column(table: str, col: str) -> bool:
|
||||
try:
|
||||
db.execute_one(f"SELECT {col} FROM {table} LIMIT 1")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not _table_has_column("global_strategy_config", "config_key"):
|
||||
print("表 global_strategy_config 不存在或结构异常,请先执行 backend/database/add_global_strategy_config.sql")
|
||||
sys.exit(1)
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
for row in DEFAULTS_TO_SYNC:
|
||||
key = row["config_key"]
|
||||
existing = GlobalStrategyConfig.get(key)
|
||||
if existing:
|
||||
skipped += 1
|
||||
print(f" 已有: {key}")
|
||||
continue
|
||||
GlobalStrategyConfig.set(
|
||||
key,
|
||||
row["config_value"],
|
||||
row["config_type"],
|
||||
row["category"],
|
||||
row.get("description"),
|
||||
updated_by="sync_global_config_defaults",
|
||||
)
|
||||
inserted += 1
|
||||
print(f" 插入: {key} = {row['config_value']}")
|
||||
|
||||
print(f"\n同步完成: 新增 {inserted} 项,已存在跳过 {skipped} 项。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
backend/查看同步日志.sh
Executable file
39
backend/查看同步日志.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/bin/bash
|
||||
# 查看同步订单日志的便捷脚本
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== 同步订单日志查看工具 ==="
|
||||
echo ""
|
||||
|
||||
# 检查日志文件是否存在
|
||||
if [ ! -f "logs/api.log" ]; then
|
||||
echo "⚠️ 日志文件不存在: logs/api.log"
|
||||
echo " 请先启动 backend 服务"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "日志文件位置:"
|
||||
echo " - Python 应用日志: backend/logs/api.log"
|
||||
echo " - Uvicorn 服务器日志: backend/logs/uvicorn.log"
|
||||
echo ""
|
||||
|
||||
# 显示最近的同步日志
|
||||
echo "=== 最近的同步订单日志(最后 50 行)==="
|
||||
echo ""
|
||||
tail -50 logs/api.log | grep -i "同步\|sync\|订单\|order" --color=always || echo "未找到同步相关日志"
|
||||
|
||||
echo ""
|
||||
echo "=== 使用说明 ==="
|
||||
echo ""
|
||||
echo "实时查看同步日志:"
|
||||
echo " tail -f logs/api.log | grep -i '同步\|sync'"
|
||||
echo ""
|
||||
echo "查看最近的同步日志:"
|
||||
echo " tail -100 logs/api.log | grep -i '同步\|sync'"
|
||||
echo ""
|
||||
echo "查看特定时间的同步日志:"
|
||||
echo " grep '2026-02-17 23:' logs/api.log | grep -i '同步\|sync'"
|
||||
echo ""
|
||||
echo "查看所有同步相关日志(包括详细信息):"
|
||||
echo " grep -i '同步\|sync\|订单\|order' logs/api.log | tail -100"
|
||||
93
backend/检查内存问题.sh
Executable file
93
backend/检查内存问题.sh
Executable file
|
|
@ -0,0 +1,93 @@
|
|||
#!/bin/bash
|
||||
# 检查交易服务内存问题
|
||||
|
||||
echo "=== 交易服务内存问题诊断 ==="
|
||||
echo ""
|
||||
|
||||
# 1. 查看交易服务进程的详细内存信息
|
||||
echo "📊 交易服务进程内存详情:"
|
||||
TRADING_PID=$(ps aux | grep "trading_system.main" | grep -v grep | awk '{print $2}')
|
||||
if [ -z "$TRADING_PID" ]; then
|
||||
echo " ⚠️ 未找到交易服务进程"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "进程 PID: $TRADING_PID"
|
||||
ps -p $TRADING_PID -o pid,vsz,rss,%mem,cmd
|
||||
echo ""
|
||||
|
||||
# 2. 查看进程的内存映射(找出占用大的区域)
|
||||
echo "📈 进程内存映射(前 20 行,按大小排序):"
|
||||
if [ -f "/proc/$TRADING_PID/smaps" ]; then
|
||||
cat /proc/$TRADING_PID/smaps 2>/dev/null | awk '/^Size:/ {size=$2} /^Rss:/ {rss=$2} /^Pss:/ {pss=$2} /^Name:/ {if (rss > 1024) print size" KB (RSS: "rss" KB) - " $2}' | sort -rn | head -20 || echo " 无法读取内存映射(需要 root 权限)"
|
||||
else
|
||||
echo " 无法访问 /proc/$TRADING_PID/smaps"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 3. 查看交易服务日志中的内存相关错误
|
||||
echo "🔍 检查交易服务日志:"
|
||||
LOG_DIRS=(
|
||||
"../trading_system/logs"
|
||||
"logs"
|
||||
"/www/wwwroot/autosys_new/trading_system/logs"
|
||||
)
|
||||
|
||||
for LOG_DIR in "${LOG_DIRS[@]}"; do
|
||||
if [ -d "$LOG_DIR" ]; then
|
||||
echo "检查目录: $LOG_DIR"
|
||||
# 查找内存相关错误
|
||||
find "$LOG_DIR" -name "*.log" -type f -mtime -1 2>/dev/null | while read logfile; do
|
||||
echo " 文件: $logfile"
|
||||
# 查找内存错误
|
||||
grep -i "memory\|oom\|out of memory\|memoryerror\|memory leak" "$logfile" 2>/dev/null | tail -5 || echo " 未找到内存相关错误"
|
||||
# 查找最近的错误
|
||||
tail -50 "$logfile" 2>/dev/null | grep -i "error\|exception\|failed" | tail -5 || echo " 未找到错误"
|
||||
done
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# 4. 查看系统内存压力
|
||||
echo "💾 系统内存压力:"
|
||||
free -h
|
||||
echo ""
|
||||
echo "内存使用率:"
|
||||
free | awk 'NR==2{printf "已用: %.1f%%\n", $3*100/$2}'
|
||||
echo ""
|
||||
|
||||
# 5. 检查是否有 swap 使用(如果有说明内存不足)
|
||||
echo "🔄 Swap 使用情况:"
|
||||
free | awk 'NR==3{if ($3 > 0) print "⚠️ Swap 正在使用: " $3 " KB (内存不足)"; else print "✓ Swap 未使用"}'
|
||||
echo ""
|
||||
|
||||
# 6. 查看最近的交易服务输出
|
||||
echo "📝 最近的交易服务输出(最后 30 行):"
|
||||
for LOG_DIR in "${LOG_DIRS[@]}"; do
|
||||
if [ -d "$LOG_DIR" ]; then
|
||||
find "$LOG_DIR" -name "trading_*.log" -o -name "*.out.log" -type f 2>/dev/null | head -1 | while read logfile; do
|
||||
if [ -f "$logfile" ]; then
|
||||
tail -30 "$logfile" 2>/dev/null
|
||||
break
|
||||
fi
|
||||
done
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "=== 诊断完成 ==="
|
||||
echo ""
|
||||
echo "💡 可能的原因:"
|
||||
echo " 1. K线数据缓存过大(market_scanner 加载了太多历史K线)"
|
||||
echo " 2. 持仓数据或订单数据在内存中累积"
|
||||
echo " 3. WebSocket 连接或消息队列占用过多内存"
|
||||
echo " 4. 数据库查询结果集太大(未使用 LIMIT)"
|
||||
echo " 5. 内存泄漏(某个数据结构不断增长)"
|
||||
echo ""
|
||||
echo "💡 临时解决方案:"
|
||||
echo " 1. 重启交易服务(释放内存)"
|
||||
echo " 2. 检查配置中的缓存大小限制"
|
||||
echo " 3. 减少扫描的交易对数量"
|
||||
echo " 4. 检查是否有大量未关闭的数据库连接"
|
||||
109
backend/诊断负载.sh
Executable file
109
backend/诊断负载.sh
Executable file
|
|
@ -0,0 +1,109 @@
|
|||
#!/bin/bash
|
||||
# 快速诊断系统负载问题
|
||||
|
||||
echo "=== 系统负载诊断工具 ==="
|
||||
echo ""
|
||||
|
||||
# 1. 当前负载
|
||||
echo "📊 当前负载情况:"
|
||||
uptime
|
||||
echo ""
|
||||
|
||||
# 2. CPU 和内存使用
|
||||
echo "💻 CPU 和内存使用:"
|
||||
top -bn1 | head -5
|
||||
echo ""
|
||||
|
||||
# 3. 查看占用 CPU 最高的进程
|
||||
echo "🔥 CPU 占用最高的进程(前 10):"
|
||||
ps aux --sort=-%cpu | head -11 | awk '{printf "%-8s %-6s %-6s %-6s %s\n", $1, $2, $3"%", $4"%", $11}'
|
||||
echo ""
|
||||
|
||||
# 4. 查看 Python 进程(交易服务)
|
||||
echo "🐍 Python 进程(交易服务):"
|
||||
PYTHON_PROCS=$(ps aux | grep -E "python.*trading|python.*main|uvicorn" | grep -v grep)
|
||||
if [ -z "$PYTHON_PROCS" ]; then
|
||||
echo " ⚠️ 未发现交易服务进程(服务可能未运行)"
|
||||
else
|
||||
echo "$PYTHON_PROCS" | awk '{printf "PID: %-6s CPU: %-5s MEM: %-5s CMD: %s\n", $2, $3"%", $4"%", $11" "$12" "$13" "$14}'
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 5. 检查是否有同步操作在运行
|
||||
echo "🔄 检查同步操作:"
|
||||
if [ -f "logs/api.log" ]; then
|
||||
SYNC_LOGS=$(tail -100 logs/api.log | grep -i "同步\|sync.*binance\|sync_trades" | tail -10)
|
||||
if [ -z "$SYNC_LOGS" ]; then
|
||||
echo " 未找到同步日志(可能未执行同步操作)"
|
||||
else
|
||||
echo "最近的同步日志(最后 10 行):"
|
||||
echo "$SYNC_LOGS"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ 日志文件不存在(backend 服务可能未运行)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 6. 检查数据库连接数
|
||||
echo "🗄️ 数据库连接数:"
|
||||
if command -v mysql >/dev/null 2>&1; then
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_USER="${DB_USER:-root}"
|
||||
DB_PASS="${DB_PASS:-}"
|
||||
DB_NAME="${DB_NAME:-auto_trade_sys}"
|
||||
|
||||
if [ -n "$DB_PASS" ]; then
|
||||
mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" -e "SHOW PROCESSLIST;" 2>/dev/null | head -20 || echo " 无法连接数据库"
|
||||
else
|
||||
mysql -h"$DB_HOST" -u"$DB_USER" -e "SHOW PROCESSLIST;" 2>/dev/null | head -20 || echo " 无法连接数据库(需要配置 DB_PASS)"
|
||||
fi
|
||||
else
|
||||
echo " mysql 客户端未安装"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 7. 检查内存使用详情
|
||||
echo "💾 内存使用详情:"
|
||||
free -h
|
||||
echo ""
|
||||
|
||||
# 8. 检查是否有大量 I/O 等待
|
||||
echo "📈 I/O 和系统状态(5秒采样):"
|
||||
vmstat 1 5
|
||||
echo ""
|
||||
|
||||
# 9. 检查交易服务日志中的错误
|
||||
echo "⚠️ 最近的错误日志(最后 5 条):"
|
||||
if [ -f "logs/api.log" ]; then
|
||||
tail -200 logs/api.log | grep -i "error\|exception\|failed\|timeout" | tail -5 || echo " 未找到错误日志"
|
||||
fi
|
||||
if [ -f "../trading_system/logs/trading_*.log" ] 2>/dev/null; then
|
||||
tail -200 ../trading_system/logs/trading_*.log 2>/dev/null | grep -i "error\|exception\|failed" | tail -5 || echo ""
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== 诊断完成 ==="
|
||||
echo ""
|
||||
echo "💡 说明:"
|
||||
echo " - 此脚本可以在交易服务未运行时使用,用于检查系统整体负载"
|
||||
echo " - 如果交易服务正在运行,会显示更详细的进程和日志信息"
|
||||
echo ""
|
||||
echo "💡 如果负载高,可能原因:"
|
||||
echo " 1. Python 进程(交易服务)占用高:"
|
||||
echo " - 市场扫描正在运行(计算技术指标)"
|
||||
echo " - 订单同步正在运行(从币安拉取大量订单)"
|
||||
echo " - 数据库查询慢(检查慢查询日志)"
|
||||
echo ""
|
||||
echo " 2. 其他进程占用高:"
|
||||
echo " - 检查 top/htop 查看具体是哪个进程"
|
||||
echo " - 可能是系统更新、备份等后台任务"
|
||||
echo ""
|
||||
echo " 3. 内存占用高:检查是否有内存泄漏"
|
||||
echo ""
|
||||
echo " 4. I/O 等待高:可能是数据库查询慢或磁盘慢"
|
||||
echo ""
|
||||
echo "💡 临时降负载方法:"
|
||||
echo " - 暂停市场扫描(在配置中设置 SCAN_ENABLED=False)"
|
||||
echo " - 等待同步操作完成(不要手动取消)"
|
||||
echo " - 重启交易服务(如果进程异常)"
|
||||
echo " - 降低扫描并发(设置 SCAN_CONCURRENT_SYMBOLS=1)"
|
||||
127
docs/DB与币安订单对账说明.md
Normal file
127
docs/DB与币安订单对账说明.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# DB 与币安订单对账说明
|
||||
|
||||
## 一、查询系统今日落入 DB 的单子
|
||||
|
||||
### 1. 命令行脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 今日、默认账号、按创建时间(落库时间)
|
||||
python scripts/query_trades_today.py
|
||||
|
||||
# 指定账号
|
||||
python scripts/query_trades_today.py --account 2
|
||||
|
||||
# 指定日期
|
||||
python scripts/query_trades_today.py --date 2026-02-21
|
||||
|
||||
# 按入场时间筛选
|
||||
python scripts/query_trades_today.py --time-filter entry
|
||||
|
||||
# 仅可对账记录(有开仓/平仓订单号)
|
||||
python scripts/query_trades_today.py --reconciled-only
|
||||
|
||||
# 导出到 JSON 文件
|
||||
python scripts/query_trades_today.py -o today_trades.json
|
||||
```
|
||||
|
||||
### 2. API 接口
|
||||
|
||||
```
|
||||
GET /api/trades?period=today&time_filter=created&reconciled_only=false
|
||||
```
|
||||
|
||||
- `period=today`:今天
|
||||
- `time_filter=created`:按创建时间(落库时间),便于对照「何时写入 DB」
|
||||
- `time_filter=entry`:按入场时间
|
||||
- `time_filter=exit`:按平仓时间
|
||||
- `reconciled_only=false`:包含所有记录(含取消、无订单号)
|
||||
|
||||
### 3. 前端导出
|
||||
|
||||
交易记录页面 → 选择「今天」→ 导出 JSON / Excel。
|
||||
|
||||
---
|
||||
|
||||
## 二、币安订单推送日志
|
||||
|
||||
系统会将收到的 **ORDER_TRADE_UPDATE**、**ALGO_UPDATE** 写入日志,便于与 DB 对照。
|
||||
|
||||
### 日志路径
|
||||
|
||||
```
|
||||
{项目根}/logs/binance_order_events.log
|
||||
```
|
||||
|
||||
### 格式
|
||||
|
||||
每行一条 JSON,例如:
|
||||
|
||||
```json
|
||||
{"ts":1737500000000,"event_type":"ORDER_TRADE_UPDATE","account_id":1,"E":1737500000123,"symbol":"BTCUSDT","orderId":123456,"clientOrderId":"SYS_1737500000_abcd","event":"TRADE","status":"FILLED","reduceOnly":false,"avgPrice":"95000","executedQty":"0.01","realizedPnl":"0"}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| ts | 本机接收时间戳 |
|
||||
| event_type | ORDER_TRADE_UPDATE / ALGO_UPDATE |
|
||||
| account_id | 账号 ID |
|
||||
| E | 币安事件时间(毫秒) |
|
||||
| symbol | 交易对 |
|
||||
| orderId | 币安订单号 |
|
||||
| clientOrderId | 自定义订单号(系统前缀) |
|
||||
| event | NEW/TRADE/CANCELED |
|
||||
| status | NEW/FILLED/CANCELED 等 |
|
||||
| reduceOnly | 是否只减仓(平仓单) |
|
||||
| avgPrice/executedQty | 成交价/成交量(FILLED 时) |
|
||||
| realizedPnl | 实现盈亏(平仓时) |
|
||||
| algoId/triggeredOrderId | ALGO_UPDATE 专用 |
|
||||
|
||||
### 对账用法
|
||||
|
||||
```bash
|
||||
# 查看今天收到的所有订单推送
|
||||
grep "ORDER_TRADE_UPDATE" logs/binance_order_events.log
|
||||
|
||||
# 查看 FILLED 成交
|
||||
grep '"status":"FILLED"' logs/binance_order_events.log
|
||||
|
||||
# 按 clientOrderId 对照
|
||||
grep "SYS_1737500000_abcd" logs/binance_order_events.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、从币安拉取订单/成交(DB 缺失时)
|
||||
|
||||
当 DB 记录查不到或需直接从币安做策略分析时,可用脚本拉取:
|
||||
|
||||
```bash
|
||||
# 拉取最近 7 天成交记录(默认,适合策略分析)
|
||||
python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT
|
||||
|
||||
# 多个交易对
|
||||
python scripts/fetch_binance_orders.py --account 2 --symbols ASTERUSDT,FILUSDT,PENGUUSDT
|
||||
|
||||
# 拉取订单列表
|
||||
python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT --type orders
|
||||
|
||||
# 指定天数、导出
|
||||
python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT --days 7 -o binance_trades.json
|
||||
```
|
||||
|
||||
- `--type trades`:成交记录(含价格、数量、盈亏,策略分析推荐)
|
||||
- `--type orders`:订单列表(含 FILLED/CANCELED)
|
||||
- 币安单次时间范围最多 7 天
|
||||
|
||||
---
|
||||
|
||||
## 四、对账流程建议
|
||||
|
||||
1. **查 DB 今日记录**:`python scripts/query_trades_today.py -o db_today.json`
|
||||
2. **查币安推送日志**:`tail -f logs/binance_order_events.log` 或 `grep "ORDER_TRADE_UPDATE" logs/binance_order_events.log`
|
||||
3. **对照**:用 `clientOrderId` 或 `orderId` 关联 DB 记录与推送日志,确认:
|
||||
- DB 有 pending 且收到 FILLED 推送 → 应更新为 open
|
||||
- DB 有 open 且收到 reduceOnly FILLED → 应更新 exit_order_id
|
||||
- 收到推送但 DB 无对应记录 → 可能漏建或为手动单
|
||||
255
docs/archive/ALTCOIN_STRATEGY_UPDATE.md
Normal file
255
docs/archive/ALTCOIN_STRATEGY_UPDATE.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# 山寨币专属策略配置更新总结
|
||||
|
||||
> 更新时间:2026-01-24
|
||||
> 核心理念:**高盈亏比 + 宽止损 + 快速止盈 + 精选时机**
|
||||
|
||||
## 📋 更新概述
|
||||
|
||||
基于交易记录分析和山寨币市场特性,从"波段趋势策略"转变为"山寨币高盈亏比狙击策略"。
|
||||
|
||||
## 🔧 核心配置变更
|
||||
|
||||
### 1. 风险控制参数(最关键)
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `ATR_STOP_LOSS_MULTIPLIER` | 2.5 | **2.0** | 山寨币波动大,止损要宽但不过宽 |
|
||||
| `MIN_HOLD_TIME_SEC` | 1800 | **0** | **立即取消!**山寨币30分钟可能暴涨暴跌50% |
|
||||
| `STOP_LOSS_PERCENT` | 0.10 | **0.15** | 固定止损15%(相对保证金) |
|
||||
| `RISK_REWARD_RATIO` | 1.5 | **4.0** | 盈亏比必须≥4,用大赢家覆盖亏损 |
|
||||
| `USE_FIXED_RISK_SIZING` | True | **True** | 保持固定风险,避免亏损扩大 |
|
||||
| `FIXED_RISK_PERCENT` | 0.02 | **0.01** | 每笔最多亏1%(山寨币风险高) |
|
||||
| `ATR_TAKE_PROFIT_MULTIPLIER` | 1.5 | **8.0** | 止盈倍数提高到8(盈亏比4:1) |
|
||||
| `TAKE_PROFIT_PERCENT` | 0.25 | **0.60** | 固定止盈60%(4:1盈亏比) |
|
||||
|
||||
### 2. 入场与出场优化
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `MIN_SIGNAL_STRENGTH` | 8 | **7** | 保持较高门槛,但比8合理 |
|
||||
| `AUTO_TRADE_ONLY_TRENDING` | True | **True** | 山寨币只做趋势明确的 |
|
||||
| `SMART_ENTRY_ENABLED` | False | **True** | 开启智能入场,提高成交率 |
|
||||
| `USE_TRAILING_STOP` | False | **True** | **必须开启!**山寨币利润要保护 |
|
||||
| `TRAILING_STOP_ACTIVATION` | 0.10 | **0.30** | 盈利30%后激活(山寨币波动大) |
|
||||
| `TRAILING_STOP_PROTECT` | 0.05 | **0.15** | 保护15%利润(给回撤足够空间) |
|
||||
| `ENTRY_MAX_DRIFT_PCT_TRENDING` | 0.6 | **0.8** | 追价偏离放宽到0.8%(山寨币跳空大) |
|
||||
| `ENTRY_SYMBOL_COOLDOWN_SEC` | 120 | **1800** | 同一币种冷却30分钟 |
|
||||
|
||||
### 3. 交易品种筛选
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `MIN_VOLUME_24H` | 5000000 | **30000000** | 24H成交额≥3000万美元,过滤垃圾币 |
|
||||
| `MIN_VOLUME_24H_STRICT` | 10000000 | **50000000** | 严格过滤≥5000万美元 |
|
||||
| `MAX_SCAN_SYMBOLS` | 500 | **150** | 扫描前150个,覆盖主流山寨 |
|
||||
| `TOP_N_SYMBOLS` | 50 | **5** | 只做信号最强的5个,专注优质机会 |
|
||||
| `MIN_VOLATILITY` | 0.02 | **0.03** | 最小波动率3%,过滤死币 |
|
||||
|
||||
### 4. 仓位与频率控制
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `MAX_POSITION_PERCENT` | 0.08 | **0.015** | 单笔仓位1.5%,山寨币不加仓 |
|
||||
| `MAX_TOTAL_POSITION_PERCENT` | 0.40 | **0.12** | 总仓位12%,保守控制总风险 |
|
||||
| `MAX_DAILY_ENTRIES` | 8 | **5** | 每日最多5笔,山寨币少做多看 |
|
||||
| `MAX_OPEN_POSITIONS` | 3 | **4** | 同时持仓不超过4个 |
|
||||
| `LEVERAGE` | 10 | **8** | 基础杠杆降到8倍(山寨币波动大) |
|
||||
| `MAX_LEVERAGE` | 15 | **12** | 最大杠杆12倍,不要超过 |
|
||||
| `USE_DYNAMIC_LEVERAGE` | True | **False** | 不使用动态杠杆(保持简单) |
|
||||
|
||||
### 5. 时间框架调整
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `PRIMARY_INTERVAL` | 1h | **4h** | 主周期用4小时,过滤噪音 |
|
||||
| `ENTRY_INTERVAL` | 15m | **1h** | 入场周期1小时,避免太小的时间框架 |
|
||||
| `CONFIRM_INTERVAL` | 4h | **1d** | 确认周期用日线,看大趋势 |
|
||||
| `SCAN_INTERVAL` | 1800 | **3600** | 扫描间隔1小时(3600秒) |
|
||||
|
||||
## 📈 山寨币专用策略逻辑
|
||||
|
||||
### 1. 止损策略:宽但坚决
|
||||
|
||||
```
|
||||
ATR倍数2.0 + 固定止损15%(哪个先触发用哪个)
|
||||
不设持仓锁:触及止损立即离场
|
||||
逻辑:山寨币正常波动10-20%很常见,止损要容忍正常波动,但不能容忍趋势反转
|
||||
```
|
||||
|
||||
### 2. 止盈策略:分批 + 移动止损
|
||||
|
||||
```
|
||||
第一目标:盈亏比1:1(快速锁定30-50%利润)
|
||||
第二目标:盈亏比4:1(剩余仓位追求大赢家)
|
||||
移动止损:盈利30%后激活,保护15%利润
|
||||
逻辑:山寨币可能暴涨100%+,也可能瞬间反转,要快速锁定部分利润
|
||||
```
|
||||
|
||||
### 3. 品种选择:流动性为王
|
||||
|
||||
```
|
||||
合格山寨币标准:
|
||||
1. 24小时成交额 > 3000万美元
|
||||
2. 市值排名前150
|
||||
3. 有明确趋势(4小时+日线)
|
||||
4. 波动率 ≥ 3%
|
||||
5. 不在异常暴涨暴跌期间
|
||||
```
|
||||
|
||||
### 4. 时机选择:跟随大盘
|
||||
|
||||
```
|
||||
只在BTC处于明确趋势时交易山寨币
|
||||
AUTO_TRADE_ONLY_TRENDING = True
|
||||
AUTO_TRADE_ALLOW_4H_NEUTRAL = False
|
||||
```
|
||||
|
||||
## 💰 数学期望计算
|
||||
|
||||
### 优化后目标
|
||||
|
||||
```
|
||||
胜率:35%(山寨币难有高胜率)
|
||||
盈亏比:4.0
|
||||
固定风险:每笔1%
|
||||
|
||||
期望值 = (胜率 × 盈亏比) - (1 - 胜率)
|
||||
= (0.35 × 4.0) - 0.65
|
||||
= 1.4 - 0.65
|
||||
= 0.75
|
||||
|
||||
每笔交易平均盈利0.75个风险单位(即总资金的0.75%)
|
||||
```
|
||||
|
||||
### 与现状对比
|
||||
|
||||
```
|
||||
现状:
|
||||
- 胜率:30%
|
||||
- 盈亏比:0.91:1
|
||||
- 期望值:(0.30 × 0.91) - 0.70 = -0.427(严重亏损)
|
||||
|
||||
优化后:
|
||||
- 胜率:35%(目标)
|
||||
- 盈亏比:4.0:1
|
||||
- 期望值:+0.75(盈利)
|
||||
|
||||
改善:从-42.7%变为+75%,期望值提升117.7%
|
||||
```
|
||||
|
||||
## ⚠️ 山寨币交易铁律
|
||||
|
||||
1. **绝不扛单**:亏损15%无条件离场
|
||||
2. **绝不加仓**:山寨币没有"摊平成本",只有越亏越多
|
||||
3. **绝不做空低流通币**:容易被轧空
|
||||
4. **绝不信消息**:只信价格和成交量
|
||||
5. **仓位永远小于主流币**:单笔不超过1.5%
|
||||
|
||||
## 🎯 执行计划
|
||||
|
||||
### 第一阶段:配置更新(今天)
|
||||
|
||||
1. ✅ 更新 `trading_system/config.py` 中的所有配置默认值
|
||||
2. ✅ 更新 `trade_recommender.py` 中的分批止盈逻辑
|
||||
3. ⏳ 重启所有trading_system进程,使新配置生效
|
||||
4. ⏳ 在Redis中清除旧配置缓存(或等待自动过期)
|
||||
|
||||
### 第二阶段:回测验证(1-2天)
|
||||
|
||||
1. 用极小实盘(单笔0.5%)测试新策略
|
||||
2. 记录每笔交易的:
|
||||
- 入场信号强度
|
||||
- 最大浮盈
|
||||
- 是否触及止损/止盈
|
||||
- 持仓时间
|
||||
- 退出原因
|
||||
3. 目标:胜率35-40%,盈亏比3.5-4.5
|
||||
|
||||
### 第三阶段:正式运行(3天后)
|
||||
|
||||
1. 单笔风险1%,总仓位不超过10%
|
||||
2. 每日最多交易3-5笔
|
||||
3. 每周复盘,调整过滤条件
|
||||
4. 持续监控盈亏比和期望值
|
||||
|
||||
## 📊 关键指标监控
|
||||
|
||||
### 必须监控的指标
|
||||
|
||||
1. **实际盈亏比**:必须 > 3.5(目标4.0)
|
||||
2. **盈利因子**:总盈利 / 总亏损,必须 > 1.1
|
||||
3. **平均持仓时间**:应该在1-4小时之间
|
||||
4. **最大回撤**:单日不超过总资金的5%
|
||||
5. **胜率**:目标35-40%
|
||||
|
||||
### 预警阈值
|
||||
|
||||
- 盈亏比 < 3.0:立即暂停交易,检查策略
|
||||
- 胜率 < 25%:信号质量有问题,提高MIN_SIGNAL_STRENGTH
|
||||
- 单日亏损 > 3%:暂停交易,检查市场环境
|
||||
- 连续亏损 > 5笔:暂停交易,等待市场转好
|
||||
|
||||
## 🔄 后续优化方向
|
||||
|
||||
### 短期(1周内)
|
||||
|
||||
1. 监控并微调 `MIN_SIGNAL_STRENGTH`(7-8之间)
|
||||
2. 根据实际情况微调 `ATR_STOP_LOSS_MULTIPLIER`(1.8-2.2之间)
|
||||
3. 观察并记录哪些币种表现最好
|
||||
|
||||
### 中期(1月内)
|
||||
|
||||
1. 实现按市值分级的动态参数(见summary中的伪代码)
|
||||
2. 添加BTC趋势过滤(BTC下跌时不做山寨币多单)
|
||||
3. 优化移动止损的激活和保护参数
|
||||
|
||||
### 长期(3月内)
|
||||
|
||||
1. 建立山寨币白名单/黑名单机制
|
||||
2. 实现资金管理优化(凯利公式动态调整)
|
||||
3. 开发山寨币专用的技术指标组合
|
||||
|
||||
## 📝 配置文件清单
|
||||
|
||||
已更新的文件:
|
||||
- ✅ `trading_system/config.py` - 核心配置默认值
|
||||
- ✅ `trading_system/trade_recommender.py` - 推荐生成逻辑
|
||||
- ⏳ `backend/config_manager.py` - 配置管理器默认值(待更新)
|
||||
- ⏳ `backend/api/routes/config.py` - API配置元数据(待更新)
|
||||
|
||||
## ⚡ 立即执行的操作
|
||||
|
||||
```bash
|
||||
# 1. 重启所有trading_system进程(使新配置生效)
|
||||
supervisorctl restart auto_sys:*
|
||||
|
||||
# 2. 重启推荐服务
|
||||
supervisorctl restart auto_recommend:*
|
||||
|
||||
# 3. 查看日志确认新配置已生效
|
||||
tail -f /www/wwwroot/autosys_new/logs/trading_*.log
|
||||
|
||||
# 4. 检查配置是否正确加载
|
||||
# 在日志中查找以下关键配置:
|
||||
# - ATR_STOP_LOSS_MULTIPLIER: 2.0
|
||||
# - RISK_REWARD_RATIO: 4.0
|
||||
# - MIN_HOLD_TIME_SEC: 0
|
||||
# - USE_TRAILING_STOP: True
|
||||
```
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
- [ ] ATR止损倍数 = 2.0
|
||||
- [ ] 盈亏比 = 4.0
|
||||
- [ ] 最小持仓时间 = 0(已取消)
|
||||
- [ ] 移动止损已启用(激活30%,保护15%)
|
||||
- [ ] 智能入场已启用
|
||||
- [ ] 单笔仓位 ≤ 1.5%
|
||||
- [ ] 总仓位 ≤ 12%
|
||||
- [ ] 每日最多5笔
|
||||
- [ ] 基础杠杆 = 8倍
|
||||
- [ ] 24H成交量 ≥ 3000万美元
|
||||
|
||||
---
|
||||
|
||||
**重要提醒**:配置更新后,务必密切监控前3-5笔交易,确保新策略按预期运行。如有异常,立即暂停并检查日志。
|
||||
259
docs/archive/ATR使用合理性分析与优化建议_2026-01-27.md
Normal file
259
docs/archive/ATR使用合理性分析与优化建议_2026-01-27.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# ATR使用合理性分析与优化建议(2026-01-27)
|
||||
|
||||
## 📊 交易数据统计
|
||||
|
||||
### 基本统计(基于交易记录_2026-01-27T02-26-05.json)
|
||||
|
||||
**总交易数**:20单
|
||||
- **持仓中**:6单(30%)
|
||||
- **已平仓**:14单(70%)
|
||||
|
||||
**已平仓交易分析**:
|
||||
- **止盈单**:2单(14.3%)
|
||||
- CHZUSDT BUY: +24.51%
|
||||
- ZROUSDT SELL: +30.18%
|
||||
|
||||
- **止损单**:10单(71.4%)
|
||||
- 盈利单:3单(AXSUSDT +4.93%, AXLUSDT +7.78%, AXSUSDT +12.04%)
|
||||
- 亏损单:7单(-0.95%, -0.61%, -12.33%, -13.88%, -11.88%, -31.56%, -12.03%)
|
||||
|
||||
- **同步平仓**:2单(14.3%)
|
||||
- AUCTIONUSDT BUY: -12.22%
|
||||
- ZETAUSDT BUY: -35.54%
|
||||
- AXSUSDT SELL: -16.37%
|
||||
|
||||
**胜率分析**:
|
||||
- 已平仓:14单
|
||||
- 盈利单:5单(35.7%)
|
||||
- 亏损单:9单(64.3%)
|
||||
- **胜率:35.7%**(严重偏低)
|
||||
|
||||
**严重问题单**:
|
||||
- AXSUSDT SELL: -65.84%(巨额亏损,SELL单止损错误)
|
||||
- ZETAUSDT BUY: -35.54%(巨额亏损)
|
||||
- JTOUSDT BUY: -31.56%(巨额亏损)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 ATR使用合理性分析
|
||||
|
||||
### 当前ATR配置
|
||||
|
||||
- `USE_ATR_STOP_LOSS`: True
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0
|
||||
- `STOP_LOSS_PERCENT`: 0.12(12%)
|
||||
- `TAKE_PROFIT_PERCENT`: 0.20(20%)
|
||||
|
||||
---
|
||||
|
||||
### ATR止损计算逻辑
|
||||
|
||||
**计算步骤**(`risk_manager.py:602-760`):
|
||||
1. **ATR止损价**:`entry_price × (1 ± ATR% × 2.0)`
|
||||
2. **保证金止损价**:基于`STOP_LOSS_PERCENT`(12%)
|
||||
3. **价格百分比止损价**:基于`MIN_STOP_LOSS_PRICE_PCT`(2%)
|
||||
4. **选择最终的止损价**:取"更紧"的(更接近入场价)✅ 已修复
|
||||
|
||||
**问题分析**:
|
||||
- ✅ SELL单止损选择逻辑已修复(选择更紧的止损)
|
||||
- ⚠️ 但ATR止损倍数2.0可能仍然过宽
|
||||
- ⚠️ 如果ATR很大(比如5%),2.0倍就是10%的止损距离
|
||||
- ⚠️ 对于山寨币,10%的止损距离可能过大,导致巨额亏损
|
||||
|
||||
---
|
||||
|
||||
### ATR止盈计算逻辑
|
||||
|
||||
**计算步骤**(`risk_manager.py:772-844`):
|
||||
1. **ATR止盈价**:基于`ATR_TAKE_PROFIT_MULTIPLIER`(3.0)
|
||||
2. **保证金止盈价**:基于`TAKE_PROFIT_PERCENT`(20%)
|
||||
3. **价格百分比止盈价**:基于`MIN_TAKE_PROFIT_PRICE_PCT`(3%)
|
||||
4. **选择最终的止盈价**:取"更宽松"的(更远离入场价)❌ 问题
|
||||
|
||||
**问题分析**:
|
||||
- ❌ 选择"更宽松"的止盈,导致止盈目标过高
|
||||
- ❌ 如果ATR很大(比如5%),3.0倍就是15%的止盈距离
|
||||
- ❌ 对于山寨币,15%的止盈距离可能过高,导致止盈单比例过低(14.3%)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 核心问题
|
||||
|
||||
### 问题1:ATR止损倍数可能过宽
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
|
||||
|
||||
**问题**:
|
||||
- 如果ATR = 5%,止损距离 = 5% × 2.0 = 10%
|
||||
- 对于8倍杠杆,10%的价格变动 = 80%的保证金变动
|
||||
- 这可能导致巨额亏损(如-65.84%)
|
||||
|
||||
**建议**:
|
||||
- 收紧ATR止损倍数:2.0 → **1.5**
|
||||
- 既能容忍波动,又能控制风险
|
||||
|
||||
---
|
||||
|
||||
### 问题2:ATR止盈倍数可能过高
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0
|
||||
|
||||
**问题**:
|
||||
- 如果ATR = 5%,止盈距离 = 5% × 3.0 = 15%
|
||||
- 对于8倍杠杆,15%的价格变动 = 120%的保证金变动
|
||||
- 这可能导致止盈目标过高,难以触发
|
||||
- 止盈单比例过低(14.3%)
|
||||
|
||||
**建议**:
|
||||
- 降低ATR止盈倍数:3.0 → **2.0**
|
||||
- 更容易触发止盈,提升止盈单比例
|
||||
|
||||
---
|
||||
|
||||
### 问题3:止盈选择逻辑问题
|
||||
|
||||
**当前逻辑**:
|
||||
- 选择"更宽松"的止盈(更远离入场价)
|
||||
|
||||
**问题**:
|
||||
- 导致止盈目标过高,难以触发
|
||||
- 止盈单比例过低(14.3%)
|
||||
|
||||
**建议**:
|
||||
- 选择"更紧"的止盈(更接近入场价),更容易触发
|
||||
- 或者,优先使用固定百分比止盈(20%),而不是ATR止盈
|
||||
|
||||
---
|
||||
|
||||
## ✅ 优化建议
|
||||
|
||||
### 建议1:收紧ATR止损倍数(紧急)
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
|
||||
|
||||
**建议配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: **1.5**
|
||||
|
||||
**理由**:
|
||||
- 2.0倍对于山寨币来说可能过宽
|
||||
- 收紧到1.5倍,既能容忍波动,又能控制风险
|
||||
- 配合12%的固定止损,应该能更好地控制风险
|
||||
|
||||
**预期效果**:
|
||||
- 减少巨额亏损单(-65.84%, -35.54%, -31.56%)
|
||||
- 减少单笔亏损幅度
|
||||
|
||||
---
|
||||
|
||||
### 建议2:降低ATR止盈倍数(重要)
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0
|
||||
|
||||
**建议配置**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: **2.0**
|
||||
|
||||
**理由**:
|
||||
- 3.0倍对于山寨币来说可能过高
|
||||
- 降低到2.0倍,更容易触发止盈
|
||||
- 配合20%的固定止盈,应该能提升止盈单比例
|
||||
|
||||
**预期效果**:
|
||||
- 提升止盈单比例(从14.3%提升到30%+)
|
||||
- 更容易触发止盈,锁定利润
|
||||
|
||||
---
|
||||
|
||||
### 建议3:优化止盈选择逻辑(建议)
|
||||
|
||||
**当前逻辑**:
|
||||
- 选择"更宽松"的止盈(更远离入场价)
|
||||
|
||||
**建议逻辑**:
|
||||
- 选择"更紧"的止盈(更接近入场价),更容易触发
|
||||
- 或者,优先使用固定百分比止盈(20%),而不是ATR止盈
|
||||
|
||||
**理由**:
|
||||
- 固定百分比止盈(20%)更容易触发
|
||||
- ATR止盈可能过高,导致止盈单比例过低
|
||||
|
||||
---
|
||||
|
||||
## 📊 配置调整建议
|
||||
|
||||
### 当前配置(问题)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0(可能过宽)
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0(可能过高)
|
||||
- `STOP_LOSS_PERCENT`: 0.12(12%)
|
||||
- `TAKE_PROFIT_PERCENT`: 0.20(20%)
|
||||
|
||||
### 建议配置(优化)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: **1.5**(收紧止损)
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: **2.0**(降低止盈目标)
|
||||
- `STOP_LOSS_PERCENT`: **0.12**(12%,保持)
|
||||
- `TAKE_PROFIT_PERCENT`: **0.20**(20%,保持)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期效果
|
||||
|
||||
### 优化后预期
|
||||
|
||||
**止损单比例**:
|
||||
- 当前:71.4%
|
||||
- 预期:50% - 60%
|
||||
|
||||
**止盈单比例**:
|
||||
- 当前:14.3%
|
||||
- 预期:30% - 40%
|
||||
|
||||
**胜率**:
|
||||
- 当前:35.7%
|
||||
- 预期:45% - 55%
|
||||
|
||||
**盈亏比**:
|
||||
- 当前:需要计算
|
||||
- 预期:1.5:1 - 2.0:1
|
||||
|
||||
**巨额亏损单**:
|
||||
- 当前:-65.84%, -35.54%, -31.56%
|
||||
- 预期:减少或消除巨额亏损单
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **ATR倍数调整**:
|
||||
- 收紧ATR止损倍数,减少单笔亏损
|
||||
- 降低ATR止盈倍数,提升止盈单比例
|
||||
|
||||
2. **止损选择逻辑**:
|
||||
- 已修复SELL单的止损选择逻辑
|
||||
- 应该能减少巨额亏损单
|
||||
|
||||
3. **止盈选择逻辑**:
|
||||
- 建议优化止盈选择逻辑,优先使用固定百分比止盈
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
**ATR使用合理性**:
|
||||
- ⚠️ ATR止损倍数2.0可能过宽,建议收紧到1.5
|
||||
- ⚠️ ATR止盈倍数3.0可能过高,建议降低到2.0
|
||||
- ⚠️ 止盈选择逻辑建议优化,优先使用固定百分比止盈
|
||||
|
||||
**优化建议**:
|
||||
- ✅ 收紧ATR止损倍数:2.0 → 1.5
|
||||
- ✅ 降低ATR止盈倍数:3.0 → 2.0
|
||||
- ✅ 保持固定止损止盈:12% / 20%
|
||||
|
||||
**预期效果**:
|
||||
- ✅ 减少巨额亏损单
|
||||
- ✅ 提升止盈单比例
|
||||
- ✅ 提升胜率
|
||||
- ✅ 改善盈亏比
|
||||
133
docs/archive/ATR配置优化完成总结_2026-01-27.md
Normal file
133
docs/archive/ATR配置优化完成总结_2026-01-27.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# ATR配置优化完成总结(2026-01-27)
|
||||
|
||||
## 🎯 优化目标
|
||||
|
||||
**结合ATR的使用,优化配置,减少巨额亏损单,提升止盈单比例,提升胜率**
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成的优化
|
||||
|
||||
### 1. 收紧ATR止损倍数
|
||||
|
||||
**修改位置**:
|
||||
- `trading_system/config.py`
|
||||
- `backend/config_manager.py`
|
||||
- `frontend/src/components/GlobalConfig.jsx`
|
||||
- `frontend/src/components/ConfigPanel.jsx`
|
||||
|
||||
**优化内容**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0 → **1.5**
|
||||
|
||||
**理由**:
|
||||
- 2.0倍对于山寨币来说可能过宽
|
||||
- 如果ATR = 5%,止损距离 = 5% × 2.0 = 10%
|
||||
- 对于8倍杠杆,10%的价格变动 = 80%的保证金变动
|
||||
- 收紧到1.5倍,既能容忍波动,又能控制风险
|
||||
|
||||
---
|
||||
|
||||
### 2. 降低ATR止盈倍数
|
||||
|
||||
**修改位置**:
|
||||
- `trading_system/config.py`
|
||||
- `backend/config_manager.py`
|
||||
- `frontend/src/components/GlobalConfig.jsx`
|
||||
- `frontend/src/components/ConfigPanel.jsx`
|
||||
|
||||
**优化内容**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0 → **2.0**
|
||||
|
||||
**理由**:
|
||||
- 3.0倍对于山寨币来说可能过高
|
||||
- 如果ATR = 5%,止盈距离 = 5% × 3.0 = 15%
|
||||
- 对于8倍杠杆,15%的价格变动 = 120%的保证金变动
|
||||
- 降低到2.0倍,更容易触发止盈
|
||||
|
||||
---
|
||||
|
||||
### 3. 优化止盈选择逻辑
|
||||
|
||||
**修改位置**:`trading_system/risk_manager.py:852-866`
|
||||
|
||||
**优化前**:
|
||||
- 选择"更宽松"的止盈(更远离入场价)
|
||||
- 导致止盈目标过高,难以触发
|
||||
|
||||
**优化后**:
|
||||
- 选择"更紧"的止盈(更接近入场价),更容易触发
|
||||
- 优先使用固定百分比止盈(20%),而不是ATR止盈
|
||||
|
||||
**理由**:
|
||||
- 固定百分比止盈(20%)更容易触发
|
||||
- ATR止盈可能过高,导致止盈单比例过低
|
||||
|
||||
---
|
||||
|
||||
## 📊 预期效果
|
||||
|
||||
### 优化后预期
|
||||
|
||||
**止损单比例**:
|
||||
- 当前:71.4%
|
||||
- 预期:50% - 60%
|
||||
|
||||
**止盈单比例**:
|
||||
- 当前:14.3%
|
||||
- 预期:30% - 40%
|
||||
|
||||
**胜率**:
|
||||
- 当前:35.7%
|
||||
- 预期:45% - 55%
|
||||
|
||||
**巨额亏损单**:
|
||||
- 当前:-65.84%, -35.54%, -31.56%
|
||||
- 预期:减少或消除巨额亏损单
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置调整清单
|
||||
|
||||
### 已调整的配置项
|
||||
|
||||
| 配置项 | 原值 | 优化值 | 变化 | 理由 |
|
||||
|--------|------|--------|------|------|
|
||||
| `ATR_STOP_LOSS_MULTIPLIER` | 2.0 | **1.5** | ↓ | 收紧止损,减少单笔亏损 |
|
||||
| `ATR_TAKE_PROFIT_MULTIPLIER` | 3.0 | **2.0** | ↓ | 降低止盈目标,更容易触发 |
|
||||
| 止盈选择逻辑 | 更宽松 | **更紧** | ↑ | 更容易触发止盈 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **ATR倍数调整**:
|
||||
- 收紧ATR止损倍数,减少单笔亏损
|
||||
- 降低ATR止盈倍数,提升止盈单比例
|
||||
|
||||
2. **止盈选择逻辑**:
|
||||
- 已优化:选择"更紧"的止盈,更容易触发
|
||||
- 优先使用固定百分比止盈(20%),而不是ATR止盈
|
||||
|
||||
3. **止损选择逻辑**:
|
||||
- 已修复:SELL单选择"更紧"的止损
|
||||
- 应该能减少巨额亏损单
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
**ATR使用合理性**:
|
||||
- ⚠️ ATR止损倍数2.0过宽 → 已优化为1.5
|
||||
- ⚠️ ATR止盈倍数3.0过高 → 已优化为2.0
|
||||
- ⚠️ 止盈选择逻辑问题 → 已优化为选择"更紧"的止盈
|
||||
|
||||
**优化效果**:
|
||||
- ✅ 减少巨额亏损单
|
||||
- ✅ 提升止盈单比例
|
||||
- ✅ 提升胜率
|
||||
- ✅ 改善盈亏比
|
||||
|
||||
**下一步**:
|
||||
- 清除Redis缓存
|
||||
- 重启交易进程
|
||||
- 监控效果
|
||||
282
docs/archive/CONFIG_ARCHITECTURE_VERIFICATION.md
Normal file
282
docs/archive/CONFIG_ARCHITECTURE_VERIFICATION.md
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
# 配置架构验证文档
|
||||
|
||||
## 📋 验证目标
|
||||
|
||||
确认:
|
||||
1. ✅ **所有用户下的账户都使用全局策略配置**
|
||||
2. ✅ **普通用户无法通过自己的配置直接影响核心策略参数**
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 配置架构设计
|
||||
|
||||
### 1. 配置层级
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 全局策略账号 (account_id=1, 默认) │
|
||||
│ - 存储所有核心策略参数 │
|
||||
│ - 例如:ATR_STOP_LOSS_MULTIPLIER, ATR_TAKE_PROFIT_... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ 读取
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 用户账户 (account_id=2, 3, 4...) │
|
||||
│ - 存储风险旋钮(每个账户独立) │
|
||||
│ - 例如:MAX_POSITION_PERCENT, AUTO_TRADE_ENABLED... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. 配置读取逻辑
|
||||
|
||||
**位置**:`backend/config_manager.py` 的 `get_trading_config()` 方法
|
||||
|
||||
```python
|
||||
def eff_get(key: str, default: Any):
|
||||
"""
|
||||
策略核心:默认从全局账号读取(GLOBAL_STRATEGY_ACCOUNT_ID)。
|
||||
风险旋钮:从当前账号读取。
|
||||
"""
|
||||
# API key/secret/testnet 永远按账号读取
|
||||
if key in RISK_KNOBS_KEYS or global_mgr is None:
|
||||
return self.get(key, default) # 从当前账号读取
|
||||
try:
|
||||
# 从全局账号读取
|
||||
return global_mgr.get(key, default)
|
||||
except Exception:
|
||||
return self.get(key, default)
|
||||
```
|
||||
|
||||
**风险旋钮列表**(`RISK_KNOBS_KEYS`):
|
||||
- `MIN_MARGIN_USDT`
|
||||
- `MIN_POSITION_PERCENT`
|
||||
- `MAX_POSITION_PERCENT`
|
||||
- `MAX_TOTAL_POSITION_PERCENT`
|
||||
- `AUTO_TRADE_ENABLED`
|
||||
- `MAX_OPEN_POSITIONS`
|
||||
- `MAX_DAILY_ENTRIES`
|
||||
|
||||
**核心策略参数**(从全局账号读取):
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`
|
||||
- `RISK_REWARD_RATIO`
|
||||
- `USE_FIXED_RISK_SIZING`
|
||||
- `FIXED_RISK_PERCENT`
|
||||
- `USE_DYNAMIC_ATR_MULTIPLIER`
|
||||
- `MIN_SIGNAL_STRENGTH`
|
||||
- `SCAN_INTERVAL`
|
||||
- `TOP_N_SYMBOLS`
|
||||
- ... 等等所有非风险旋钮的配置
|
||||
|
||||
---
|
||||
|
||||
## 🔒 权限控制
|
||||
|
||||
### 1. 后端API权限控制
|
||||
|
||||
**位置**:`backend/api/routes/config.py`
|
||||
|
||||
#### GET `/api/config` - 获取配置列表
|
||||
|
||||
```python
|
||||
# 普通用户:只展示风险旋钮 + 账号密钥
|
||||
# 管理员:若当前不是"全局策略账号",同样只展示风险旋钮
|
||||
is_admin = (user.get("role") or "user") == "admin"
|
||||
gid = _global_strategy_account_id()
|
||||
if (not is_admin) or (is_admin and int(account_id) != int(gid)):
|
||||
allowed = set(USER_RISK_KNOBS) | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}
|
||||
result = {k: v for k, v in result.items() if k in allowed}
|
||||
```
|
||||
|
||||
**验证**:
|
||||
- ✅ 普通用户只能看到 `USER_RISK_KNOBS` + API密钥
|
||||
- ✅ 管理员在非全局策略账号时,也只能看到风险旋钮
|
||||
- ✅ 只有管理员在全局策略账号时,才能看到所有配置
|
||||
|
||||
#### PUT `/api/config/{key}` - 更新单个配置
|
||||
|
||||
```python
|
||||
# 管理员:若不是全局策略账号,则禁止修改策略核心
|
||||
if (user.get("role") or "user") == "admin":
|
||||
gid = _global_strategy_account_id()
|
||||
if int(account_id) != int(gid):
|
||||
if key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
raise HTTPException(status_code=403, detail=f"该配置由全局策略账号 #{gid} 统一管理")
|
||||
|
||||
# 产品模式:普通用户只能改"风险旋钮"与账号私有密钥/测试网
|
||||
if (user.get("role") or "user") != "admin":
|
||||
if key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
raise HTTPException(status_code=403, detail="该配置由平台统一管理(仅管理员可修改)")
|
||||
```
|
||||
|
||||
**验证**:
|
||||
- ✅ 普通用户尝试修改核心策略参数会返回 403 错误
|
||||
- ✅ 管理员在非全局策略账号时,也无法修改核心策略参数
|
||||
- ✅ 只有管理员在全局策略账号时,才能修改核心策略参数
|
||||
|
||||
#### POST `/api/config/batch` - 批量更新配置
|
||||
|
||||
```python
|
||||
for item in configs:
|
||||
# 管理员:若不是全局策略账号,则批量只允许风险旋钮/密钥
|
||||
if (user.get("role") or "user") == "admin":
|
||||
gid = _global_strategy_account_id()
|
||||
if int(account_id) != int(gid):
|
||||
if item.key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
errors.append(f"{item.key}: 该配置由全局策略账号 #{gid} 统一管理,请切换账号修改")
|
||||
continue
|
||||
|
||||
# 产品模式:普通用户只能改"风险旋钮"与账号私有密钥/测试网
|
||||
if (user.get("role") or "user") != "admin":
|
||||
if item.key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
errors.append(f"{item.key}: 该配置由平台统一管理(仅管理员可修改)")
|
||||
continue
|
||||
```
|
||||
|
||||
**验证**:
|
||||
- ✅ 普通用户批量更新时,核心策略参数会被过滤并返回错误
|
||||
- ✅ 管理员在非全局策略账号时,核心策略参数也会被过滤
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证结果
|
||||
|
||||
### 1. 所有账户使用全局策略配置 ✅
|
||||
|
||||
**验证点**:
|
||||
- `config_manager.py` 的 `get_trading_config()` 方法中,所有非风险旋钮的配置都通过 `eff_get()` 从全局账号读取
|
||||
- 即使普通用户在自己的账户中设置了核心策略参数,也不会生效(因为读取时从全局账号读取)
|
||||
|
||||
**代码位置**:
|
||||
- `backend/config_manager.py:509-522`
|
||||
|
||||
**结论**:✅ **所有账户都使用全局策略配置**
|
||||
|
||||
---
|
||||
|
||||
### 2. 普通用户无法修改核心策略参数 ✅
|
||||
|
||||
**验证点**:
|
||||
- **前端限制**:普通用户在配置页面只能看到风险旋钮(通过API过滤)
|
||||
- **后端限制**:
|
||||
- GET `/api/config`:只返回风险旋钮
|
||||
- PUT `/api/config/{key}`:尝试修改核心参数返回 403
|
||||
- POST `/api/config/batch`:核心参数被过滤并返回错误
|
||||
|
||||
**代码位置**:
|
||||
- `backend/api/routes/config.py:273-280` (GET)
|
||||
- `backend/api/routes/config.py:645-655` (PUT)
|
||||
- `backend/api/routes/config.py:765-776` (POST)
|
||||
|
||||
**结论**:✅ **普通用户无法通过自己的配置直接影响核心策略参数**
|
||||
|
||||
---
|
||||
|
||||
## 📊 配置分类总结
|
||||
|
||||
### 风险旋钮(每个账户独立)
|
||||
- `MIN_MARGIN_USDT` - 最小保证金(USDT)
|
||||
- `MIN_POSITION_PERCENT` - 最小仓位占比
|
||||
- `MAX_POSITION_PERCENT` - 最大仓位占比
|
||||
- `MAX_TOTAL_POSITION_PERCENT` - 总仓位占比上限
|
||||
- `AUTO_TRADE_ENABLED` - 自动交易开关
|
||||
- `MAX_OPEN_POSITIONS` - 同时持仓数量上限
|
||||
- `MAX_DAILY_ENTRIES` - 每日最多开仓次数
|
||||
|
||||
### 核心策略参数(全局统一)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER` - ATR止损倍数
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER` - ATR止盈倍数
|
||||
- `RISK_REWARD_RATIO` - 盈亏比
|
||||
- `USE_FIXED_RISK_SIZING` - 使用固定风险百分比
|
||||
- `FIXED_RISK_PERCENT` - 固定风险百分比
|
||||
- `USE_DYNAMIC_ATR_MULTIPLIER` - 动态ATR倍数
|
||||
- `MIN_SIGNAL_STRENGTH` - 最小信号强度
|
||||
- `SCAN_INTERVAL` - 扫描间隔
|
||||
- `TOP_N_SYMBOLS` - 每次扫描处理的交易对数量
|
||||
- ... 等等所有非风险旋钮的配置
|
||||
|
||||
### 账号私有配置(每个账户独立)
|
||||
- `BINANCE_API_KEY` - 币安API密钥
|
||||
- `BINANCE_API_SECRET` - 币安API密钥
|
||||
- `USE_TESTNET` - 是否使用测试网
|
||||
|
||||
---
|
||||
|
||||
## 🎯 实际运行验证
|
||||
|
||||
### 测试场景1:普通用户查看配置
|
||||
1. 普通用户登录
|
||||
2. 进入配置页面
|
||||
3. **预期**:只能看到风险旋钮 + API密钥配置
|
||||
4. **验证**:前端只显示允许的配置项
|
||||
|
||||
### 测试场景2:普通用户尝试修改核心策略参数
|
||||
1. 普通用户登录
|
||||
2. 尝试通过API修改 `ATR_STOP_LOSS_MULTIPLIER`
|
||||
3. **预期**:返回 403 错误:"该配置由平台统一管理(仅管理员可修改)"
|
||||
4. **验证**:后端拒绝修改请求
|
||||
|
||||
### 测试场景3:管理员在非全局策略账号修改核心策略参数
|
||||
1. 管理员登录
|
||||
2. 切换到非全局策略账号(如 account_id=2)
|
||||
3. 尝试修改 `ATR_STOP_LOSS_MULTIPLIER`
|
||||
4. **预期**:返回 403 错误:"该配置由全局策略账号 #1 统一管理,请切换到该账号修改"
|
||||
5. **验证**:后端拒绝修改请求
|
||||
|
||||
### 测试场景4:管理员在全局策略账号修改核心策略参数
|
||||
1. 管理员登录
|
||||
2. 切换到全局策略账号(account_id=1)
|
||||
3. 修改 `ATR_STOP_LOSS_MULTIPLIER = 2.5`
|
||||
4. **预期**:修改成功
|
||||
5. **验证**:所有账户的交易系统都会使用新的值(通过 `config_manager.get_trading_config()` 读取)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 代码检查清单
|
||||
|
||||
- [x] `backend/config_manager.py` - 配置读取逻辑使用全局账号
|
||||
- [x] `backend/api/routes/config.py` - API权限控制
|
||||
- [x] `frontend/src/components/ConfigPanel.jsx` - 前端配置页面(依赖后端过滤)
|
||||
- [x] `frontend/src/components/GlobalConfig.jsx` - 管理员全局配置页面
|
||||
|
||||
---
|
||||
|
||||
## ✅ 最终结论
|
||||
|
||||
1. ✅ **所有用户下的账户都使用全局策略配置**
|
||||
- 通过 `config_manager.get_trading_config()` 的 `eff_get()` 函数实现
|
||||
- 核心策略参数从全局账号(account_id=1)读取
|
||||
- 风险旋钮从当前账号读取
|
||||
|
||||
2. ✅ **普通用户无法通过自己的配置直接影响核心策略参数**
|
||||
- 前端:只能看到风险旋钮
|
||||
- 后端:尝试修改核心参数会返回 403 错误
|
||||
- 即使数据库中有值,读取时也会从全局账号读取
|
||||
|
||||
3. ✅ **管理员权限控制**
|
||||
- 管理员在非全局策略账号时,也只能修改风险旋钮
|
||||
- 只有管理员在全局策略账号时,才能修改核心策略参数
|
||||
|
||||
---
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **全局策略账号ID**:默认是 `account_id=1`,可通过环境变量 `ATS_GLOBAL_STRATEGY_ACCOUNT_ID` 修改
|
||||
|
||||
2. **配置缓存**:配置存储在 Redis 中,修改后需要确保 Redis 缓存已更新
|
||||
|
||||
3. **配置生效**:修改全局策略配置后,所有账户的交易系统会在下次 `reload_from_redis()` 时读取新值
|
||||
|
||||
4. **风险旋钮的作用**:虽然核心策略参数是全局的,但每个账户可以通过风险旋钮控制:
|
||||
- 仓位大小(MAX_POSITION_PERCENT)
|
||||
- 交易频率(MAX_DAILY_ENTRIES)
|
||||
- 同时持仓数量(MAX_OPEN_POSITIONS)
|
||||
- 是否启用自动交易(AUTO_TRADE_ENABLED)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 建议
|
||||
|
||||
1. **定期检查**:定期验证全局策略账号的配置是否正确
|
||||
2. **配置快照**:在修改全局策略配置前,先导出配置快照作为备份
|
||||
3. **测试环境**:在测试环境验证配置修改的效果,再应用到生产环境
|
||||
4. **文档更新**:修改配置后,及时更新相关文档
|
||||
266
docs/archive/GLOBAL_CONFIG_MIGRATION.md
Normal file
266
docs/archive/GLOBAL_CONFIG_MIGRATION.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# 全局配置独立化迁移说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
将全局策略配置从依赖 `account_id=1` 改为独立的配置系统,使用独立的 `global_strategy_config` 表和 Redis 缓存。
|
||||
|
||||
## 🎯 目标
|
||||
|
||||
1. ✅ 全局配置不再依赖任何账户(account_id)
|
||||
2. ✅ 独立的数据库表 `global_strategy_config`
|
||||
3. ✅ 独立的 Redis 缓存键 `global_strategy_config`
|
||||
4. ✅ 只有管理员可以查看和修改全局配置
|
||||
5. ✅ 所有账户自动使用全局配置(通过 `config_manager.py` 读取)
|
||||
|
||||
---
|
||||
|
||||
## 📦 数据库变更
|
||||
|
||||
### 1. 创建新表
|
||||
|
||||
执行迁移脚本:
|
||||
```bash
|
||||
mysql -u your_user -p auto_trade_sys < backend/database/add_global_strategy_config.sql
|
||||
```
|
||||
|
||||
**新表结构**:
|
||||
```sql
|
||||
CREATE TABLE `global_strategy_config` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`config_key` VARCHAR(100) NOT NULL,
|
||||
`config_value` TEXT NOT NULL,
|
||||
`config_type` VARCHAR(50) NOT NULL,
|
||||
`category` VARCHAR(50) NOT NULL,
|
||||
`description` TEXT,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(50),
|
||||
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||
)
|
||||
```
|
||||
|
||||
### 2. 数据迁移
|
||||
|
||||
迁移脚本会自动将 `account_id=1` 的核心策略配置迁移到 `global_strategy_config` 表。
|
||||
|
||||
**迁移规则**:
|
||||
- 只迁移非风险旋钮的配置
|
||||
- 风险旋钮(`MIN_MARGIN_USDT`, `MAX_POSITION_PERCENT` 等)不迁移
|
||||
- API密钥(`BINANCE_API_KEY`, `BINANCE_API_SECRET`)不迁移
|
||||
|
||||
---
|
||||
|
||||
## 🔧 代码变更
|
||||
|
||||
### 1. 数据库模型 (`backend/database/models.py`)
|
||||
|
||||
**新增**:`GlobalStrategyConfig` 类
|
||||
- `get_all()` - 获取所有全局配置
|
||||
- `get(key)` - 获取单个配置
|
||||
- `set(key, value, ...)` - 设置配置
|
||||
- `get_value(key, default)` - 获取配置值(自动转换类型)
|
||||
- `delete(key)` - 删除配置
|
||||
|
||||
### 2. 配置管理器 (`backend/config_manager.py`)
|
||||
|
||||
**新增**:`GlobalStrategyConfigManager` 类
|
||||
- 独立的 Redis 缓存键:`global_strategy_config`
|
||||
- 独立的数据库表:`global_strategy_config`
|
||||
- 单例模式,确保全局唯一
|
||||
|
||||
**修改**:`ConfigManager.get_trading_config()`
|
||||
- 从 `GlobalStrategyConfigManager` 读取全局配置
|
||||
- 不再依赖 `account_id=1`
|
||||
- 风险旋钮仍从当前账户读取
|
||||
|
||||
### 3. API 路由 (`backend/api/routes/config.py`)
|
||||
|
||||
**新增端点**:
|
||||
- `GET /api/config/global` - 获取全局配置(仅管理员)
|
||||
- `PUT /api/config/global/{key}` - 更新单个全局配置(仅管理员)
|
||||
- `POST /api/config/global/batch` - 批量更新全局配置(仅管理员)
|
||||
|
||||
**修改端点**:
|
||||
- `GET /api/config/meta` - 移除 `global_strategy_account_id` 字段
|
||||
|
||||
**权限控制**:
|
||||
- 所有全局配置端点都检查管理员权限
|
||||
- 非管理员访问返回 403 错误
|
||||
|
||||
### 4. 前端 API 服务 (`frontend/src/services/api.js`)
|
||||
|
||||
**修改**:
|
||||
- `getGlobalConfigs()` - 不再需要 `globalAccountId` 参数
|
||||
- `updateGlobalConfigsBatch()` - 不再需要 `globalAccountId` 参数
|
||||
|
||||
### 5. 前端组件 (`frontend/src/components/GlobalConfig.jsx`)
|
||||
|
||||
**移除**:
|
||||
- 所有对 `configMeta.global_strategy_account_id` 的引用
|
||||
- 所有对 `globalAccountId` 的计算和使用
|
||||
|
||||
**简化**:
|
||||
- `loadConfigs()` - 直接调用 `api.getGlobalConfigs()`,无需 account_id
|
||||
- `handleApplyPreset()` - 直接调用 `api.updateGlobalConfigsBatch()`,无需 account_id
|
||||
- `buildConfigSnapshot()` - 直接调用 `api.getGlobalConfigs()`,无需 account_id
|
||||
|
||||
---
|
||||
|
||||
## 🔄 迁移步骤
|
||||
|
||||
### 步骤1:执行数据库迁移
|
||||
|
||||
```bash
|
||||
cd /path/to/auto_trade_sys
|
||||
mysql -u your_user -p auto_trade_sys < backend/database/add_global_strategy_config.sql
|
||||
```
|
||||
|
||||
### 步骤2:重启后端服务
|
||||
|
||||
```bash
|
||||
# 重启 FastAPI 后端
|
||||
systemctl restart your-backend-service
|
||||
# 或
|
||||
supervisorctl restart backend
|
||||
```
|
||||
|
||||
### 步骤3:重启交易系统
|
||||
|
||||
```bash
|
||||
# 重启所有交易进程,使新配置生效
|
||||
supervisorctl restart all
|
||||
```
|
||||
|
||||
### 步骤4:验证
|
||||
|
||||
1. **管理员登录**,进入"全局配置"页面
|
||||
2. **检查配置项**:应该能看到所有核心策略配置
|
||||
3. **修改配置**:尝试修改一个配置项,确认保存成功
|
||||
4. **检查数据库**:确认 `global_strategy_config` 表中有数据
|
||||
5. **检查 Redis**:确认 `global_strategy_config` 键中有缓存
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 向后兼容
|
||||
|
||||
- 如果 `global_strategy_config` 表不存在,系统会回退到从 `account_id=1` 读取(兼容旧系统)
|
||||
- 迁移脚本会自动迁移现有配置,不会丢失数据
|
||||
|
||||
### 2. Redis 缓存
|
||||
|
||||
- 全局配置使用独立的 Redis 键:`global_strategy_config`
|
||||
- 账户配置仍使用:`trading_config:{account_id}`
|
||||
- 修改全局配置后,会自动更新 Redis 缓存
|
||||
|
||||
### 3. 权限控制
|
||||
|
||||
- **管理员**:可以查看和修改全局配置
|
||||
- **普通用户**:无法访问全局配置 API(返回 403)
|
||||
- 普通用户只能修改自己账户的风险旋钮
|
||||
|
||||
### 4. 配置读取优先级
|
||||
|
||||
1. **风险旋钮**:从当前账户的 `trading_config` 表读取
|
||||
2. **核心策略参数**:从 `global_strategy_config` 表读取
|
||||
3. **API密钥**:从 `accounts` 表读取(每个账户独立)
|
||||
|
||||
---
|
||||
|
||||
## 📊 配置分类
|
||||
|
||||
### 全局配置(管理员专用)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`
|
||||
- `RISK_REWARD_RATIO`
|
||||
- `USE_FIXED_RISK_SIZING`
|
||||
- `FIXED_RISK_PERCENT`
|
||||
- `USE_DYNAMIC_ATR_MULTIPLIER`
|
||||
- `MIN_SIGNAL_STRENGTH`
|
||||
- `SCAN_INTERVAL`
|
||||
- `TOP_N_SYMBOLS`
|
||||
- ... 等等所有非风险旋钮的配置
|
||||
|
||||
### 账户配置(每个账户独立)
|
||||
- `MIN_MARGIN_USDT`
|
||||
- `MIN_POSITION_PERCENT`
|
||||
- `MAX_POSITION_PERCENT`
|
||||
- `MAX_TOTAL_POSITION_PERCENT`
|
||||
- `AUTO_TRADE_ENABLED`
|
||||
- `MAX_OPEN_POSITIONS`
|
||||
- `MAX_DAILY_ENTRIES`
|
||||
|
||||
### 账号私有配置(每个账户独立)
|
||||
- `BINANCE_API_KEY`
|
||||
- `BINANCE_API_SECRET`
|
||||
- `USE_TESTNET`
|
||||
|
||||
---
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题1:全局配置无法加载
|
||||
|
||||
**检查**:
|
||||
1. 确认 `global_strategy_config` 表已创建
|
||||
2. 确认表中有数据(执行迁移脚本)
|
||||
3. 检查后端日志,查看是否有错误
|
||||
|
||||
**解决**:
|
||||
```sql
|
||||
-- 检查表是否存在
|
||||
SHOW TABLES LIKE 'global_strategy_config';
|
||||
|
||||
-- 检查表中是否有数据
|
||||
SELECT COUNT(*) FROM global_strategy_config;
|
||||
```
|
||||
|
||||
### 问题2:管理员无法修改全局配置
|
||||
|
||||
**检查**:
|
||||
1. 确认用户角色是 `admin`
|
||||
2. 检查 API 返回的错误信息
|
||||
3. 检查后端日志
|
||||
|
||||
**解决**:
|
||||
```sql
|
||||
-- 检查用户角色
|
||||
SELECT id, username, role FROM users WHERE username = 'your_username';
|
||||
```
|
||||
|
||||
### 问题3:交易系统仍使用旧配置
|
||||
|
||||
**检查**:
|
||||
1. 确认 Redis 缓存已更新
|
||||
2. 确认交易系统已重启
|
||||
3. 检查 `config_manager.py` 是否正确读取全局配置
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 清除 Redis 缓存(可选)
|
||||
redis-cli DEL global_strategy_config
|
||||
|
||||
# 重启交易系统
|
||||
supervisorctl restart all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
- [ ] 数据库迁移脚本已执行
|
||||
- [ ] `global_strategy_config` 表已创建
|
||||
- [ ] 配置数据已迁移
|
||||
- [ ] 后端服务已重启
|
||||
- [ ] 交易系统已重启
|
||||
- [ ] 管理员可以查看全局配置
|
||||
- [ ] 管理员可以修改全局配置
|
||||
- [ ] 普通用户无法访问全局配置
|
||||
- [ ] 所有账户使用相同的全局配置
|
||||
- [ ] Redis 缓存正常工作
|
||||
|
||||
---
|
||||
|
||||
## 📝 总结
|
||||
|
||||
全局配置已完全独立化,不再依赖任何账户。所有核心策略参数由管理员统一管理,存储在独立的 `global_strategy_config` 表中,使用独立的 Redis 缓存键。普通用户只能修改自己账户的风险旋钮,无法影响全局策略。
|
||||
234
docs/archive/OPTIMIZATION_IMPLEMENTATION_SUMMARY.md
Normal file
234
docs/archive/OPTIMIZATION_IMPLEMENTATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# 交易策略优化实施总结
|
||||
|
||||
## ✅ 已完成的优化(高优先级)
|
||||
|
||||
### 实施总结
|
||||
|
||||
已完成5项高优先级优化,显著提升系统风险控制和信号质量:
|
||||
|
||||
1. ✅ **大盘共振(Beta Filter)** - 减少大盘暴跌时的多单损失
|
||||
2. ✅ **成交量验证** - 避免流动性差的币种,减少滑点损失
|
||||
3. ✅ **固定风险百分比仓位计算** - 每笔单子风险恒定(2%),避免30%大额亏损
|
||||
4. ✅ **信号强度分级** - 高质量信号(9-10分)获得更大收益,低质量信号(8分)降低风险
|
||||
5. ✅ **阶梯杠杆** - 小众币风险降低(最大杠杆5倍)
|
||||
|
||||
---
|
||||
|
||||
### 1. ✅ 动态过滤:大盘共振(Beta Filter)
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/strategy.py` - `_check_beta_filter()`, `_get_symbol_change_period()`
|
||||
- `trading_system/strategy.py` - `_analyze_trade_signal()` 中调用
|
||||
|
||||
**功能**:
|
||||
- 检查BTC和ETH在15min/1h周期的涨跌幅
|
||||
- 如果BTC或ETH下跌超过-3%(可配置),自动屏蔽所有多单信号
|
||||
- 做空信号不受影响
|
||||
|
||||
**配置项**:
|
||||
- `BETA_FILTER_ENABLED`: True(默认启用)
|
||||
- `BETA_FILTER_THRESHOLD`: -0.03(-3%)
|
||||
|
||||
### 2. ✅ 成交量验证(严格过滤)
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/market_scanner.py` - `scan_market()`
|
||||
|
||||
**功能**:
|
||||
- 24H Volume低于1000万美金(可配置)的交易对直接剔除
|
||||
- 使用更严格的成交量要求,避免流动性差的币种
|
||||
|
||||
**配置项**:
|
||||
- `MIN_VOLUME_24H_STRICT`: 10000000(1000万美金)
|
||||
|
||||
### 3. ✅ 固定风险百分比仓位计算(凯利公式)
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_position_size()`
|
||||
- `trading_system/position_manager.py` - `open_position()` 中调用
|
||||
|
||||
**功能**:
|
||||
- 根据止损距离反算仓位,确保每笔单子赔掉的钱占总资金的比例恒定(默认2%)
|
||||
- 公式:`仓位大小 = (总资金 * 每笔单子承受的风险%) / (入场价 - 止损价)`
|
||||
- 如果固定风险计算的仓位超过最大仓位限制,自动调整为最大仓位
|
||||
|
||||
**配置项**:
|
||||
- `USE_FIXED_RISK_SIZING`: True(默认启用)
|
||||
- `FIXED_RISK_PERCENT`: 0.02(2%)
|
||||
|
||||
### 4. ✅ 信号强度分级
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_position_size()`
|
||||
- `trading_system/position_manager.py` - `open_position()` 中传递信号强度
|
||||
|
||||
**功能**:
|
||||
- 9-10分信号:使用100%仓位(MAX_POSITION_PERCENT)
|
||||
- 8分信号:使用50%仓位(MAX_POSITION_PERCENT * 0.5)
|
||||
- 提高高质量信号的收益,降低低质量信号的风险
|
||||
|
||||
**配置项**:
|
||||
- `SIGNAL_STRENGTH_POSITION_MULTIPLIER`: {8: 0.5, 9: 1.0, 10: 1.0}
|
||||
|
||||
### 5. ✅ 阶梯杠杆(小众币限制)
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_dynamic_leverage()`
|
||||
- `trading_system/strategy.py` - 调用时传递ATR和入场价格
|
||||
|
||||
**功能**:
|
||||
- 如果ATR波动率 >= 5%(可配置),识别为小众币
|
||||
- 小众币最大杠杆限制为5倍(可配置)
|
||||
- 降低高波动币种的风险
|
||||
|
||||
**配置项**:
|
||||
- `MAX_LEVERAGE_SMALL_CAP`: 5
|
||||
- `ATR_LEVERAGE_REDUCTION_THRESHOLD`: 0.05(5%)
|
||||
|
||||
---
|
||||
|
||||
## ⏳ 待实现的优化(中低优先级)
|
||||
|
||||
### 6. ⏳ 波动率阈值
|
||||
|
||||
**目标**:避开ATR异常激增的时刻
|
||||
|
||||
**实现方案**:
|
||||
- 在 `market_scanner.py` 中计算平均ATR
|
||||
- 如果当前ATR / 平均ATR > 2.0,过滤掉该交易对
|
||||
|
||||
**配置项**:
|
||||
- `ATR_SPIKE_THRESHOLD`: 2.0
|
||||
|
||||
### 7. ⏳ 追踪止损(Trailing Stop)
|
||||
|
||||
**目标**:当价格达到1:1目标后,利用币安Trailing Stop Order或代码层面实现
|
||||
|
||||
**实现方案**:
|
||||
- 检查币安是否支持 `TRAILING_STOP_MARKET` 订单类型
|
||||
- 在分步止盈后,挂币安Trailing Stop Order或代码层面实现
|
||||
|
||||
**配置项**:
|
||||
- `USE_TRAILING_STOP_AFTER_PARTIAL_PROFIT`: True
|
||||
- `TRAILING_STOP_ATR_MULTIPLIER`: 1.5
|
||||
|
||||
### 8. ⏳ ADX趋势强度判断
|
||||
|
||||
**目标**:如果ADX > 25且处于上升趋势,延迟第一止盈位触发或取消50%减仓
|
||||
|
||||
**实现方案**:
|
||||
- 在 `indicators.py` 中计算ADX
|
||||
- 在 `position_manager.py` 的止盈检查中,如果ADX > 25且趋势向上,跳过第一止盈
|
||||
|
||||
**配置项**:
|
||||
- `ADX_STRONG_TREND_THRESHOLD`: 25
|
||||
- `ADX_SKIP_PARTIAL_PROFIT`: True
|
||||
|
||||
### 9. ⏳ 心跳检测与兜底巡检
|
||||
|
||||
**目标**:WebSocket断线重连机制 + 每1-2分钟兜底巡检
|
||||
|
||||
**实现方案**:
|
||||
- 在 `position_manager.py` 的WebSocket监控中增加心跳检测
|
||||
- 增加独立的定时巡检任务(每1-2分钟),作为兜底
|
||||
|
||||
**配置项**:
|
||||
- `WEBSOCKET_HEARTBEAT_INTERVAL`: 30(30秒)
|
||||
- `FALLBACK_CHECK_INTERVAL`: 120(2分钟)
|
||||
|
||||
### 10. ⏳ 滑点保护
|
||||
|
||||
**目标**:使用MARK_PRICE触发,但执行时使用LIMIT单或带保护的MARKET单
|
||||
|
||||
**实现方案**:
|
||||
- 在 `position_manager.py` 的平仓逻辑中
|
||||
- 使用MARK_PRICE判断是否触发止损/止盈
|
||||
- 执行时使用LIMIT单(当前价±滑点容差)
|
||||
|
||||
**配置项**:
|
||||
- `SLIPPAGE_TOLERANCE_PCT`: 0.002(0.2%)
|
||||
- `USE_LIMIT_ON_CLOSE`: True
|
||||
|
||||
### 11. ⏳ 资金费率避险
|
||||
|
||||
**目标**:在费率结算前(8:00, 16:00, 24:00),如果费率过高(>0.1%),提前止盈或暂缓入场
|
||||
|
||||
**实现方案**:
|
||||
- 在 `binance_client.py` 中获取资金费率
|
||||
- 在 `strategy.py` 中检查是否接近结算时间
|
||||
- 如果费率 > 0.1%,提前止盈或暂缓入场
|
||||
|
||||
**配置项**:
|
||||
- `FUNDING_RATE_THRESHOLD`: 0.001(0.1%)
|
||||
- `FUNDING_RATE_EARLY_EXIT_HOURS`: 1(结算前1小时)
|
||||
|
||||
---
|
||||
|
||||
## 📊 配置项汇总
|
||||
|
||||
所有新增配置项已添加到 `trading_system/config.py` 的 `_get_trading_config()` 函数中:
|
||||
|
||||
```python
|
||||
# 动态过滤
|
||||
'BETA_FILTER_ENABLED': True,
|
||||
'BETA_FILTER_THRESHOLD': -0.03, # -3%
|
||||
'MIN_VOLUME_24H_STRICT': 10000000, # 1000万美金
|
||||
'SIGNAL_STRENGTH_POSITION_MULTIPLIER': {8: 0.5, 9: 1.0, 10: 1.0},
|
||||
|
||||
# 仓位管理
|
||||
'USE_FIXED_RISK_SIZING': True,
|
||||
'FIXED_RISK_PERCENT': 0.02, # 2%
|
||||
'MAX_LEVERAGE_SMALL_CAP': 5,
|
||||
'ATR_LEVERAGE_REDUCTION_THRESHOLD': 0.05, # 5%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期效果
|
||||
|
||||
### 已实现优化的预期效果:
|
||||
|
||||
1. **大盘共振过滤**:
|
||||
- ✅ 减少在大盘暴跌时的多单损失
|
||||
- ✅ 提高整体胜率
|
||||
|
||||
2. **成交量验证**:
|
||||
- ✅ 避免流动性差的币种
|
||||
- ✅ 减少滑点损失(2-3%)
|
||||
|
||||
3. **固定风险百分比**:
|
||||
- ✅ 每笔单子风险恒定(2%),避免30%的大额亏损
|
||||
- ✅ 根据止损距离自动调整仓位,更科学
|
||||
|
||||
4. **信号强度分级**:
|
||||
- ✅ 高质量信号(9-10分)获得更大收益
|
||||
- ✅ 低质量信号(8分)降低风险
|
||||
|
||||
5. **阶梯杠杆**:
|
||||
- ✅ 小众币风险降低(最大杠杆5倍)
|
||||
- ✅ 减少因高杠杆导致的强平风险
|
||||
|
||||
---
|
||||
|
||||
## 📝 使用说明
|
||||
|
||||
### 管理员配置
|
||||
|
||||
所有优化配置项都可以在 `GlobalConfig` 页面中配置:
|
||||
- 大盘共振过滤:`BETA_FILTER_ENABLED`, `BETA_FILTER_THRESHOLD`
|
||||
- 成交量验证:`MIN_VOLUME_24H_STRICT`
|
||||
- 固定风险百分比:`USE_FIXED_RISK_SIZING`, `FIXED_RISK_PERCENT`
|
||||
- 信号强度分级:`SIGNAL_STRENGTH_POSITION_MULTIPLIER`
|
||||
- 阶梯杠杆:`MAX_LEVERAGE_SMALL_CAP`, `ATR_LEVERAGE_REDUCTION_THRESHOLD`
|
||||
|
||||
### 默认值
|
||||
|
||||
所有优化默认启用,使用推荐的参数值。管理员可以根据实际情况调整。
|
||||
|
||||
---
|
||||
|
||||
## 🔄 后续优化建议
|
||||
|
||||
1. **监控效果**:观察优化后的实际效果,根据数据调整参数
|
||||
2. **逐步实现**:剩余优化可以根据实际需求逐步实现
|
||||
3. **测试验证**:建议在测试环境或小资金账户先测试
|
||||
250
docs/archive/QUICK_APPLY_ALTCOIN_STRATEGY.md
Normal file
250
docs/archive/QUICK_APPLY_ALTCOIN_STRATEGY.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# 山寨币策略快速应用指南
|
||||
|
||||
> 5分钟内完成配置更新和验证
|
||||
|
||||
## 🚀 快速应用步骤
|
||||
|
||||
### 步骤1:确认代码已更新(✅ 已完成)
|
||||
|
||||
已更新的文件:
|
||||
- ✅ `trading_system/config.py` - 核心配置
|
||||
- ✅ `trading_system/trade_recommender.py` - 推荐生成
|
||||
- ✅ `trading_system/position_manager.py` - 持仓管理
|
||||
|
||||
### 步骤2:重启所有进程(⚡ 立即执行)
|
||||
|
||||
```bash
|
||||
# 1. 重启所有交易进程
|
||||
supervisorctl restart auto_sys:*
|
||||
|
||||
# 2. 重启推荐服务
|
||||
supervisorctl restart auto_recommend:*
|
||||
|
||||
# 3. 确认进程状态
|
||||
supervisorctl status
|
||||
```
|
||||
|
||||
### 步骤3:验证配置生效(🔍 关键检查)
|
||||
|
||||
查看日志,确认以下关键参数:
|
||||
|
||||
```bash
|
||||
# 查看最新日志
|
||||
tail -n 100 /www/wwwroot/autosys_new/logs/trading_*.log | grep -E "ATR_STOP_LOSS_MULTIPLIER|RISK_REWARD_RATIO|MIN_HOLD_TIME_SEC|USE_TRAILING_STOP|MAX_POSITION_PERCENT"
|
||||
```
|
||||
|
||||
应该看到:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER: 2.0`
|
||||
- `RISK_REWARD_RATIO: 4.0`
|
||||
- `MIN_HOLD_TIME_SEC: 0`
|
||||
- `USE_TRAILING_STOP: True`
|
||||
- `MAX_POSITION_PERCENT: 0.015`
|
||||
|
||||
### 步骤4:清理旧配置缓存(可选)
|
||||
|
||||
如果配置没有生效,可能需要清理Redis缓存:
|
||||
|
||||
```bash
|
||||
# 方法1:通过backend API清理(推荐)
|
||||
curl -X POST "http://your-api-domain/api/config/clear-cache"
|
||||
|
||||
# 方法2:直接重启Redis(谨慎!)
|
||||
# supervisorctl restart redis
|
||||
```
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
使用这个清单逐项验证:
|
||||
|
||||
### 风险控制
|
||||
- [ ] ATR止损倍数 = 2.0(日志确认)
|
||||
- [ ] 固定止损 = 15%(日志确认)
|
||||
- [ ] 盈亏比 = 4.0(日志确认)
|
||||
- [ ] 最小持仓时间 = 0秒(已取消)
|
||||
- [ ] 每笔风险 = 1%
|
||||
|
||||
### 止盈策略
|
||||
- [ ] 移动止损已启用
|
||||
- [ ] 移动止损激活 = 30%
|
||||
- [ ] 移动止损保护 = 15%
|
||||
- [ ] 第一目标盈亏比 = 1:1
|
||||
- [ ] 第二目标盈亏比 = 4:1
|
||||
|
||||
### 仓位管理
|
||||
- [ ] 单笔仓位 ≤ 1.5%
|
||||
- [ ] 总仓位 ≤ 12%
|
||||
- [ ] 最大同时持仓 = 4个
|
||||
- [ ] 基础杠杆 = 8倍
|
||||
- [ ] 最大杠杆 = 12倍
|
||||
|
||||
### 交易控制
|
||||
- [ ] 每日最多5笔
|
||||
- [ ] 智能入场已启用
|
||||
- [ ] 币种冷却 = 30分钟
|
||||
- [ ] 只做趋势市(AUTO_TRADE_ONLY_TRENDING = True)
|
||||
|
||||
### 品种筛选
|
||||
- [ ] 24H成交量 ≥ 3000万美元
|
||||
- [ ] 最小波动率 ≥ 3%
|
||||
- [ ] 最多扫描150个
|
||||
- [ ] 只做前5个最强信号
|
||||
|
||||
### 时间框架
|
||||
- [ ] 主周期 = 4小时
|
||||
- [ ] 入场周期 = 1小时
|
||||
- [ ] 确认周期 = 日线
|
||||
- [ ] 扫描间隔 = 1小时
|
||||
|
||||
## 🔧 如果配置未生效
|
||||
|
||||
### 情况1:进程重启失败
|
||||
|
||||
```bash
|
||||
# 查看错误日志
|
||||
tail -n 50 /www/wwwroot/autosys_new/logs/trading_*.err.log
|
||||
|
||||
# 常见问题:
|
||||
# - 代码语法错误:检查最近修改的代码
|
||||
# - 数据库连接失败:检查数据库状态
|
||||
# - Redis连接失败:检查Redis状态
|
||||
```
|
||||
|
||||
### 情况2:配置值仍是旧值
|
||||
|
||||
```bash
|
||||
# 强制重新加载配置
|
||||
# 在Python代码中调用:
|
||||
# config._config_manager.reload_from_redis()
|
||||
# 或重启backend服务:
|
||||
supervisorctl restart backend
|
||||
```
|
||||
|
||||
### 情况3:部分配置生效,部分未生效
|
||||
|
||||
```bash
|
||||
# 检查数据库中的配置(可能有冲突)
|
||||
# 使用backend管理界面或直接查询数据库:
|
||||
# SELECT * FROM trading_config WHERE config_key LIKE '%ATR%' OR config_key LIKE '%RISK%';
|
||||
```
|
||||
|
||||
## 📊 监控前3笔交易
|
||||
|
||||
策略更新后,密切监控前3笔交易的关键数据:
|
||||
|
||||
```
|
||||
第1笔交易:
|
||||
- 开仓价格:_______
|
||||
- 止损价格:_______(应该是开仓价的±15%左右)
|
||||
- 止盈价格:_______(应该是止损距离的4倍)
|
||||
- 实际杠杆:_______(应该是8倍左右)
|
||||
- 保证金占比:_______(应该≤1.5%)
|
||||
|
||||
第2笔交易:
|
||||
- 开仓价格:_______
|
||||
- 止损价格:_______
|
||||
- 止盈价格:_______
|
||||
- 实际杠杆:_______
|
||||
- 保证金占比:_______
|
||||
|
||||
第3笔交易:
|
||||
- 开仓价格:_______
|
||||
- 止损价格:_______
|
||||
- 止盈价格:_______
|
||||
- 实际杠杆:_______
|
||||
- 保证金占比:_______
|
||||
```
|
||||
|
||||
### 异常判断标准
|
||||
|
||||
如果出现以下情况,立即暂停并检查:
|
||||
- ❌ 止损距离 < 10%或 > 20%
|
||||
- ❌ 盈亏比 < 3:1
|
||||
- ❌ 单笔保证金 > 2%
|
||||
- ❌ 杠杆 > 12倍
|
||||
- ❌ 同时持仓 > 4个
|
||||
- ❌ 触发止损但仍在持仓(说明止损未生效)
|
||||
|
||||
## 🎯 预期效果(3-5天后)
|
||||
|
||||
如果策略正确执行,应该看到:
|
||||
|
||||
### 短期指标(1-2天)
|
||||
- 胜率:30-40%(初期可能偏低,正常)
|
||||
- 单笔盈亏:盈利单平均+4%,亏损单平均-1%
|
||||
- 交易频率:每日2-5笔
|
||||
- 持仓时间:1-4小时
|
||||
|
||||
### 中期指标(3-5天)
|
||||
- 胜率:35-45%
|
||||
- 盈亏比:3.5:1 - 4.5:1
|
||||
- 期望值:+0.5% - +1.0%(每笔)
|
||||
- 最大回撤:单日 < 3%
|
||||
|
||||
### 预警信号
|
||||
|
||||
如果出现以下情况,说明需要调整:
|
||||
- ⚠️ 胜率 < 25%:提高MIN_SIGNAL_STRENGTH到8
|
||||
- ⚠️ 盈亏比 < 3:1:检查止盈设置
|
||||
- ⚠️ 单日亏损 > 5%:暂停交易,检查市场环境
|
||||
- ⚠️ 连续亏损 > 5笔:暂停交易,等待市场转好
|
||||
|
||||
## 📞 问题排查
|
||||
|
||||
### 问题1:配置更新后没有新交易
|
||||
|
||||
**可能原因:**
|
||||
- 信号强度要求提高(MIN_SIGNAL_STRENGTH=7)
|
||||
- 成交量要求提高(MIN_VOLUME_24H=3000万)
|
||||
- 市场不满足AUTO_TRADE_ONLY_TRENDING条件
|
||||
|
||||
**解决方案:**
|
||||
- 查看推荐服务日志,确认是否有新推荐生成
|
||||
- 检查当前市场是否处于趋势中
|
||||
- 如果长期没有交易,可以临时降低MIN_SIGNAL_STRENGTH到6
|
||||
|
||||
### 问题2:止损触发太频繁
|
||||
|
||||
**可能原因:**
|
||||
- ATR_STOP_LOSS_MULTIPLIER太小
|
||||
- 选择的币种波动过大
|
||||
|
||||
**解决方案:**
|
||||
- 提高ATR_STOP_LOSS_MULTIPLIER到2.2或2.5
|
||||
- 提高MIN_VOLATILITY筛选标准
|
||||
- 检查是否在异常波动期间交易
|
||||
|
||||
### 问题3:盈利单无法达到TP2
|
||||
|
||||
**可能原因:**
|
||||
- 盈亏比4:1对当前市场环境过高
|
||||
- 移动止损激活过早
|
||||
|
||||
**解决方案:**
|
||||
- 降低RISK_REWARD_RATIO到3.0或3.5
|
||||
- 提高TRAILING_STOP_ACTIVATION到40%
|
||||
- 观察是否有盈利单达到30%但未触发移动止损
|
||||
|
||||
## 🔄 后续优化
|
||||
|
||||
根据实际运行情况,可能需要微调:
|
||||
|
||||
### 1周后可能的调整
|
||||
- MIN_SIGNAL_STRENGTH:6.5 - 8
|
||||
- ATR_STOP_LOSS_MULTIPLIER:1.8 - 2.2
|
||||
- RISK_REWARD_RATIO:3.5 - 4.5
|
||||
- TRAILING_STOP_ACTIVATION:25% - 35%
|
||||
|
||||
### 1个月后可能的调整
|
||||
- 建立币种白名单/黑名单
|
||||
- 按市值分级设置不同参数
|
||||
- 添加BTC趋势过滤
|
||||
|
||||
---
|
||||
|
||||
**最后提醒**:
|
||||
1. 🚨 配置更新后前3笔交易必须人工监控
|
||||
2. 📊 每日检查盈亏比和期望值是否符合预期
|
||||
3. ⚡ 如有异常立即暂停交易并检查日志
|
||||
4. 📈 坚持记录每笔交易数据,持续优化
|
||||
|
||||
**祝交易顺利!**
|
||||
198
docs/archive/QUICK_PRESET_RECOMMENDATION.md
Normal file
198
docs/archive/QUICK_PRESET_RECOMMENDATION.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# 快速方案选择建议
|
||||
|
||||
## 📊 当前可用的快速方案
|
||||
|
||||
根据你的系统已完成的优化(大盘共振、固定风险百分比、信号强度分级、阶梯杠杆),以下是各方案的适用场景:
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐方案(按优先级)
|
||||
|
||||
### ⭐⭐⭐ **首选:波段回归(swing)**
|
||||
|
||||
**适用场景**:
|
||||
- ✅ 刚完成优化,想验证效果
|
||||
- ✅ 追求稳定盈利,不追求高频
|
||||
- ✅ 能接受"可能撤单"的情况
|
||||
|
||||
**核心特点**:
|
||||
- `SMART_ENTRY_ENABLED: false` - 纯限价单,不追价
|
||||
- `MIN_SIGNAL_STRENGTH: 8` - 高质量信号(配合信号强度分级,8分用50%仓位)
|
||||
- `SCAN_INTERVAL: 1800` - 30分钟扫描,低频波段
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER: 1.5` - 已优化为1.5:1盈亏比
|
||||
- `MAX_POSITION_PERCENT: 2.0%` - 配合固定风险百分比,每笔风险恒定
|
||||
|
||||
**优势**:
|
||||
- ✅ 与最新优化最匹配(固定风险百分比、信号强度分级)
|
||||
- ✅ 避免高频追价导致的损失
|
||||
- ✅ 高质量信号,胜率更高
|
||||
- ✅ 配合大盘共振过滤,减少大盘暴跌时的损失
|
||||
|
||||
**注意事项**:
|
||||
- ⚠️ 可能因为限价单未成交而撤单(这是正常的,避免追价损失)
|
||||
- ⚠️ 交易频率较低,需要耐心
|
||||
|
||||
---
|
||||
|
||||
### ⭐⭐ **次选:精选低频(strict)**
|
||||
|
||||
**适用场景**:
|
||||
- ✅ 追求最高胜率
|
||||
- ✅ 只做趋势行情
|
||||
- ✅ 能接受更少的交易次数
|
||||
|
||||
**核心特点**:
|
||||
- `AUTO_TRADE_ONLY_TRENDING: true` - 仅趋势行情自动交易
|
||||
- `AUTO_TRADE_ALLOW_4H_NEUTRAL: false` - 4H中性不自动下单
|
||||
- `MIN_SIGNAL_STRENGTH: 8` - 高质量信号
|
||||
- `SMART_ENTRY_ENABLED: false` - 纯限价单
|
||||
- `LIMIT_ORDER_OFFSET_PCT: 0.1` - 限价偏移较小,更容易成交
|
||||
|
||||
**优势**:
|
||||
- ✅ 胜率最高(只做趋势行情)
|
||||
- ✅ 避免震荡市亏损
|
||||
- ✅ 配合大盘共振过滤,效果更好
|
||||
|
||||
**注意事项**:
|
||||
- ⚠️ 交易次数最少
|
||||
- ⚠️ 如果市场长期震荡,可能很久不出单
|
||||
|
||||
---
|
||||
|
||||
### ⭐ **备选:成交优先(fill)**
|
||||
|
||||
**适用场景**:
|
||||
- ✅ 发现"波段回归"方案撤单太多
|
||||
- ✅ 想要更多成交,但不想回到高频追价
|
||||
- ✅ 能接受有限的追价(有上限保护)
|
||||
|
||||
**核心特点**:
|
||||
- `SMART_ENTRY_ENABLED: true` - 智能入场,有限追价
|
||||
- `ENTRY_CHASE_MAX_STEPS: 2` - 最多追价2步(严格限制)
|
||||
- `ENTRY_MAX_DRIFT_PCT_TRENDING: 0.3` - 追价上限30%(有保护)
|
||||
- `MIN_SIGNAL_STRENGTH: 7` - 信号门槛略低
|
||||
- `AUTO_TRADE_ONLY_TRENDING: false` - 解锁自动交易过滤
|
||||
|
||||
**优势**:
|
||||
- ✅ 成交率更高,减少撤单
|
||||
- ✅ 追价有严格限制,不会回到高频追价
|
||||
- ✅ 配合固定风险百分比,风险可控
|
||||
|
||||
**注意事项**:
|
||||
- ⚠️ 追价可能增加成本(但有限制)
|
||||
- ⚠️ 信号门槛略低,需要配合信号强度分级使用
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 不推荐(除非特殊需求)
|
||||
|
||||
### 稳定出单(steady)
|
||||
- **问题**:`MIN_SIGNAL_STRENGTH: 6` 太低,信号质量差
|
||||
- **问题**:`SCAN_INTERVAL: 900` 15分钟扫描,频率较高
|
||||
- **建议**:除非你发现其他方案完全不出单,否则不推荐
|
||||
|
||||
### 传统方案(conservative/balanced/aggressive)
|
||||
- **问题**:这些方案没有应用最新的优化(固定风险百分比、信号强度分级等)
|
||||
- **问题**:`ATR_TAKE_PROFIT_MULTIPLIER` 可能还是旧值
|
||||
- **建议**:仅用于对比测试,不建议长期使用
|
||||
|
||||
---
|
||||
|
||||
## 🎯 选择决策树
|
||||
|
||||
```
|
||||
开始
|
||||
│
|
||||
├─ 是否刚完成优化,想验证效果?
|
||||
│ └─ 是 → 选择「波段回归(swing)」
|
||||
│
|
||||
├─ 是否追求最高胜率,能接受很少交易?
|
||||
│ └─ 是 → 选择「精选低频(strict)」
|
||||
│
|
||||
├─ 是否发现「波段回归」撤单太多?
|
||||
│ └─ 是 → 选择「成交优先(fill)」
|
||||
│
|
||||
└─ 其他情况 → 选择「波段回归(swing)」
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 方案对比表
|
||||
|
||||
| 方案 | 信号门槛 | 入场机制 | 交易频率 | 胜率倾向 | 推荐度 |
|
||||
|------|---------|---------|---------|---------|--------|
|
||||
| **波段回归(swing)** | 8分 | 纯限价 | 低频 | 高 | ⭐⭐⭐ |
|
||||
| **精选低频(strict)** | 8分 | 纯限价 | 最低 | 最高 | ⭐⭐ |
|
||||
| **成交优先(fill)** | 7分 | 智能入场(有限) | 中频 | 中高 | ⭐ |
|
||||
| **稳定出单(steady)** | 6分 | 智能入场 | 中高频 | 中 | ⚠️ |
|
||||
| **传统方案** | 3-5分 | 混合 | 高频 | 低 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 💡 最终建议
|
||||
|
||||
### 第一步:先用「波段回归(swing)」
|
||||
|
||||
**理由**:
|
||||
1. ✅ 与最新优化最匹配(固定风险百分比、信号强度分级、大盘共振)
|
||||
2. ✅ 高质量信号(8分),配合信号强度分级,8分用50%仓位
|
||||
3. ✅ 纯限价单,避免追价损失
|
||||
4. ✅ 已优化为1.5:1盈亏比,更容易止盈
|
||||
|
||||
**观察期**:运行20-30单,观察:
|
||||
- 胜率是否提升
|
||||
- 是否出现30%以上的大额亏损(应该不会,因为有固定风险百分比)
|
||||
- 撤单率是否过高
|
||||
|
||||
### 第二步:根据观察结果调整
|
||||
|
||||
**如果撤单太多**:
|
||||
- 切换到「成交优先(fill)」
|
||||
- 或手动调整 `LIMIT_ORDER_OFFSET_PCT` 从 0.5% 降到 0.1%
|
||||
|
||||
**如果交易太少**:
|
||||
- 切换到「精选低频(strict)」
|
||||
- 或手动调整 `AUTO_TRADE_ONLY_TRENDING: false`
|
||||
|
||||
**如果胜率不够**:
|
||||
- 保持「波段回归(swing)」
|
||||
- 或切换到「精选低频(strict)」
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配合最新优化的配置
|
||||
|
||||
无论选择哪个方案,以下配置已自动应用(在 `config.py` 中):
|
||||
|
||||
- ✅ `BETA_FILTER_ENABLED: True` - 大盘共振过滤
|
||||
- ✅ `MIN_VOLUME_24H_STRICT: 10000000` - 成交量验证(1000万美金)
|
||||
- ✅ `USE_FIXED_RISK_SIZING: True` - 固定风险百分比(2%)
|
||||
- ✅ `SIGNAL_STRENGTH_POSITION_MULTIPLIER: {8: 0.5, 9: 1.0, 10: 1.0}` - 信号强度分级
|
||||
- ✅ `MAX_LEVERAGE_SMALL_CAP: 5` - 小众币杠杆限制
|
||||
|
||||
这些优化会在所有方案中生效,进一步提升系统表现。
|
||||
|
||||
---
|
||||
|
||||
## 📊 预期效果
|
||||
|
||||
使用「波段回归(swing)」+ 最新优化,预期:
|
||||
|
||||
- ✅ **胜率**:提升(高质量信号 + 大盘共振过滤)
|
||||
- ✅ **单笔亏损**:控制在2%(固定风险百分比)
|
||||
- ✅ **大额亏损**:避免30%以上的亏损(固定风险百分比 + 阶梯杠杆)
|
||||
- ✅ **滑点损失**:减少2-3%(成交量验证)
|
||||
- ✅ **大盘暴跌损失**:减少(大盘共振过滤)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
**推荐顺序**:
|
||||
1. **首选**:波段回归(swing)
|
||||
2. **次选**:精选低频(strict)
|
||||
3. **备选**:成交优先(fill)
|
||||
|
||||
**不推荐**:稳定出单、传统方案
|
||||
|
||||
**建议**:先用「波段回归(swing)」跑20-30单,根据实际效果再调整。
|
||||
20
docs/archive/README.md
Normal file
20
docs/archive/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# 归档文档说明
|
||||
|
||||
本目录为 **历史/一次性** 文档,已从 `docs/` 根目录移入,避免日常阅读与 AI 整理时干扰。
|
||||
|
||||
## 归档内容概览
|
||||
|
||||
- **按日期的一次性分析**:如 2026-01-23 ~ 2026-02-04 的交易分析、亏损分析、策略执行分析等
|
||||
- **已完成实施的总结**:如「配置优化实施完成总结」「分步止盈状态细分实施完成总结」「ATR 配置优化完成总结」等
|
||||
- **多版本文案只保留最终版后**:如「配置值格式统一」多个版本、「分步止盈」多篇分析
|
||||
- **单次修复/单币种分析**:如止损失效修复、某币种止损价错误分析、WebSocket/Redis 修复说明等
|
||||
- **旧计划与建议**:如 newplan20260115、策略优化建议评估与实施方案等
|
||||
|
||||
## 使用方式
|
||||
|
||||
- 需要查 **当时为什么这样改** 或 **某次问题结论** 时,可在此目录按文件名或日期查找
|
||||
- 当前策略与配置以 **docs/当前策略方案总结_2026-02-15.md** 和 **docs/INDEX.md** 为准
|
||||
|
||||
## 归档时间
|
||||
|
||||
2026-02-15
|
||||
75
docs/archive/RECOMMENDATION_SERVICE_API_KEY_FIX.md
Normal file
75
docs/archive/RECOMMENDATION_SERVICE_API_KEY_FIX.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# 推荐服务 API Key 修复说明
|
||||
|
||||
## 问题描述
|
||||
|
||||
推荐服务(`recommendations_main.py`)仍然在使用真实的 API key,导致可能使用错误的账户(如 account_id=2)进行下单。
|
||||
|
||||
## 根本原因
|
||||
|
||||
1. **推荐服务不应该使用任何账户的 API key**:推荐服务只需要获取公开行情数据,不需要认证。
|
||||
2. **`config.py` 在导入时会读取 `ATS_ACCOUNT_ID`**:如果推荐服务的 supervisor 配置中设置了 `ATS_ACCOUNT_ID=2`,那么 `config.py` 会读取 account_id=2 的 API key。
|
||||
3. **`BinanceClient.__init__` 可能被覆盖**:即使传入空字符串,如果 `config._config_manager` 存在,可能会在某个地方被覆盖。
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 1. 修复 `BinanceClient.__init__` 逻辑
|
||||
|
||||
确保传入空字符串时不会被 config 覆盖:
|
||||
|
||||
```python
|
||||
# 如果传入的是空字符串,保持为空字符串(不覆盖)
|
||||
# 这样推荐服务可以使用空字符串来明确表示"只使用公开接口"
|
||||
```
|
||||
|
||||
### 2. 修复 `connect` 方法
|
||||
|
||||
当 API key 为空时,跳过权限验证:
|
||||
|
||||
```python
|
||||
# 验证API密钥权限(仅当提供了有效的 API key 时)
|
||||
if self.api_key and self.api_secret:
|
||||
await self._verify_api_permissions()
|
||||
else:
|
||||
logger.info("✓ 使用公开 API,跳过权限验证(只能获取行情数据)")
|
||||
```
|
||||
|
||||
### 3. 在推荐服务中添加验证
|
||||
|
||||
在 `recommendations_main.py` 中添加验证逻辑,确保 API key 确实是空的:
|
||||
|
||||
```python
|
||||
# 验证:确保 API key 确实是空的
|
||||
if client.api_key:
|
||||
logger.error(f"❌ 推荐服务 API Key 非空!当前值: {client.api_key[:4]}...")
|
||||
logger.error(" 这可能导致推荐服务使用错误的账户密钥,请检查 BinanceClient.__init__ 逻辑")
|
||||
else:
|
||||
logger.info("✓ 推荐服务 API Key 确认为空,将只使用公开接口")
|
||||
```
|
||||
|
||||
## 检查清单
|
||||
|
||||
1. ✅ 确保 `recommendations_main.py` 传入空字符串:`BinanceClient(api_key="", api_secret="")`
|
||||
2. ✅ 确保 `BinanceClient.__init__` 不会覆盖空字符串
|
||||
3. ✅ 确保 `connect` 方法在 API key 为空时跳过权限验证
|
||||
4. ✅ 在推荐服务中添加验证逻辑,确保 API key 确实是空的
|
||||
|
||||
## 验证方法
|
||||
|
||||
1. 查看推荐服务的日志,确认显示:
|
||||
- `✓ 推荐服务 API Key 确认为空,将只使用公开接口`
|
||||
- `✓ 使用公开 API,跳过权限验证(只能获取行情数据)`
|
||||
|
||||
2. 如果看到以下日志,说明仍有问题:
|
||||
- `❌ 推荐服务 API Key 非空!`
|
||||
- `初始化币安客户端: gqtx...sYmj, l3IB...I6NA`(显示真实的 API key)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **推荐服务不应该设置 `ATS_ACCOUNT_ID`**:推荐服务的 supervisor 配置不应该设置 `ATS_ACCOUNT_ID`,或者应该明确设置为空。
|
||||
2. **推荐服务不应该下单**:推荐服务只生成推荐,不应该进行任何下单操作。
|
||||
3. **如果推荐服务仍然使用真实的 API key**:检查 supervisor 配置,确保推荐服务进程没有设置 `ATS_ACCOUNT_ID`。
|
||||
|
||||
## 后续优化
|
||||
|
||||
1. 考虑在 `config.py` 中添加一个标志,区分推荐服务和交易服务。
|
||||
2. 考虑在推荐服务启动时,明确清除 `ATS_ACCOUNT_ID` 环境变量。
|
||||
166
docs/archive/Redis缓存问题修复说明.md
Normal file
166
docs/archive/Redis缓存问题修复说明.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Redis缓存问题修复说明
|
||||
|
||||
## 🔍 问题分析
|
||||
|
||||
即使执行了数据迁移,日志中仍然显示格式转换警告。原因是:
|
||||
|
||||
1. **Redis缓存中还有旧数据**:即使数据库已经迁移为比例形式(0.30),Redis缓存中可能还存储着百分比形式(30)
|
||||
2. **格式转换后没有更新缓存**:当检测到值>1时,代码会转换为比例形式(0.30),但转换后的值没有写回Redis缓存
|
||||
3. **下次读取时再次触发转换**:下次从Redis读取时,又会读到旧值(30),再次触发转换
|
||||
|
||||
---
|
||||
|
||||
## ✅ 修复方案
|
||||
|
||||
### 方案1:在格式转换时更新Redis缓存(已实现)
|
||||
|
||||
**修改位置**:`backend/config_manager.py:756-777`
|
||||
|
||||
**修复逻辑**:
|
||||
```python
|
||||
if value > 1:
|
||||
old_value = value
|
||||
value = value / 100.0
|
||||
logger.warning(...)
|
||||
# ⚠️ 关键修复:转换后立即更新Redis缓存
|
||||
try:
|
||||
if key in RISK_KNOBS_KEYS:
|
||||
# 风险旋钮:更新当前账号的Redis缓存
|
||||
self._set_to_redis(key, value)
|
||||
self._cache[key] = value
|
||||
else:
|
||||
# 全局配置:更新全局配置的Redis缓存
|
||||
global_config_mgr._set_to_redis(key, value)
|
||||
global_config_mgr._cache[key] = value
|
||||
except Exception as e:
|
||||
logger.debug(f"更新Redis缓存失败(不影响使用): {key} = {e}")
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 转换后的值(0.30)会立即写回Redis缓存
|
||||
- ✅ 下次读取时,直接从Redis读取到正确的值(0.30),不再触发转换
|
||||
- ✅ 警告日志会逐渐减少,直到所有缓存都更新完成
|
||||
|
||||
---
|
||||
|
||||
### 方案2:手动清除Redis缓存(推荐配合使用)
|
||||
|
||||
**执行命令**:
|
||||
```bash
|
||||
# 清除所有配置缓存
|
||||
redis-cli DEL "config:trading_config:*"
|
||||
redis-cli DEL "config:global_strategy_config"
|
||||
|
||||
# 或者清除特定账号的缓存
|
||||
redis-cli DEL "config:trading_config:1"
|
||||
redis-cli DEL "config:trading_config:2"
|
||||
redis-cli DEL "config:trading_config:3"
|
||||
redis-cli DEL "config:trading_config:4"
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 强制系统从数据库重新加载配置
|
||||
- ✅ 如果数据库已经迁移,加载的将是正确的比例形式(0.30)
|
||||
- ✅ 新的配置值会写入Redis缓存
|
||||
|
||||
---
|
||||
|
||||
## 🔧 实施步骤
|
||||
|
||||
### 步骤1:应用代码修复(已完成)
|
||||
|
||||
代码已经修复,格式转换时会自动更新Redis缓存。
|
||||
|
||||
### 步骤2:清除Redis缓存(推荐)
|
||||
|
||||
```bash
|
||||
# 清除所有配置缓存
|
||||
redis-cli DEL "config:trading_config:*"
|
||||
redis-cli DEL "config:global_strategy_config"
|
||||
```
|
||||
|
||||
### 步骤3:重启服务
|
||||
|
||||
```bash
|
||||
# 重启后端服务
|
||||
supervisorctl restart backend
|
||||
|
||||
# 重启交易进程
|
||||
supervisorctl restart auto_sys_acc1 auto_sys_acc2 auto_sys_acc3 auto_sys_acc4
|
||||
```
|
||||
|
||||
### 步骤4:验证
|
||||
|
||||
**检查日志**:
|
||||
```bash
|
||||
# 查看日志,确认格式转换警告逐渐减少
|
||||
tail -f /www/wwwroot/autosys_new/logs/trading_*.log | grep "配置值格式转换"
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 第一次读取时,可能会看到格式转换警告(从Redis读取到旧值)
|
||||
- ✅ 转换后,值会写回Redis缓存
|
||||
- ✅ 下次读取时,不再触发转换,警告消失
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据流
|
||||
|
||||
### 修复前
|
||||
|
||||
```
|
||||
从Redis读取:30(旧数据)
|
||||
↓
|
||||
格式转换:30 -> 0.30
|
||||
↓
|
||||
使用:0.30
|
||||
↓
|
||||
⚠️ Redis缓存中还是30(没有更新)
|
||||
↓
|
||||
下次读取:30(再次触发转换)
|
||||
```
|
||||
|
||||
### 修复后
|
||||
|
||||
```
|
||||
从Redis读取:30(旧数据)
|
||||
↓
|
||||
格式转换:30 -> 0.30
|
||||
↓
|
||||
更新Redis缓存:0.30 ✅
|
||||
↓
|
||||
使用:0.30
|
||||
↓
|
||||
下次读取:0.30(不再触发转换)✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
### 修复内容
|
||||
|
||||
1. **代码修复**:格式转换时自动更新Redis缓存
|
||||
2. **手动清除**:清除Redis缓存,强制从数据库重新加载
|
||||
|
||||
### 效果
|
||||
|
||||
- ✅ 格式转换警告会逐渐减少
|
||||
- ✅ Redis缓存会自动更新为正确的值
|
||||
- ✅ 下次读取时不再触发转换
|
||||
|
||||
### 建议
|
||||
|
||||
1. **立即清除Redis缓存**:确保从数据库加载最新数据
|
||||
2. **重启服务**:让新代码生效
|
||||
3. **监控日志**:确认警告逐渐减少
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最终效果
|
||||
|
||||
- ✅ 数据库中统一存储比例形式(0.30)
|
||||
- ✅ Redis缓存中也是比例形式(0.30)
|
||||
- ✅ 前端直接显示小数(0.30),不带%符号
|
||||
- ✅ 后端直接使用(0.30),不需要转换
|
||||
- ✅ 日志中不再出现格式转换警告
|
||||
130
docs/archive/SELL单止损价格计算错误修复.md
Normal file
130
docs/archive/SELL单止损价格计算错误修复.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# SELL单止损价格计算错误修复
|
||||
|
||||
## 🚨 严重问题
|
||||
|
||||
### 问题描述
|
||||
|
||||
SELL单(做空)出现巨额亏损(-91.93%),原因是止损价格计算逻辑错误,选择了"更宽松"的止损(更远离入场价),而不是"更紧"的止损(更接近入场价)。
|
||||
|
||||
### 具体案例
|
||||
|
||||
**AXLUSDT SELL 单(交易ID: 1727)**:
|
||||
- 入场价:0.0731
|
||||
- 出场价:0.0815
|
||||
- 方向:SELL(做空)
|
||||
- 盈亏比例:-91.93%(几乎亏光保证金)
|
||||
|
||||
**问题分析**:
|
||||
- 做空单,价格从0.0731涨到0.0815,涨幅11.22%
|
||||
- 如果止损价格正确(更接近入场价,比如0.075),应该在价格涨到0.075时止损,亏损约5%
|
||||
- 但实际亏损-91.93%,说明止损价格设置错误,选择了"更宽松"的止损(更远离入场价,比如0.082)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 根本原因
|
||||
|
||||
### 代码逻辑矛盾
|
||||
|
||||
**位置**:`trading_system/risk_manager.py:689-757`
|
||||
|
||||
**问题**:
|
||||
1. **第689-700行**:选择"更紧的止损"(更接近入场价)
|
||||
- BUY: 取最大值(更高的止损价,更接近入场价)✅
|
||||
- SELL: 取最小值(更低的止损价,更接近入场价)✅
|
||||
|
||||
2. **第750-757行**:重新选择最终的止损价,保持"更宽松/更远"的选择规则 ❌
|
||||
- BUY: 取最小值(更低的止损价,更远离入场价)❌
|
||||
- SELL: 取最大值(更高的止损价,更远离入场价)❌
|
||||
|
||||
**结果**:
|
||||
- 第750-757行的逻辑会覆盖第689-700行的逻辑
|
||||
- 导致SELL单选择了"更宽松"的止损(更远离入场价)
|
||||
- 这就是为什么会出现-91.93%的巨额亏损
|
||||
|
||||
---
|
||||
|
||||
## ✅ 修复方案
|
||||
|
||||
### 修复内容
|
||||
|
||||
**修改位置**:`trading_system/risk_manager.py:750-757`
|
||||
|
||||
**修复前**:
|
||||
```python
|
||||
# 重新选择最终的止损价(包括技术止损)
|
||||
# 仍保持"更宽松/更远"的选择规则
|
||||
if side == 'BUY':
|
||||
final_stop_loss = min(p[1] for p in candidate_prices) # ❌ 更宽松
|
||||
selected_method = [p[0] for p in candidate_prices if p[1] == final_stop_loss][0]
|
||||
else:
|
||||
final_stop_loss = max(p[1] for p in candidate_prices) # ❌ 更宽松
|
||||
selected_method = [p[0] for p in candidate_prices if p[1] == final_stop_loss][0]
|
||||
```
|
||||
|
||||
**修复后**:
|
||||
```python
|
||||
# ⚠️ 关键修复:重新选择最终的止损价(包括技术止损)
|
||||
# 必须保持"更紧的止损"(更接近入场价)的选择规则,保护资金
|
||||
# - 做多(BUY):止损价越低越紧 → 取最大值(更高的止损价,更接近入场价)
|
||||
# - 做空(SELL):止损价越高越紧 → 取最小值(更低的止损价,更接近入场价)
|
||||
if side == 'BUY':
|
||||
# 做多:选择更高的止损价(更接近入场价,更紧)
|
||||
final_stop_loss = max(p[1] for p in candidate_prices) # ✅ 更紧
|
||||
selected_method = [p[0] for p in candidate_prices if p[1] == final_stop_loss][0]
|
||||
else:
|
||||
# 做空:选择更低的止损价(更接近入场价,更紧)
|
||||
# ⚠️ 注意:对于SELL单,止损价高于入场价,所以"更低的止损价"意味着更接近入场价
|
||||
final_stop_loss = min(p[1] for p in candidate_prices) # ✅ 更紧
|
||||
selected_method = [p[0] for p in candidate_prices if p[1] == final_stop_loss][0]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 修复效果
|
||||
|
||||
### 修复前
|
||||
|
||||
**SELL单止损价格选择**:
|
||||
- 入场价:0.0731
|
||||
- 候选止损价:0.075(保证金止损)、0.082(ATR止损)
|
||||
- 选择:max(0.075, 0.082) = 0.082(更宽松,更远离入场价)❌
|
||||
- 结果:价格涨到0.0815时触发止损,亏损-91.93%
|
||||
|
||||
### 修复后
|
||||
|
||||
**SELL单止损价格选择**:
|
||||
- 入场价:0.0731
|
||||
- 候选止损价:0.075(保证金止损)、0.082(ATR止损)
|
||||
- 选择:min(0.075, 0.082) = 0.075(更紧,更接近入场价)✅
|
||||
- 结果:价格涨到0.075时触发止损,亏损约5%
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期效果
|
||||
|
||||
修复后预期:
|
||||
- ✅ SELL单止损价格正确,选择"更紧"的止损(更接近入场价)
|
||||
- ✅ 不再出现巨额亏损(-91.93%)
|
||||
- ✅ 止损及时触发,保护资金
|
||||
- ✅ 盈亏比改善(从0.39:1提升到1.5:1+)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **立即重启交易进程**:修复后需要重启所有交易进程,让新代码生效
|
||||
2. **监控SELL单**:修复后需要密切监控SELL单的止损价格和止损触发情况
|
||||
3. **检查现有持仓**:如果有现有的SELL单持仓,需要检查止损价格是否正确
|
||||
|
||||
---
|
||||
|
||||
## 📝 相关配置
|
||||
|
||||
当前配置:
|
||||
- `STOP_LOSS_PERCENT`: 0.15(15%)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
|
||||
- `MIN_STOP_LOSS_PRICE_PCT`: 0.02(2%)
|
||||
|
||||
建议:
|
||||
- 保持当前配置,修复后应该能正常工作
|
||||
- 如果仍然出现止损过宽的问题,可以考虑降低`ATR_STOP_LOSS_MULTIPLIER`到1.5
|
||||
128
docs/archive/STOP_LOSS_IMMEDIATE_CLOSE_FIX.md
Normal file
128
docs/archive/STOP_LOSS_IMMEDIATE_CLOSE_FIX.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# 止损立即平仓修复说明
|
||||
|
||||
## 🔍 问题描述
|
||||
|
||||
**时间范围**:23点后到早上
|
||||
**症状**:系统检测到价格已触发止损价,但只记录错误日志,**没有执行平仓操作**,导致亏损持续扩大。
|
||||
|
||||
### 错误日志示例
|
||||
```
|
||||
NOMUSDT ⚠️ 当前价格(0.00944000)已触发止损价(0.00977746),无法挂止损单,应该立即平仓!
|
||||
ZROUSDT ⚠️ 当前价格(2.02533560)已触发止损价(2.02531200),无法挂止损单,应该立即平仓!
|
||||
WCTUSDT ⚠️ 当前价格(0.08786000)已触发止损价(0.08963080),无法挂止损单,应该立即平仓!
|
||||
```
|
||||
|
||||
## 🎯 根本原因
|
||||
|
||||
在 `trading_system/position_manager.py` 的 `_ensure_exchange_sltp_orders()` 方法中:
|
||||
|
||||
1. **挂单逻辑死锁**:当 `current_price` 已经低于 `stop_loss_price`(做多时),币安 API 会拒绝 `STOP_MARKET` 订单,返回 `Order would immediately trigger`(错误代码 -2021)。
|
||||
|
||||
2. **只报警不执行**:代码检测到了这个情况并打印了警告日志,但**只设置了 `sl_order = None`,没有触发市价平仓**。
|
||||
|
||||
3. **依赖WebSocket延迟**:代码注释说"依赖WebSocket监控立即平仓",但WebSocket监控可能有延迟,在深夜价格剧烈波动时,无法及时止损。
|
||||
|
||||
## ✅ 修复方案
|
||||
|
||||
### 修复位置
|
||||
`trading_system/position_manager.py` 的 `_ensure_exchange_sltp_orders()` 方法
|
||||
|
||||
### 修复内容
|
||||
|
||||
#### 1. 止损价触发时立即平仓(第1199-1223行)
|
||||
|
||||
**修复前**:
|
||||
```python
|
||||
if current_price_val <= stop_loss_val:
|
||||
logger.error(f"{symbol} ⚠️ 当前价格(...)已触发止损价(...),无法挂止损单,应该立即平仓!")
|
||||
logger.error(f" 建议: 立即手动平仓或等待WebSocket监控触发平仓")
|
||||
sl_order = None # ❌ 只设置None,没有执行平仓
|
||||
```
|
||||
|
||||
**修复后**:
|
||||
```python
|
||||
if current_price_val <= stop_loss_val:
|
||||
logger.error(f"{symbol} ⚠️ 当前价格({current_price_val:.8f})已触发止损价({stop_loss_val:.8f}),无法挂止损单,立即执行市价平仓保护!")
|
||||
logger.error(f" 入场价: {entry_price_val:.8f if entry_price_val else 'N/A'}")
|
||||
# ✅ 立即执行市价平仓
|
||||
await self.close_position(symbol, reason='stop_loss')
|
||||
return # 直接返回,不再尝试挂单
|
||||
```
|
||||
|
||||
#### 2. 止盈价触发时立即平仓(第1272-1288行)
|
||||
|
||||
**新增逻辑**:在挂止盈单前,也检查价格是否已经达到止盈价,如果达到则立即执行市价平仓。
|
||||
|
||||
```python
|
||||
# 在挂止盈单前,检查当前价格是否已经触发止盈
|
||||
if current_price and take_profit:
|
||||
try:
|
||||
current_price_val = float(current_price)
|
||||
take_profit_val = float(take_profit)
|
||||
|
||||
# 检查是否已经触发止盈
|
||||
triggered_tp = False
|
||||
if side == "BUY" and current_price_val >= take_profit_val:
|
||||
triggered_tp = True
|
||||
elif side == "SELL" and current_price_val <= take_profit_val:
|
||||
triggered_tp = True
|
||||
|
||||
if triggered_tp:
|
||||
logger.info(f"{symbol} 🎯 当前价格({current_price_val:.8f})已达到止盈价({take_profit_val:.8f}),立即执行市价止盈!")
|
||||
await self.close_position(symbol, reason='take_profit')
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"{symbol} 检查止盈触发条件时出错: {e}")
|
||||
```
|
||||
|
||||
## 📊 修复效果
|
||||
|
||||
### 修复前
|
||||
- ❌ 检测到止损触发 → 只记录错误日志 → 等待WebSocket监控 → **可能延迟或失败**
|
||||
- ❌ 价格继续下跌 → 亏损扩大 → 直到下次扫描才可能止损
|
||||
|
||||
### 修复后
|
||||
- ✅ 检测到止损触发 → **立即执行市价平仓** → 止损保护立即生效
|
||||
- ✅ 价格继续下跌 → **已平仓,不再亏损**
|
||||
|
||||
## 🔄 触发场景
|
||||
|
||||
这个修复会在以下场景生效:
|
||||
|
||||
1. **开仓后立即检查**:在 `_ensure_exchange_sltp_orders()` 被调用时(开仓后立即执行)
|
||||
2. **系统重启后同步**:如果系统重启,同步持仓时会调用 `_ensure_exchange_sltp_orders()` 补挂保护单
|
||||
3. **定期检查**:`check_stop_loss_take_profit()` 方法会定期检查(通过扫描间隔)
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **市价平仓**:修复使用 `close_position()` 方法执行市价平仓,可能会有轻微滑点,但能确保及时止损。
|
||||
|
||||
2. **扫描间隔影响**:如果扫描间隔较长(如1小时),在间隔期间价格暴跌穿透止损线,等到下次扫描时,`_ensure_exchange_sltp_orders()` 会被调用(例如系统重启后),此时会立即平仓。
|
||||
|
||||
3. **WebSocket监控**:WebSocket监控仍然有效,作为第二层保护。但修复后,即使WebSocket延迟,也能通过价格检查立即平仓。
|
||||
|
||||
## 🚀 部署建议
|
||||
|
||||
1. **重启交易进程**:修复后需要重启所有 `trading_system` 进程才能生效。
|
||||
```bash
|
||||
supervisorctl restart auto_sys_acc1 auto_sys_acc2 auto_sys_acc3 ...
|
||||
```
|
||||
|
||||
2. **验证修复**:查看日志,确认当价格触发止损时,会看到:
|
||||
```
|
||||
{symbol} ⚠️ 当前价格(...)已触发止损价(...),无法挂止损单,立即执行市价平仓保护!
|
||||
{symbol} [平仓] 开始平仓操作 (原因: stop_loss)
|
||||
{symbol} [平仓] ✓ 平仓订单已提交
|
||||
```
|
||||
|
||||
3. **监控效果**:观察后续交易,确认深夜价格波动时能及时止损,不再出现"只报警不平仓"的情况。
|
||||
|
||||
## 📝 相关文件
|
||||
|
||||
- `trading_system/position_manager.py`:主要修复文件
|
||||
- `_ensure_exchange_sltp_orders()` 方法(第1101-1320行)
|
||||
- `close_position()` 方法(第669-769行)
|
||||
|
||||
## ✅ 修复完成时间
|
||||
|
||||
2026-01-25
|
||||
246
docs/archive/STOP_LOSS_ORDER_FAILURE_ANALYSIS.md
Normal file
246
docs/archive/STOP_LOSS_ORDER_FAILURE_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# 止损单挂单失败分析
|
||||
|
||||
## 📋 问题描述
|
||||
|
||||
INUSDT 止损单挂单失败,系统将依赖WebSocket监控,但可能无法及时止损。
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
INUSDT ❌ 止损单挂单失败!将依赖WebSocket监控,但可能无法及时止损
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 风险
|
||||
|
||||
**止损单挂单失败的风险**:
|
||||
1. **没有交易所级别保护**:如果系统崩溃或网络中断,可能无法及时止损
|
||||
2. **依赖WebSocket监控**:如果WebSocket断开,可能无法及时止损
|
||||
3. **用户无法在币安界面看到止损单**:无法手动确认止损单是否已设置
|
||||
|
||||
---
|
||||
|
||||
## 🔍 可能的原因
|
||||
|
||||
### 1. 止损价格计算错误
|
||||
|
||||
**问题**:
|
||||
- 止损价格可能不在正确的一侧
|
||||
- BUY时止损价应低于入场价,SELL时止损价应高于入场价
|
||||
- 如果止损价计算错误,币安会拒绝挂单
|
||||
|
||||
**检查**:
|
||||
- 查看日志中的止损价格和入场价格
|
||||
- 确认止损价格方向是否正确
|
||||
|
||||
### 2. 价格精度问题
|
||||
|
||||
**问题**:
|
||||
- 止损价格可能不符合币安的精度要求(tickSize)
|
||||
- 错误代码:-4014(Price not increased by tick size)
|
||||
|
||||
**检查**:
|
||||
- 查看日志中的价格精度信息
|
||||
- 确认止损价格是否对齐到 tickSize
|
||||
|
||||
### 3. 持仓不存在或方向不对
|
||||
|
||||
**问题**:
|
||||
- 可能没有持仓或持仓方向不匹配
|
||||
- 错误代码:-2022(ReduceOnly Order is rejected)
|
||||
|
||||
**检查**:
|
||||
- 确认币安账户中是否有持仓
|
||||
- 确认持仓方向是否匹配
|
||||
|
||||
### 4. 对冲/单向模式问题
|
||||
|
||||
**问题**:
|
||||
- 币安账户可能是对冲模式,但代码按单向模式处理(或反之)
|
||||
- 需要正确设置 `positionSide` 参数
|
||||
|
||||
**检查**:
|
||||
- 查看日志中的对冲模式信息
|
||||
- 确认 `positionSide` 参数是否正确
|
||||
|
||||
### 5. 触发价格会导致立即触发
|
||||
|
||||
**问题**:
|
||||
- 止损价格太接近当前价格,会导致立即触发
|
||||
- 币安会拒绝这种订单
|
||||
|
||||
**检查**:
|
||||
- 查看日志中的当前价格和止损价格
|
||||
- 确认止损价格是否在正确的一侧
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成的改进
|
||||
|
||||
### 1. 增强错误日志
|
||||
|
||||
**改进内容**:
|
||||
- 添加详细的错误信息(错误代码、错误消息)
|
||||
- 记录止损价格、当前价格、持仓方向等关键信息
|
||||
- 针对常见错误码提供具体的解决建议
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/binance_client.py:1535-1580`
|
||||
- `trading_system/position_manager.py:1154-1155`
|
||||
|
||||
### 2. 添加止损价格验证
|
||||
|
||||
**改进内容**:
|
||||
- 在挂单前验证止损价格方向是否正确
|
||||
- BUY时止损价应低于入场价,SELL时止损价应高于入场价
|
||||
- 如果验证失败,记录错误并跳过挂单
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py:1136-1148`
|
||||
|
||||
### 3. 改进重试逻辑
|
||||
|
||||
**改进内容**:
|
||||
- 如果首次挂单失败,尝试切换 `positionSide` 重试
|
||||
- 记录重试过程和结果
|
||||
- 如果所有重试都失败,记录详细参数用于调试
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/binance_client.py:1526-1549`
|
||||
|
||||
### 4. 自动获取当前价格
|
||||
|
||||
**改进内容**:
|
||||
- 如果未提供当前价格,自动从币安获取
|
||||
- 确保止损价格验证和调整使用最新的价格
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py:1150-1155`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 故障排查步骤
|
||||
|
||||
### 步骤1:查看详细错误日志
|
||||
|
||||
检查交易日志,查找以下信息:
|
||||
```
|
||||
INUSDT ❌ 挂保护单失败(STOP_MARKET): ...
|
||||
错误代码: ...
|
||||
触发价格: ...
|
||||
当前价格: ...
|
||||
持仓方向: ...
|
||||
平仓方向: ...
|
||||
价格精度: ..., 价格步长: ...
|
||||
```
|
||||
|
||||
### 步骤2:检查止损价格计算
|
||||
|
||||
确认止损价格是否正确:
|
||||
- **BUY订单**:止损价应 < 入场价
|
||||
- **SELL订单**:止损价应 > 入场价
|
||||
|
||||
如果止损价格方向错误,检查:
|
||||
1. `risk_manager.get_stop_loss_price()` 的计算逻辑
|
||||
2. ATR 值是否正确
|
||||
3. `ATR_STOP_LOSS_MULTIPLIER` 配置是否正确
|
||||
|
||||
### 步骤3:检查持仓状态
|
||||
|
||||
确认币安账户中是否有持仓:
|
||||
- 登录币安,查看是否有 INUSDT 的持仓
|
||||
- 确认持仓方向(LONG/SHORT)是否匹配
|
||||
|
||||
### 步骤4:检查价格精度
|
||||
|
||||
确认止损价格是否符合精度要求:
|
||||
- 查看日志中的 `价格精度` 和 `价格步长`
|
||||
- 确认止损价格是否对齐到 tickSize
|
||||
|
||||
### 步骤5:检查对冲模式
|
||||
|
||||
确认币安账户的持仓模式:
|
||||
- 查看日志中的 `对冲模式` 信息
|
||||
- 确认 `positionSide` 参数是否正确
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案
|
||||
|
||||
### 方案1:修复止损价格计算(如果计算错误)
|
||||
|
||||
**如果止损价格方向错误**:
|
||||
1. 检查 `risk_manager.get_stop_loss_price()` 方法
|
||||
2. 确认 ATR 计算是否正确
|
||||
3. 确认 `ATR_STOP_LOSS_MULTIPLIER` 配置是否正确
|
||||
|
||||
### 方案2:调整价格精度(如果精度问题)
|
||||
|
||||
**如果价格精度错误**:
|
||||
1. 检查 `_format_price_str_with_rounding()` 方法
|
||||
2. 确认价格格式化是否正确
|
||||
3. 确保止损价格对齐到 tickSize
|
||||
|
||||
### 方案3:手动设置止损(临时方案)
|
||||
|
||||
**如果自动挂单失败**:
|
||||
1. 登录币安,手动设置止损单
|
||||
2. 确保止损价格在正确的一侧
|
||||
3. 等待系统修复后,再使用自动挂单
|
||||
|
||||
### 方案4:检查持仓模式(如果模式问题)
|
||||
|
||||
**如果对冲模式问题**:
|
||||
1. 确认币安账户的持仓模式(对冲/单向)
|
||||
2. 检查代码中的 `dual` 变量是否正确
|
||||
3. 确保 `positionSide` 参数正确设置
|
||||
|
||||
---
|
||||
|
||||
## 📊 预期改善
|
||||
|
||||
改进后预期:
|
||||
1. **详细的错误日志**:能够快速定位问题原因
|
||||
2. **价格验证**:在挂单前验证止损价格,避免无效请求
|
||||
3. **自动重试**:尝试切换 `positionSide` 重试,提高成功率
|
||||
4. **更好的诊断**:记录所有关键参数,便于调试
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**止损单挂单失败是严重问题**,因为:
|
||||
1. 没有交易所级别保护,系统崩溃时可能无法止损
|
||||
2. 依赖WebSocket监控,网络中断时可能无法止损
|
||||
3. 用户无法在币安界面看到止损单
|
||||
|
||||
**必须尽快修复**,否则可能导致大额亏损。
|
||||
|
||||
---
|
||||
|
||||
## 🔍 需要检查的信息
|
||||
|
||||
1. **交易日志**:
|
||||
- 止损单挂单失败的详细错误信息
|
||||
- 错误代码和错误消息
|
||||
- 止损价格、当前价格、持仓方向
|
||||
|
||||
2. **币安账户**:
|
||||
- 是否有 INUSDT 的持仓
|
||||
- 持仓方向(LONG/SHORT)
|
||||
- 持仓模式(对冲/单向)
|
||||
|
||||
3. **配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER` 的值
|
||||
- `EXCHANGE_SLTP_ENABLED` 的值
|
||||
- 止损价格计算逻辑
|
||||
|
||||
---
|
||||
|
||||
## 📝 下一步行动
|
||||
|
||||
1. **查看详细日志**:检查最新的错误日志,确认具体失败原因
|
||||
2. **验证止损价格**:确认止损价格计算是否正确
|
||||
3. **检查持仓状态**:确认币安账户中是否有持仓
|
||||
4. **修复问题**:根据错误信息修复相应的问题
|
||||
5. **测试验证**:修复后测试止损单挂单是否成功
|
||||
263
docs/archive/STRATEGY_LOGIC_ANALYSIS.md
Normal file
263
docs/archive/STRATEGY_LOGIC_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
# 交易策略逻辑完整分析
|
||||
|
||||
## 📊 当前策略参数配置
|
||||
|
||||
### 核心参数
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `ATR_STOP_LOSS_MULTIPLIER` | 1.8 | ATR止损倍数(止损距离 = ATR × 1.8) |
|
||||
| `ATR_TAKE_PROFIT_MULTIPLIER` | 1.5 | ATR止盈倍数(备选方法,当无止损距离时使用) |
|
||||
| `RISK_REWARD_RATIO` | 1.5 | 盈亏比(止盈距离 = 止损距离 × 1.5) |
|
||||
| `MIN_TAKE_PROFIT_PRICE_PCT` | 0.02 (2%) | 最小止盈价格变动保护 |
|
||||
| `MIN_HOLD_TIME_SEC` | 1800 (30分钟) | 最小持仓时间锁 |
|
||||
| `USE_TRAILING_STOP` | False | 移动止损(已禁用) |
|
||||
|
||||
## 🎯 止盈计算逻辑(优先级顺序)
|
||||
|
||||
### 方法1:基于止损距离和盈亏比(优先使用)
|
||||
```
|
||||
止盈距离 = 止损距离 × RISK_REWARD_RATIO (1.5)
|
||||
止盈价 = 入场价 ± 止盈距离
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 入场价:100 USDT
|
||||
- ATR:3 USDT (3%)
|
||||
- 止损距离:100 × 0.03 × 1.8 = 5.4 USDT (5.4%)
|
||||
- 止损价:100 - 5.4 = 94.6 USDT
|
||||
- **止盈距离**:5.4 × 1.5 = **8.1 USDT (8.1%)**
|
||||
- **止盈价**:100 + 8.1 = **108.1 USDT**
|
||||
|
||||
### 方法2:基于ATR倍数(备选,当无止损距离时)
|
||||
```
|
||||
止盈距离 = ATR百分比 × ATR_TAKE_PROFIT_MULTIPLIER (1.5)
|
||||
止盈价 = 入场价 × (1 ± 止盈距离百分比)
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 入场价:100 USDT
|
||||
- ATR:3 USDT (3%)
|
||||
- **止盈距离**:0.03 × 1.5 = **4.5%**
|
||||
- **止盈价**:100 × 1.045 = **104.5 USDT**
|
||||
|
||||
### 方法3:基于保证金百分比(兜底)
|
||||
```
|
||||
止盈金额 = 保证金 × TAKE_PROFIT_PERCENT (25%)
|
||||
止盈价 = 入场价 ± (止盈金额 / 数量)
|
||||
```
|
||||
|
||||
### 方法4:最小价格变动保护
|
||||
```
|
||||
止盈价 = 入场价 × (1 ± MIN_TAKE_PROFIT_PRICE_PCT) (2%)
|
||||
```
|
||||
|
||||
**最终止盈价选择**:取以上方法中最宽松(最远)的价格
|
||||
|
||||
## 🔄 分步止盈策略
|
||||
|
||||
### 第一阶段:50% 仓位在 1:1 盈亏比止盈
|
||||
```
|
||||
第一目标价 = 入场价 ± (入场价 - 止损价)
|
||||
第一目标 = 盈亏比 1:1(相对于保证金)
|
||||
```
|
||||
|
||||
**触发条件**:
|
||||
- 当前盈亏百分比(基于保证金)≥ 止损百分比(基于保证金)
|
||||
- 平仓 50% 仓位
|
||||
- **将剩余仓位止损移至入场价(保本)**
|
||||
|
||||
### 第二阶段:剩余 50% 仓位在 1.5:1 盈亏比止盈
|
||||
```
|
||||
第二目标价 = 原始止盈价(基于止损距离 × 1.5)
|
||||
第二目标 = 盈亏比 1.5:1(相对于剩余仓位的保证金)
|
||||
```
|
||||
|
||||
**触发条件**:
|
||||
- 剩余仓位盈亏百分比(基于剩余保证金)≥ 1.5 × 止损百分比
|
||||
- 平仓剩余 50% 仓位
|
||||
|
||||
## 📈 胜率要求分析
|
||||
|
||||
### 理论盈亏比计算
|
||||
|
||||
假设:
|
||||
- 止损损失:-1 单位(基于保证金)
|
||||
- 第一目标盈利(50%仓位):+1 单位(1:1)
|
||||
- 第二目标盈利(50%仓位):+1.5 单位(1.5:1)
|
||||
|
||||
**完整交易期望收益**:
|
||||
- 如果第一目标触发(概率 P1),第二目标也触发(概率 P2):
|
||||
- 总盈利 = 0.5 × 1 + 0.5 × 1.5 = **1.25 单位**
|
||||
- 如果第一目标触发,但第二目标未触发(概率 P1 × (1-P2)):
|
||||
- 总盈利 = 0.5 × 1 + 0.5 × 0 = **0.5 单位**
|
||||
- 如果第一目标未触发,直接止损:
|
||||
- 总损失 = **-1 单位**
|
||||
|
||||
### 盈亏平衡点计算
|
||||
|
||||
**最理想情况**(第一目标100%触发,第二目标100%触发):
|
||||
```
|
||||
胜率 × 1.25 = 败率 × 1
|
||||
胜率 × 1.25 = (1 - 胜率) × 1
|
||||
胜率 × 2.25 = 1
|
||||
胜率 = 44.4%
|
||||
```
|
||||
|
||||
**保守情况**(第一目标100%触发,第二目标50%触发):
|
||||
```
|
||||
平均盈利 = 0.5 × 1.25 + 0.5 × 0.5 = 0.875 单位
|
||||
胜率 × 0.875 = (1 - 胜率) × 1
|
||||
胜率 × 1.875 = 1
|
||||
胜率 = 53.3%
|
||||
```
|
||||
|
||||
**最保守情况**(第一目标100%触发,第二目标0%触发):
|
||||
```
|
||||
平均盈利 = 0.5 单位
|
||||
胜率 × 0.5 = (1 - 胜率) × 1
|
||||
胜率 × 1.5 = 1
|
||||
胜率 = 66.7%
|
||||
```
|
||||
|
||||
### 实际胜率要求评估
|
||||
|
||||
**关键因素**:
|
||||
1. **第一目标触发率**:1:1 盈亏比相对容易触发(预期 60-70%)
|
||||
2. **第二目标触发率**:1.5:1 盈亏比需要趋势延续(预期 40-50%)
|
||||
3. **保本保护**:第一目标触发后,剩余仓位止损移至入场价,**彻底杜绝亏损可能**
|
||||
|
||||
**实际期望**:
|
||||
- 如果第一目标触发率 = 65%,第二目标触发率 = 45%
|
||||
- 平均盈利 = 0.65 × (0.45 × 1.25 + 0.55 × 0.5) = **0.65 × 0.8375 = 0.544 单位**
|
||||
- 盈亏平衡点:胜率 × 0.544 = (1 - 胜率) × 1
|
||||
- **胜率 = 64.8%**
|
||||
|
||||
## ⚠️ 潜在问题分析
|
||||
|
||||
### 1. 胜率要求较高
|
||||
**问题**:如果第一目标触发率低,或第二目标触发率低,需要更高的胜率才能盈利。
|
||||
|
||||
**缓解措施**:
|
||||
- ✅ 分步止盈确保至少锁定部分利润
|
||||
- ✅ 保本保护确保第一目标触发后不会亏损
|
||||
- ✅ 最小持仓时间锁(30分钟)避免过早平仓
|
||||
- ⚠️ **需要监控实际第一/第二目标触发率**
|
||||
|
||||
### 2. ATR_TAKE_PROFIT_MULTIPLIER 与 RISK_REWARD_RATIO 的关系
|
||||
**当前逻辑**:
|
||||
- 优先使用 `止损距离 × RISK_REWARD_RATIO (1.5)` 计算止盈
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER (1.5)` 仅作为备选(当无止损距离时)
|
||||
|
||||
**潜在问题**:
|
||||
- 如果 ATR 很小,`ATR_TAKE_PROFIT_MULTIPLIER` 可能计算出过小的止盈距离
|
||||
- 但 `MIN_TAKE_PROFIT_PRICE_PCT (2%)` 提供了保护
|
||||
|
||||
**建议**:
|
||||
- ✅ 当前逻辑合理,`ATR_TAKE_PROFIT_MULTIPLIER` 主要作为备选
|
||||
- ✅ `MIN_TAKE_PROFIT_PRICE_PCT` 确保最小止盈距离
|
||||
|
||||
### 3. 分步止盈的保本逻辑
|
||||
**当前实现**:
|
||||
- 第一目标触发后,剩余仓位止损移至入场价(保本)
|
||||
- **无论 `USE_TRAILING_STOP` 是否启用,都会移至保本**
|
||||
|
||||
**优势**:
|
||||
- ✅ 彻底杜绝第一目标触发后的亏损可能
|
||||
- ✅ 剩余仓位可以追求更高收益
|
||||
|
||||
**潜在问题**:
|
||||
- ⚠️ 如果价格在入场价附近震荡,可能频繁触发保本止损
|
||||
- ⚠️ 但这是可接受的,因为已经锁定了50%的利润
|
||||
|
||||
### 4. 止盈价选择逻辑
|
||||
**当前实现**:取所有方法中最宽松(最远)的价格
|
||||
|
||||
**潜在问题**:
|
||||
- 如果 `TAKE_PROFIT_PERCENT (25%)` 计算出的止盈价很远,可能难以触发
|
||||
- 但 ATR 方法通常会给出更合理的价格
|
||||
|
||||
**建议**:
|
||||
- ✅ 当前逻辑合理,优先使用 ATR 方法
|
||||
- ⚠️ 需要监控实际止盈触发率
|
||||
|
||||
## 📋 策略逻辑流程图
|
||||
|
||||
```
|
||||
开仓
|
||||
↓
|
||||
计算止损(ATR × 1.8)
|
||||
↓
|
||||
计算止盈(止损距离 × 1.5 或 ATR × 1.5)
|
||||
↓
|
||||
设置第一目标(1:1 盈亏比,50%仓位)
|
||||
↓
|
||||
设置第二目标(1.5:1 盈亏比,剩余50%仓位)
|
||||
↓
|
||||
监控持仓
|
||||
↓
|
||||
├─→ 触发止损 → 平仓(损失 -1 单位)
|
||||
│
|
||||
├─→ 触发第一目标 → 平仓50% → 止损移至保本 → 继续监控
|
||||
│ │
|
||||
│ └─→ 触发第二目标 → 平仓剩余50%(总盈利 1.25 单位)
|
||||
│ └─→ 触发保本止损 → 平仓剩余50%(总盈利 0.5 单位)
|
||||
│
|
||||
└─→ 最小持仓时间未到 → 继续监控
|
||||
```
|
||||
|
||||
## 🎯 优化建议
|
||||
|
||||
### 1. 监控关键指标
|
||||
- **第一目标触发率**:目标 ≥ 60%
|
||||
- **第二目标触发率**:目标 ≥ 40%
|
||||
- **实际盈亏比**:目标 ≥ 1.2
|
||||
- **盈利因子**:目标 ≥ 1.1
|
||||
|
||||
### 2. 如果胜率不足
|
||||
**选项A**:提高第一目标触发率
|
||||
- 降低第一目标到 0.8:1 盈亏比
|
||||
- 但会降低平均盈利
|
||||
|
||||
**选项B**:提高第二目标触发率
|
||||
- 降低第二目标到 1.2:1 盈亏比
|
||||
- 但会降低平均盈利
|
||||
|
||||
**选项C**:提高入场信号质量
|
||||
- 提高 `MIN_SIGNAL_STRENGTH`(当前 8)
|
||||
- 仅在 `marketRegime=trending` 时交易
|
||||
- 提高 `MIN_SIGNAL_STRENGTH` 到 9 或 10
|
||||
|
||||
### 3. 如果第一目标触发率低
|
||||
- 检查是否因为最小持仓时间锁导致过早平仓
|
||||
- 检查止损是否过紧(ATR_STOP_LOSS_MULTIPLIER = 1.8 是否合理)
|
||||
- 考虑降低第一目标到 0.9:1
|
||||
|
||||
### 4. 如果第二目标触发率低
|
||||
- 检查止盈价是否过远
|
||||
- 考虑降低第二目标到 1.3:1 或 1.2:1
|
||||
- 但需要权衡:降低目标会降低平均盈利
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
### 当前策略的优势
|
||||
1. ✅ **分步止盈**:锁定部分利润,降低风险
|
||||
2. ✅ **保本保护**:第一目标触发后不会亏损
|
||||
3. ✅ **动态止损**:基于 ATR,适应市场波动
|
||||
4. ✅ **最小持仓时间**:避免过早平仓
|
||||
|
||||
### 当前策略的挑战
|
||||
1. ⚠️ **胜率要求**:需要 45-65% 胜率(取决于第二目标触发率)
|
||||
2. ⚠️ **第二目标触发率**:需要趋势延续,可能较低
|
||||
3. ⚠️ **需要监控**:实际触发率可能与理论不符
|
||||
|
||||
### 建议
|
||||
1. **先运行观察**:收集实际数据(第一/第二目标触发率、实际盈亏比)
|
||||
2. **根据数据调整**:
|
||||
- 如果第一目标触发率 < 60%:考虑降低到 0.9:1
|
||||
- 如果第二目标触发率 < 40%:考虑降低到 1.3:1
|
||||
- 如果胜率 < 50%:提高入场信号质量
|
||||
3. **目标指标**:
|
||||
- 第一目标触发率 ≥ 60%
|
||||
- 第二目标触发率 ≥ 40%
|
||||
- 实际盈亏比 ≥ 1.2
|
||||
- 盈利因子 ≥ 1.1
|
||||
247
docs/archive/STRATEGY_OPTIMIZATION_PLAN.md
Normal file
247
docs/archive/STRATEGY_OPTIMIZATION_PLAN.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# 交易策略优化计划
|
||||
|
||||
## 📋 优化目标
|
||||
|
||||
根据专业建议,系统化提升:
|
||||
1. **入场信号质量** (Win Rate Up)
|
||||
2. **利润捕获能力** (Profit Up)
|
||||
3. **风险控制** (Survival First)
|
||||
4. **系统可靠性** (Reliability Up)
|
||||
5. **小众币专项优化**
|
||||
|
||||
---
|
||||
|
||||
## 1. 动态过滤:提升入场信号质量 (Win Rate Up)
|
||||
|
||||
### 1.1 大盘共振(Beta Filter)✅ 优先实现
|
||||
|
||||
**目标**:当BTC或ETH在15min/1h周期剧烈下跌时,自动屏蔽所有多单信号
|
||||
|
||||
**实现方案**:
|
||||
- 在 `strategy.py` 的 `_analyze_trade_signal` 中增加大盘检查
|
||||
- 获取BTCUSDT和ETHUSDT的15min和1h K线
|
||||
- 计算最近N根K线的涨跌幅
|
||||
- 如果BTC或ETH在15min/1h周期下跌超过阈值(如-3%),屏蔽所有多单
|
||||
- 配置项:`BETA_FILTER_ENABLED`, `BETA_FILTER_THRESHOLD`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/strategy.py` - `_analyze_trade_signal()`
|
||||
- `trading_system/market_scanner.py` - 增加大盘数据获取
|
||||
|
||||
### 1.2 波动率阈值
|
||||
|
||||
**目标**:避开成交量极低或ATR异常激增的时刻
|
||||
|
||||
**实现方案**:
|
||||
- 在 `market_scanner.py` 中增加波动率检查
|
||||
- ATR异常激增:当前ATR / 平均ATR > 阈值(如2.0)
|
||||
- 成交量极低:24H Volume < 配置阈值(如1000万美金)
|
||||
- 配置项:`ATR_SPIKE_THRESHOLD`, `MIN_VOLUME_24H_STRICT`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/market_scanner.py` - `_get_symbol_change()`
|
||||
- `trading_system/indicators.py` - ATR计算
|
||||
|
||||
### 1.3 信号强度分级
|
||||
|
||||
**目标**:9-10分信号分配更高权重,8分信号仅作为轻仓试探
|
||||
|
||||
**实现方案**:
|
||||
- 在 `risk_manager.py` 的 `calculate_position_size` 中根据信号强度调整仓位
|
||||
- 9-10分:使用100%仓位(MAX_POSITION_PERCENT)
|
||||
- 8分:使用50%仓位(MAX_POSITION_PERCENT * 0.5)
|
||||
- 配置项:`SIGNAL_STRENGTH_POSITION_MULTIPLIER`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_position_size()`
|
||||
- `trading_system/strategy.py` - 传递信号强度
|
||||
|
||||
---
|
||||
|
||||
## 2. 策略优化:从"固定止盈"到"动态追踪" (Profit Up)
|
||||
|
||||
### 2.1 追踪止损(Trailing Stop)
|
||||
|
||||
**目标**:当价格达到1:1目标后,利用币安Trailing Stop Order或代码层面根据ATR向上移动止损线
|
||||
|
||||
**实现方案**:
|
||||
- 检查币安是否支持 `TRAILING_STOP_MARKET` 订单类型
|
||||
- 如果支持:在分步止盈后,挂币安Trailing Stop Order
|
||||
- 如果不支持:代码层面实现,根据ATR动态调整止损价
|
||||
- 配置项:`USE_TRAILING_STOP_AFTER_PARTIAL_PROFIT`, `TRAILING_STOP_ATR_MULTIPLIER`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py` - 分步止盈后逻辑
|
||||
- `trading_system/binance_client.py` - Trailing Stop Order支持
|
||||
|
||||
### 2.2 ADX趋势强度判断
|
||||
|
||||
**目标**:如果ADX > 25且处于上升趋势,延迟第一止盈位触发或取消50%减仓
|
||||
|
||||
**实现方案**:
|
||||
- 在 `indicators.py` 中计算ADX
|
||||
- 在 `position_manager.py` 的止盈检查中,如果ADX > 25且趋势向上,跳过第一止盈(50%减仓)
|
||||
- 配置项:`ADX_STRONG_TREND_THRESHOLD`, `ADX_SKIP_PARTIAL_PROFIT`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/indicators.py` - ADX计算
|
||||
- `trading_system/position_manager.py` - 止盈逻辑
|
||||
|
||||
---
|
||||
|
||||
## 3. 仓位管理:基于风险的头寸缩放 (Survival First)
|
||||
|
||||
### 3.1 凯利公式/固定风险百分比
|
||||
|
||||
**目标**:根据止损距离反算仓位,确保每笔单子赔掉的钱占总资金的比例恒定(如2%)
|
||||
|
||||
**实现方案**:
|
||||
- 在 `risk_manager.py` 的 `calculate_position_size` 中实现
|
||||
- 公式:`仓位大小 = (总资金 * 每笔单子承受的风险%) / (入场价 - 止损价)`
|
||||
- 配置项:`FIXED_RISK_PERCENT`, `USE_FIXED_RISK_SIZING`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_position_size()`
|
||||
|
||||
### 3.2 阶梯杠杆
|
||||
|
||||
**目标**:针对小众币,强制限制最高杠杆(如3-5倍)
|
||||
|
||||
**实现方案**:
|
||||
- 在 `risk_manager.py` 的 `calculate_dynamic_leverage` 中增加波动率检查
|
||||
- 如果ATR过高或成交量过低,限制最高杠杆
|
||||
- 配置项:`MAX_LEVERAGE_SMALL_CAP`, `ATR_LEVERAGE_REDUCTION_THRESHOLD`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_dynamic_leverage()`
|
||||
|
||||
---
|
||||
|
||||
## 4. 基础设施与风控 (Reliability Up)
|
||||
|
||||
### 4.1 心跳检测与延迟监控
|
||||
|
||||
**目标**:WebSocket断线重连机制 + 每1-2分钟兜底巡检
|
||||
|
||||
**实现方案**:
|
||||
- 在 `position_manager.py` 的WebSocket监控中增加心跳检测
|
||||
- 如果WebSocket断线,自动重连
|
||||
- 增加独立的定时巡检任务(每1-2分钟),作为兜底
|
||||
- 配置项:`WEBSOCKET_HEARTBEAT_INTERVAL`, `FALLBACK_CHECK_INTERVAL`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py` - WebSocket监控逻辑
|
||||
|
||||
### 4.2 滑点保护
|
||||
|
||||
**目标**:使用MARK_PRICE触发,但执行时使用LIMIT单或带保护的MARKET单
|
||||
|
||||
**实现方案**:
|
||||
- 在 `position_manager.py` 的平仓逻辑中
|
||||
- 使用MARK_PRICE判断是否触发止损/止盈
|
||||
- 执行时使用LIMIT单(当前价±滑点容差)或带保护的MARKET单
|
||||
- 配置项:`SLIPPAGE_TOLERANCE_PCT`, `USE_LIMIT_ON_CLOSE`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py` - `close_position()`
|
||||
|
||||
---
|
||||
|
||||
## 5. 针对小众币的专项优化
|
||||
|
||||
### 5.1 资金费率避险
|
||||
|
||||
**目标**:在费率结算前(8:00, 16:00, 24:00),如果费率过高(>0.1%),提前止盈或暂缓入场
|
||||
|
||||
**实现方案**:
|
||||
- 在 `binance_client.py` 中获取资金费率
|
||||
- 在 `strategy.py` 中检查是否接近结算时间(8:00, 16:00, 24:00)
|
||||
- 如果费率 > 0.1%,提前止盈或暂缓入场
|
||||
- 配置项:`FUNDING_RATE_THRESHOLD`, `FUNDING_RATE_EARLY_EXIT_HOURS`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/binance_client.py` - 资金费率获取
|
||||
- `trading_system/strategy.py` - 入场检查
|
||||
- `trading_system/position_manager.py` - 止盈检查
|
||||
|
||||
### 5.2 成交量验证
|
||||
|
||||
**目标**:24H Volume低于1000万美金,直接剔除
|
||||
|
||||
**实现方案**:
|
||||
- 在 `market_scanner.py` 中增加严格成交量过滤
|
||||
- 配置项:`MIN_VOLUME_24H_STRICT` (10000000)
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/market_scanner.py` - 扫描过滤
|
||||
|
||||
---
|
||||
|
||||
## 📊 实施优先级
|
||||
|
||||
### ✅ 高优先级(已完成)
|
||||
|
||||
1. ✅ **大盘共振(Beta Filter)** - 当BTC/ETH下跌超过-3%时,屏蔽所有多单
|
||||
2. ✅ **成交量验证(1000万美金)** - 24H Volume低于1000万美金直接剔除
|
||||
3. ✅ **固定风险百分比仓位计算** - 根据止损距离反算仓位,每笔风险恒定2%
|
||||
4. ✅ **信号强度分级** - 8分50%仓位,9-10分100%仓位
|
||||
5. ✅ **阶梯杠杆** - 小众币(ATR>=5%)限制最高杠杆5倍
|
||||
|
||||
**预期效果**:
|
||||
- ✅ 减少大盘暴跌时的损失
|
||||
- ✅ 避免流动性差的币种,减少滑点损失(2-3%)
|
||||
- ✅ 每笔单子风险恒定(2%),避免30%的大额亏损
|
||||
- ✅ 高质量信号获得更大收益,低质量信号降低风险
|
||||
- ✅ 小众币风险降低,减少强平风险
|
||||
|
||||
### ⏳ 中优先级(待实施)
|
||||
|
||||
6. ⏳ 波动率阈值 - 避开ATR异常激增的时刻
|
||||
7. ⏳ 心跳检测与兜底巡检 - WebSocket断线重连和兜底巡检
|
||||
8. ⏳ 滑点保护 - 使用MARK_PRICE触发,LIMIT单执行
|
||||
|
||||
### 中优先级(本周实施)
|
||||
5. 波动率阈值
|
||||
6. 信号强度分级
|
||||
7. 阶梯杠杆(小众币)
|
||||
8. 滑点保护
|
||||
|
||||
### 低优先级(后续优化)
|
||||
9. 追踪止损(Trailing Stop)
|
||||
10. ADX趋势强度判断
|
||||
11. 资金费率避险
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置项汇总
|
||||
|
||||
```python
|
||||
# 动态过滤
|
||||
'BETA_FILTER_ENABLED': True,
|
||||
'BETA_FILTER_THRESHOLD': -0.03, # -3%
|
||||
'ATR_SPIKE_THRESHOLD': 2.0,
|
||||
'MIN_VOLUME_24H_STRICT': 10000000, # 1000万美金
|
||||
'SIGNAL_STRENGTH_POSITION_MULTIPLIER': {8: 0.5, 9: 1.0, 10: 1.0},
|
||||
|
||||
# 策略优化
|
||||
'USE_TRAILING_STOP_AFTER_PARTIAL_PROFIT': True,
|
||||
'TRAILING_STOP_ATR_MULTIPLIER': 1.5,
|
||||
'ADX_STRONG_TREND_THRESHOLD': 25,
|
||||
'ADX_SKIP_PARTIAL_PROFIT': True,
|
||||
|
||||
# 仓位管理
|
||||
'USE_FIXED_RISK_SIZING': True,
|
||||
'FIXED_RISK_PERCENT': 0.02, # 2%
|
||||
'MAX_LEVERAGE_SMALL_CAP': 5,
|
||||
'ATR_LEVERAGE_REDUCTION_THRESHOLD': 0.05, # 5%
|
||||
|
||||
# 基础设施
|
||||
'WEBSOCKET_HEARTBEAT_INTERVAL': 30, # 30秒
|
||||
'FALLBACK_CHECK_INTERVAL': 120, # 2分钟
|
||||
'SLIPPAGE_TOLERANCE_PCT': 0.002, # 0.2%
|
||||
'USE_LIMIT_ON_CLOSE': True,
|
||||
|
||||
# 小众币优化
|
||||
'FUNDING_RATE_THRESHOLD': 0.001, # 0.1%
|
||||
'FUNDING_RATE_EARLY_EXIT_HOURS': 1, # 结算前1小时
|
||||
```
|
||||
109
docs/archive/TAKE_PROFIT_TIME_LOCK_ANALYSIS.md
Normal file
109
docs/archive/TAKE_PROFIT_TIME_LOCK_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# 止盈时间锁分析与优化建议
|
||||
|
||||
## 🤔 问题:止盈时间锁是否有必要?
|
||||
|
||||
### 当前情况
|
||||
- ✅ **止损**:已修复,不受时间锁限制,立即执行
|
||||
- ⚠️ **止盈**:仍然受30分钟时间锁限制
|
||||
|
||||
### 止盈时间锁的利弊分析
|
||||
|
||||
#### ✅ 支持保留的理由(原始设计意图)
|
||||
1. **防止过早止盈**
|
||||
- 避免价格刚达到止盈目标就立即平仓
|
||||
- 给趋势更多时间发展,追求更大利润
|
||||
- 符合"让利润奔跑"的交易理念
|
||||
|
||||
2. **避免分钟级平仓**
|
||||
- 防止因短期波动触发止盈
|
||||
- 强制波段持仓纪律
|
||||
- 减少频繁交易成本
|
||||
|
||||
3. **配合分步止盈策略**
|
||||
- 第一目标(1:1)在30分钟后才能触发
|
||||
- 给市场更多时间达到第二目标(1.5:1)
|
||||
|
||||
#### ❌ 反对保留的理由(实际问题)
|
||||
1. **错过最佳止盈时机**
|
||||
- 如果价格在30分钟内达到止盈目标,但之后回落
|
||||
- 可能从盈利变成亏损
|
||||
- **对于小众币,价格波动剧烈,30分钟可能错过最佳退出点**
|
||||
|
||||
2. **与交易所级别止盈单冲突**
|
||||
- 币安交易所级别的止盈单不受时间锁限制
|
||||
- 如果交易所止盈单触发,但本地监控被时间锁阻止,可能造成不一致
|
||||
|
||||
3. **降低资金效率**
|
||||
- 资金被锁定30分钟,即使已经达到目标
|
||||
- 无法及时释放资金用于新机会
|
||||
|
||||
4. **实际案例**
|
||||
- 用户反馈亏损严重,可能也与止盈不及时有关
|
||||
- 如果止盈能及时执行,可能减少亏损
|
||||
|
||||
## 📊 数据驱动的决策建议
|
||||
|
||||
### 方案A:完全移除止盈时间锁(推荐)
|
||||
**优点**:
|
||||
- ✅ 止盈立即执行,不错过最佳退出点
|
||||
- ✅ 与交易所级别止盈单一致
|
||||
- ✅ 提高资金效率
|
||||
- ✅ 减少因价格回落导致的利润回吐
|
||||
|
||||
**缺点**:
|
||||
- ❌ 可能过早止盈,错过更大利润
|
||||
- ❌ 可能因短期波动触发止盈
|
||||
|
||||
**适用场景**:
|
||||
- 小众币(波动剧烈,需要及时止盈)
|
||||
- 短期交易策略
|
||||
- 追求稳定收益而非最大化利润
|
||||
|
||||
### 方案B:缩短时间锁(折中方案)
|
||||
**建议**:将30分钟缩短到5-10分钟
|
||||
|
||||
**优点**:
|
||||
- ✅ 保留防止过早止盈的保护
|
||||
- ✅ 减少错过最佳退出点的风险
|
||||
- ✅ 平衡利润最大化与及时止盈
|
||||
|
||||
**缺点**:
|
||||
- ❌ 仍然可能错过最佳退出点
|
||||
- ❌ 需要测试确定最佳时长
|
||||
|
||||
### 方案C:保留但可配置(灵活方案)
|
||||
**建议**:将时间锁设为可配置,默认值降低
|
||||
|
||||
**优点**:
|
||||
- ✅ 灵活性高,可根据市场调整
|
||||
- ✅ 可以针对不同币种设置不同值
|
||||
- ✅ 保留原始设计意图
|
||||
|
||||
**缺点**:
|
||||
- ❌ 增加配置复杂度
|
||||
- ❌ 需要用户理解并正确配置
|
||||
|
||||
## 🎯 推荐方案:完全移除止盈时间锁 ✅ 已实施
|
||||
|
||||
### 理由
|
||||
1. **止损已不受限制**:如果止损可以立即执行,止盈也应该可以
|
||||
2. **交易所级别保护**:币安交易所级别的止盈单已经提供保护
|
||||
3. **分步止盈策略**:分步止盈本身已经提供了利润保护(50%在1:1止盈,剩余保本)
|
||||
4. **实际需求**:用户反馈亏损严重,需要及时止盈保护利润
|
||||
|
||||
### ✅ 已实施
|
||||
1. ✅ **完全移除**:已移除所有止盈时间锁限制
|
||||
2. ✅ **保留分步止盈**:分步止盈策略仍然有效,提供利润保护
|
||||
3. ✅ **依赖交易所级别止盈单**:主要依赖币安交易所级别的止盈单
|
||||
4. ✅ **修复位置**:
|
||||
- `check_stop_loss_take_profit()` - 定期检查
|
||||
- `_check_single_position()` - WebSocket实时监控(两处)
|
||||
|
||||
## 📈 预期效果
|
||||
|
||||
移除止盈时间锁后:
|
||||
- ✅ 止盈能及时执行,保护利润
|
||||
- ✅ 减少因价格回落导致的利润回吐
|
||||
- ✅ 提高资金效率
|
||||
- ✅ 与止损逻辑一致(都不受时间锁限制)
|
||||
- ⚠️ 可能错过一些更大利润的机会(但分步止盈策略会部分补偿)
|
||||
127
docs/archive/TRADING_FLOW_ANALYSIS.md
Normal file
127
docs/archive/TRADING_FLOW_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# 交易流程分析与优化方案
|
||||
|
||||
## 🔴 当前严重问题:亏损达到30%以上
|
||||
|
||||
### 问题分析
|
||||
|
||||
根据最近的交易记录:
|
||||
- CLOUSDT SELL: -17.54% (手动平仓)
|
||||
- ICNTUSDT BUY: -19.60% (手动平仓)
|
||||
- 0GUSDT BUY: -31.34% (手动平仓)
|
||||
- ALCHUSDT BUY: -30.95% (同步平仓)
|
||||
|
||||
**核心问题:止损没有及时触发,导致亏损远超止损设置(通常止损设置为8-10%)**
|
||||
|
||||
### 根本原因
|
||||
|
||||
1. **最小持仓时间锁阻止止损触发** ⚠️ **最严重**
|
||||
- `MIN_HOLD_TIME_SEC = 1800秒(30分钟)`
|
||||
- 在持仓前30分钟内,即使触发止损,系统也会**禁止平仓**
|
||||
- 这导致止损单无法执行,亏损持续扩大
|
||||
- **对于小众币,30分钟内价格可能剧烈波动,亏损可能达到30%以上**
|
||||
|
||||
2. **交易所级别止损单可能未正确挂单**
|
||||
- 如果 `_ensure_exchange_sltp_orders` 失败,只有本地监控
|
||||
- 本地监控被时间锁阻止,无法平仓
|
||||
|
||||
3. **止损检查逻辑在时间锁之后**
|
||||
- 代码顺序:先检查时间锁 → 如果不足30分钟,直接 `continue`/`return`
|
||||
- 止损检查逻辑永远不会执行
|
||||
|
||||
## 📊 当前交易流程
|
||||
|
||||
### 开仓流程
|
||||
1. 市场扫描(每30分钟)
|
||||
2. 信号筛选(MIN_SIGNAL_STRENGTH >= 8)
|
||||
3. 计算止损止盈(基于ATR或保证金)
|
||||
4. 挂限价单开仓
|
||||
5. 订单成交后:
|
||||
- 保存交易记录到数据库
|
||||
- 在币安挂止损/止盈保护单(`_ensure_exchange_sltp_orders`)
|
||||
- 启动WebSocket实时监控
|
||||
|
||||
### 平仓流程(当前有严重问题)
|
||||
|
||||
#### 方式1:交易所级别止损/止盈单(最可靠)
|
||||
- 币安自动触发,不受时间锁影响
|
||||
- **但如果挂单失败,就没有保护**
|
||||
|
||||
#### 方式2:本地监控检查(被时间锁阻止)
|
||||
- `check_stop_loss_take_profit()` 定期检查
|
||||
- `_check_single_position()` WebSocket实时监控
|
||||
- **都被 `MIN_HOLD_TIME_SEC` 阻止,前30分钟无法平仓**
|
||||
|
||||
## ✅ 优化方案(已实施)
|
||||
|
||||
### 1. ✅ 完全移除最小持仓时间锁(已修复)
|
||||
|
||||
**问题**:时间锁阻止止损和止盈,导致亏损扩大和利润回吐
|
||||
|
||||
**解决方案**:✅ **完全移除时间锁限制**
|
||||
- ✅ 止损检查在时间锁之前执行,立即平仓
|
||||
- ✅ 止盈也立即执行,不受时间锁限制
|
||||
- ✅ 止损和止盈逻辑一致,都立即执行
|
||||
- ✅ 修复了三个位置:`check_stop_loss_take_profit()`、`_check_single_position()` 和移动止损检查
|
||||
|
||||
**移除理由**:
|
||||
1. 止损和止盈都应该立即执行,保护资金和利润
|
||||
2. 交易所级别的止损/止盈单已提供保护
|
||||
3. 分步止盈策略本身已提供利润保护(50%在1:1止盈,剩余保本)
|
||||
4. 及时执行可以避免价格回落导致的利润回吐
|
||||
5. 如果需要防止秒级平仓,可以通过提高入场信号质量(MIN_SIGNAL_STRENGTH)来实现
|
||||
|
||||
### 2. 确保交易所级别止损单正确挂单
|
||||
|
||||
- 增加日志,记录挂单成功/失败
|
||||
- 如果挂单失败,重试机制
|
||||
- 定期检查并补挂止损单
|
||||
|
||||
### 3. 优化止损逻辑
|
||||
|
||||
- 止损检查应该在时间锁之前(如果采用选项B)
|
||||
- 或者完全移除时间锁对止损的限制
|
||||
|
||||
### 4. 针对小众币的优化
|
||||
|
||||
- 提高最小成交量要求(避免流动性差的币)
|
||||
- 增大止损距离(ATR倍数)以应对高波动
|
||||
- 降低杠杆倍数(降低风险)
|
||||
|
||||
## 🎯 具体修复建议
|
||||
|
||||
### 立即修复(高优先级)
|
||||
|
||||
1. **移除时间锁对止损的限制**
|
||||
- 止损应该立即执行,不受时间锁影响
|
||||
- 时间锁只应用于止盈(防止过早止盈)
|
||||
|
||||
2. **增强止损单挂单可靠性**
|
||||
- 增加重试机制
|
||||
- 增加失败告警
|
||||
- 定期检查并补挂
|
||||
|
||||
3. **优化止损检查逻辑**
|
||||
- 确保止损检查在时间锁之前(如果保留时间锁)
|
||||
- 或者完全移除时间锁
|
||||
|
||||
### 中期优化
|
||||
|
||||
1. **提高入场信号质量**
|
||||
- 提高 `MIN_SIGNAL_STRENGTH` 到 9-10
|
||||
- 只交易高质量信号
|
||||
|
||||
2. **优化止损距离**
|
||||
- 对于小众币,使用更大的ATR倍数(2.0-2.5)
|
||||
- 确保止损距离足够,不会被正常波动触发
|
||||
|
||||
3. **降低杠杆**
|
||||
- 对于小众币,降低杠杆到5-8倍
|
||||
- 降低单笔仓位到5%
|
||||
|
||||
## 📈 预期效果
|
||||
|
||||
修复后:
|
||||
- ✅ 止损能及时触发,亏损控制在8-10%以内
|
||||
- ✅ 不会出现30%以上的大额亏损
|
||||
- ✅ 胜率提升(及时止损,避免大亏)
|
||||
- ✅ 盈亏比改善(小亏大赚)
|
||||
222
docs/archive/TRADING_LOSS_ANALYSIS_2026-01-23-2.md
Normal file
222
docs/archive/TRADING_LOSS_ANALYSIS_2026-01-23-2.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# 交易亏损分析报告 - 2026-01-23(第二批)
|
||||
|
||||
## 📊 亏损交易详情
|
||||
|
||||
### 交易 #1278 (INUSDT)
|
||||
- **方向**:BUY
|
||||
- **入场价**:0.0937 USDT
|
||||
- **出场价**:0.0914 USDT
|
||||
- **价格跌幅**:**2.45%**
|
||||
- **盈亏**:-2.54 USDT
|
||||
- **盈亏比例**:**-37.00%**(相对于保证金6.87 USDT)
|
||||
- **持仓时间**:10分钟(16:03 - 16:13)
|
||||
- **平仓类型**:手动平仓 ❌
|
||||
|
||||
### 交易 #1275 (INUSDT)
|
||||
- **方向**:BUY
|
||||
- **入场价**:0.0970 USDT
|
||||
- **出场价**:0.0952 USDT
|
||||
- **价格跌幅**:**1.86%**
|
||||
- **盈亏**:-1.97 USDT
|
||||
- **盈亏比例**:**-28.60%**(相对于保证金6.90 USDT)
|
||||
- **持仓时间**:10分钟(15:33 - 15:43)
|
||||
- **平仓类型**:手动平仓 ❌
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 核心问题分析
|
||||
|
||||
### 问题1:手动平仓误判(最严重)
|
||||
|
||||
**现象**:
|
||||
- 两笔交易都被标记为"手动平仓"
|
||||
- 但亏损比例极高(-37%和-28.6%),明显是止损触发
|
||||
- 持仓时间只有10分钟,符合止损触发的特征
|
||||
|
||||
**根本原因**:
|
||||
1. **币安保护单触发机制**:
|
||||
- 币安的保护单(STOP/TAKE_PROFIT)触发后,会生成一个 MARKET 订单
|
||||
- 这个 MARKET 订单的 `reduceOnly` 字段可能为 `false`(币安API的bug或特殊情况)
|
||||
- 导致系统误判为手动平仓
|
||||
|
||||
2. **价格匹配逻辑失效**:
|
||||
- 代码使用 `_close_to(ep, sl, max_pct=0.05)` 判断平仓价格是否接近止损价
|
||||
- 但这两笔交易的平仓价格可能离止损价较远(超过5%),导致无法匹配
|
||||
- 如果止损价设置错误或滑点太大,价格匹配会失败
|
||||
|
||||
**代码位置**:`trading_system/position_manager.py:1918-1967`
|
||||
|
||||
---
|
||||
|
||||
### 问题2:止损距离可能太紧
|
||||
|
||||
**分析**:
|
||||
- 交易 #1278:价格只跌了 2.45%,但亏损比例达到 -37%
|
||||
- 交易 #1275:价格只跌了 1.86%,但亏损比例达到 -28.6%
|
||||
|
||||
**可能原因**:
|
||||
1. **ATR 太小**:
|
||||
- 如果 ATR 只有 0.5-1%,即使使用 2.5 倍 ATR,止损距离也只有 1.25-2.5%
|
||||
- 对于波动较大的币种,这个止损距离太紧了
|
||||
|
||||
2. **固定风险百分比未生效**:
|
||||
- 如果固定风险2%生效,每笔亏损应该限制在总资金的2%左右
|
||||
- 但实际亏损比例(相对于保证金)达到 28-37%,说明固定风险可能没有生效
|
||||
|
||||
3. **仓位过大**:
|
||||
- 如果固定风险计算失败,回退到传统方法(基于 MAX_POSITION_PERCENT)
|
||||
- 可能导致仓位过大,止损距离相对较小
|
||||
|
||||
---
|
||||
|
||||
### 问题3:价格匹配容忍度可能不够
|
||||
|
||||
**当前逻辑**:
|
||||
```python
|
||||
def _close_to(a: float, b: float, max_pct: float = 0.05) -> bool:
|
||||
return abs((a - b) / b) <= max_pct # 5%容忍度
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 如果止损价是 0.0900,平仓价是 0.0914,差距是 1.56%
|
||||
- 但如果止损价计算错误(比如是 0.0920),平仓价 0.0914 与止损价 0.0920 的差距是 0.65%
|
||||
- 在极端行情下,滑点可能超过5%,导致价格匹配失败
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案
|
||||
|
||||
### 方案1:改进手动平仓识别逻辑(最高优先级)
|
||||
|
||||
**问题**:当前逻辑依赖 `reduceOnly` 字段,但币安API可能不准确。
|
||||
|
||||
**解决方案**:
|
||||
1. **优先使用价格匹配**:如果平仓价格接近止损/止盈价(5%范围内),直接标记为对应类型
|
||||
2. **检查持仓时间**:如果持仓时间很短(< 30分钟)且亏损,更可能是止损触发
|
||||
3. **检查亏损比例**:如果亏损比例超过止损目标(如 -10%),更可能是止损触发
|
||||
4. **检查订单来源**:如果是系统自动下单,不应该标记为手动平仓
|
||||
|
||||
**代码修改**:
|
||||
```python
|
||||
# 在 sync_positions_with_binance 中
|
||||
# 1. 优先检查价格匹配(已实现,但需要提高优先级)
|
||||
# 2. 如果价格不匹配,但满足以下条件,也标记为止损:
|
||||
# - 持仓时间 < 30分钟
|
||||
# - 亏损比例 > 止损目标
|
||||
# - 是系统自动下单(有 trade_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案2:放宽止损距离(提高 ATR 倍数)
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER = 2.5`
|
||||
|
||||
**建议调整**:
|
||||
- 提高到 **3.0-3.5**,给波动留出更多空间
|
||||
- 或者根据币种波动率动态调整
|
||||
|
||||
**风险**:
|
||||
- 止损距离放宽后,单笔亏损会增加
|
||||
- 但如果固定风险2%生效,总亏损仍然可控
|
||||
|
||||
---
|
||||
|
||||
### 方案3:增强价格匹配逻辑
|
||||
|
||||
**当前问题**:
|
||||
- 5%的容忍度可能不够(极端滑点)
|
||||
- 只检查平仓价与止损价,没有检查实际亏损比例
|
||||
|
||||
**改进方案**:
|
||||
1. **提高容忍度**:从5%提高到8-10%
|
||||
2. **检查亏损比例**:如果实际亏损比例接近止损目标(如 -8% vs -10%),也标记为止损
|
||||
3. **检查价格方向**:如果平仓价在止损价的方向上(BUY时平仓价 < 止损价),更可能是止损触发
|
||||
|
||||
---
|
||||
|
||||
### 方案4:确保固定风险百分比生效
|
||||
|
||||
**检查点**:
|
||||
1. 确认 `USE_FIXED_RISK_SIZING = true`
|
||||
2. 确认 `FIXED_RISK_PERCENT = 0.02`(2%)
|
||||
3. 检查交易日志,确认是否显示"使用固定风险百分比计算仓位"
|
||||
4. 如果固定风险计算失败,需要修复bug
|
||||
|
||||
---
|
||||
|
||||
## 🎯 立即行动
|
||||
|
||||
### 1. 修复手动平仓识别逻辑(紧急)
|
||||
|
||||
**修改文件**:`trading_system/position_manager.py`
|
||||
|
||||
**修改位置**:`sync_positions_with_binance` 方法中的 `exit_reason` 判断逻辑
|
||||
|
||||
**修改内容**:
|
||||
1. 提高价格匹配的优先级
|
||||
2. 增加持仓时间和亏损比例的检查
|
||||
3. 如果满足止损特征,即使 `reduceOnly=false`,也标记为止损
|
||||
|
||||
---
|
||||
|
||||
### 2. 检查并调整止损距离
|
||||
|
||||
**检查**:
|
||||
1. 查看这两笔交易的 ATR 值
|
||||
2. 计算实际止损距离
|
||||
3. 确认是否使用了 2.5 倍 ATR
|
||||
|
||||
**调整**:
|
||||
- 如果 ATR 太小,考虑提高 `ATR_STOP_LOSS_MULTIPLIER` 到 3.0-3.5
|
||||
- 或者设置最小止损距离(如 3%)
|
||||
|
||||
---
|
||||
|
||||
### 3. 验证固定风险百分比
|
||||
|
||||
**检查**:
|
||||
1. 查看交易日志,确认是否使用固定风险计算
|
||||
2. 如果未使用,检查失败原因
|
||||
3. 修复bug,确保固定风险生效
|
||||
|
||||
---
|
||||
|
||||
## 📋 预期改善
|
||||
|
||||
修复后预期:
|
||||
1. **准确识别平仓原因**:止损触发不再被误判为手动平仓
|
||||
2. **止损距离更合理**:减少被随机波动扫损的概率
|
||||
3. **单笔亏损可控**:固定风险2%生效,每笔亏损限制在总资金的2%左右
|
||||
|
||||
---
|
||||
|
||||
## 🔍 需要检查的数据
|
||||
|
||||
1. **交易日志**:
|
||||
- 这两笔交易的 ATR 值
|
||||
- 止损价格
|
||||
- 是否使用固定风险计算
|
||||
- 币安订单的 `reduceOnly` 字段
|
||||
|
||||
2. **配置快照**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER` 的实际值
|
||||
- `USE_FIXED_RISK_SIZING` 的实际值
|
||||
- `FIXED_RISK_PERCENT` 的实际值
|
||||
|
||||
3. **数据库记录**:
|
||||
- 这两笔交易的 `stop_loss_price` 字段
|
||||
- `atr` 字段
|
||||
- `exit_reason` 字段
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
这两笔交易亏损比例极高(-37%和-28.6%),说明:
|
||||
1. **止损距离太紧**:价格只跌了1.86-2.45%就触发止损
|
||||
2. **固定风险可能未生效**:如果固定风险2%生效,亏损比例不应该这么高
|
||||
3. **手动平仓误判**:这两笔明显是止损触发,不应该标记为手动平仓
|
||||
|
||||
**必须立即修复**,否则系统会继续产生大额亏损。
|
||||
250
docs/archive/TRADING_LOSS_ANALYSIS_2026-01-23.md
Normal file
250
docs/archive/TRADING_LOSS_ANALYSIS_2026-01-23.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# 交易亏损分析报告 - 2026-01-23
|
||||
|
||||
## 📊 统计数据
|
||||
|
||||
- **总交易数**:107
|
||||
- **胜率**:33.68% ❌(远低于盈亏平衡点50%)
|
||||
- **总盈亏**:-4.97 USDT(亏损率 8.3%,本金60 USDT)
|
||||
- **平均盈亏**:-0.05 USDT
|
||||
- **平均持仓时长**:65分钟
|
||||
- **平仓原因**:止损 23 / 止盈 21 / 移动止损 2 / **同步 49**(45.8%)
|
||||
- **平均盈利/平均亏损**:1.22:1 ❌(远低于期望的3:1)
|
||||
- **总交易量(名义)**:1538.25 USDT
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 核心问题分析
|
||||
|
||||
### 问题1:止损距离过紧,导致大额亏损
|
||||
|
||||
**典型案例**:
|
||||
- **订单 #1246 (MANAUSDT)**:
|
||||
- 入场价:0.1815,出场价:0.1793
|
||||
- 价格跌幅:**仅 1.21%**
|
||||
- 但盈亏比例:**-18.18%**(相对于保证金)
|
||||
- 说明:止损距离太紧,价格稍微波动就触发止损
|
||||
|
||||
- **订单 #1245 (IOUSDT)**:
|
||||
- 入场价:0.1681,出场价:0.1661
|
||||
- 价格跌幅:**仅 1.19%**
|
||||
- 但盈亏比例:**-17.85%**
|
||||
|
||||
**根本原因**:
|
||||
1. **可能使用了旧的ATR止损倍数**(0.5或1.8),而不是新的2.5
|
||||
2. **固定风险百分比可能没有生效**,或者被最大仓位限制覆盖
|
||||
3. **止损距离计算错误**,导致止损价太接近入场价
|
||||
|
||||
---
|
||||
|
||||
### 问题2:固定风险百分比可能没有生效
|
||||
|
||||
**理论计算**:
|
||||
- 本金:60 USDT
|
||||
- 固定风险:2% = 1.2 USDT
|
||||
- 如果止损距离 = 2.5倍ATR,假设ATR = 0.5%,止损距离 = 1.25%
|
||||
- 仓位 = 1.2 / (入场价 × 1.25%) = 1.2 / (入场价 × 0.0125)
|
||||
|
||||
**实际情况**:
|
||||
- 大部分订单保证金:0.9-1.0 USDT(约占总资金的1.67%)
|
||||
- 但亏损比例(相对于保证金):15-31%
|
||||
- **如果固定风险2%生效,每笔亏损应该限制在总资金的2%左右,而不是保证金的15-31%**
|
||||
|
||||
**可能原因**:
|
||||
1. **固定风险计算失败**,回退到传统方法(基于MAX_POSITION_PERCENT)
|
||||
2. **止损距离太紧**,导致即使使用固定风险,实际亏损比例仍然很高
|
||||
3. **最大仓位限制覆盖了固定风险**:如果固定风险计算的保证金超过MAX_POSITION_PERCENT,会被调整为最大仓位,但止损距离不变
|
||||
|
||||
---
|
||||
|
||||
### 问题3:同步平仓过多(49笔,45.8%)
|
||||
|
||||
**问题**:
|
||||
- 49笔订单被标记为"同步平仓",说明系统无法正确识别平仓原因
|
||||
- 可能原因:
|
||||
1. **滑点太大**,超过了5%的容忍度
|
||||
2. **币安订单历史获取不完整**
|
||||
3. **WebSocket断线**,导致没有及时监控
|
||||
|
||||
**影响**:
|
||||
- 无法准确分析哪些是止损、哪些是止盈
|
||||
- 无法优化策略参数
|
||||
|
||||
---
|
||||
|
||||
### 问题4:胜率太低(33.68%)
|
||||
|
||||
**数学分析**:
|
||||
- 当前胜率:33.68%
|
||||
- 当前盈亏比:1.22:1
|
||||
- 盈亏平衡点 = 1 / (1 + 1.22) = **45.05%**
|
||||
- **当前胜率低于盈亏平衡点,必然亏损**
|
||||
|
||||
**原因**:
|
||||
1. **止损距离太紧**,导致频繁被扫损
|
||||
2. **入场信号质量不够**,或者市场环境不适合交易
|
||||
3. **止盈目标可能设置太高**,导致大部分订单无法止盈
|
||||
|
||||
---
|
||||
|
||||
## 🔍 具体案例分析
|
||||
|
||||
### 案例1:订单 #1246 (MANAUSDT)
|
||||
```
|
||||
入场价:0.1815
|
||||
出场价:0.1793
|
||||
价格跌幅:1.21%
|
||||
盈亏:-0.1716 USDT
|
||||
盈亏比例:-18.18%(相对于保证金0.9438 USDT)
|
||||
```
|
||||
|
||||
**分析**:
|
||||
- 如果使用固定风险2%,本金60 USDT,风险金额 = 1.2 USDT
|
||||
- 如果止损距离 = 1.21%,那么仓位 = 1.2 / (0.1815 × 0.0121) = 546.5
|
||||
- 实际数量:78,保证金:0.9438 USDT
|
||||
- **说明:可能使用了传统方法计算仓位,而不是固定风险**
|
||||
|
||||
### 案例2:订单 #1245 (IOUSDT)
|
||||
```
|
||||
入场价:0.1681
|
||||
出场价:0.1661
|
||||
价格跌幅:1.19%
|
||||
盈亏:-0.1726 USDT
|
||||
盈亏比例:-17.85%(相对于保证金0.967 USDT)
|
||||
```
|
||||
|
||||
**分析**:
|
||||
- 价格只跌了1.19%就触发止损
|
||||
- 如果使用2.5倍ATR止损,ATR应该约为 1.19% / 2.5 = **0.48%**
|
||||
- **但实际止损距离只有1.19%,说明可能使用了更小的ATR倍数(如0.5倍或1.8倍)**
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案
|
||||
|
||||
### 方案1:确认并应用新的策略配置(最高优先级)
|
||||
|
||||
**立即行动**:
|
||||
1. **在前端"全局配置"页面,重新应用"波段回归"方案**
|
||||
- 确保 `ATR_STOP_LOSS_MULTIPLIER = 2.5`
|
||||
- 确保 `USE_DYNAMIC_ATR_MULTIPLIER = false`
|
||||
- 确保 `USE_FIXED_RISK_SIZING = true`
|
||||
- 确保 `FIXED_RISK_PERCENT = 0.02`
|
||||
|
||||
2. **重启交易服务**,使新配置生效
|
||||
|
||||
3. **验证配置**:
|
||||
- 查看交易日志,确认是否显示"使用固定风险百分比计算仓位"
|
||||
- 确认止损距离是否基于2.5倍ATR
|
||||
|
||||
---
|
||||
|
||||
### 方案2:优化固定风险计算的逻辑
|
||||
|
||||
**问题**:如果固定风险计算的保证金超过MAX_POSITION_PERCENT,系统会调整为最大仓位,但止损距离不变,导致实际风险超过2%。
|
||||
|
||||
**建议**:
|
||||
- 当固定风险计算的保证金超过最大仓位时,应该**同时调整止损距离**,确保实际风险仍然是2%
|
||||
- 或者:**降低MAX_POSITION_PERCENT**,让固定风险计算有更多空间
|
||||
|
||||
---
|
||||
|
||||
### 方案3:降低交易频率,提高信号质量
|
||||
|
||||
**当前问题**:
|
||||
- 107笔交易,平均持仓65分钟
|
||||
- 胜率只有33.68%
|
||||
|
||||
**建议**:
|
||||
1. **提高信号强度门槛**:`MIN_SIGNAL_STRENGTH` 从8提高到9
|
||||
2. **增加扫描间隔**:`SCAN_INTERVAL` 从1800秒(30分钟)增加到3600秒(1小时)
|
||||
3. **减少TOP_N_SYMBOLS**:从8减少到5,只交易最优质的信号
|
||||
|
||||
---
|
||||
|
||||
### 方案4:优化同步平仓识别逻辑
|
||||
|
||||
**问题**:49笔同步平仓,占比45.8%
|
||||
|
||||
**建议**:
|
||||
1. **增加滑点容忍度**:从5%增加到8%,以应对极端行情
|
||||
2. **增强WebSocket监控**:确保及时接收价格更新
|
||||
3. **优化订单历史获取**:扩大时间范围,确保能获取到所有平仓订单
|
||||
|
||||
---
|
||||
|
||||
## 📋 预期改善
|
||||
|
||||
应用新的策略配置(ATR_STOP_LOSS_MULTIPLIER = 2.5)后,预期:
|
||||
|
||||
1. **止损距离放宽**:
|
||||
- 从1.2%增加到约3%(假设ATR = 1.2%)
|
||||
- 减少被随机波动扫损的概率
|
||||
|
||||
2. **胜率提升**:
|
||||
- 从33.68%提升到**50-60%**以上
|
||||
- 因为止损距离放宽,给波动留出更多空间
|
||||
|
||||
3. **单笔亏损降低**:
|
||||
- 如果固定风险2%生效,每笔亏损限制在总资金的2%左右
|
||||
- 而不是保证金的15-31%
|
||||
|
||||
4. **盈亏比改善**:
|
||||
- 从1.22:1提升到**1.5:1以上**
|
||||
- 配合止盈倍数1.5,更容易达成目标
|
||||
|
||||
---
|
||||
|
||||
## 🎯 立即行动清单
|
||||
|
||||
### 高优先级(立即执行)
|
||||
|
||||
1. ✅ **重新应用策略配置**
|
||||
- 在"全局配置"页面,点击"应用"波段回归方案
|
||||
- 确认 `ATR_STOP_LOSS_MULTIPLIER = 2.5`
|
||||
- 确认 `USE_DYNAMIC_ATR_MULTIPLIER = false`
|
||||
|
||||
2. ✅ **重启交易服务**
|
||||
- 使新配置立即生效
|
||||
|
||||
3. ✅ **验证配置**
|
||||
- 查看交易日志,确认使用固定风险计算
|
||||
- 确认止损距离基于2.5倍ATR
|
||||
|
||||
### 中优先级(本周执行)
|
||||
|
||||
4. ⏳ **提高信号质量门槛**
|
||||
- `MIN_SIGNAL_STRENGTH`: 8 → 9
|
||||
- 减少交易频率,提高胜率
|
||||
|
||||
5. ⏳ **优化固定风险计算逻辑**
|
||||
- 当超过最大仓位时,同时调整止损距离
|
||||
|
||||
---
|
||||
|
||||
## 📝 总结
|
||||
|
||||
**当前主要问题**:
|
||||
1. ❌ 止损距离太紧(可能使用了旧的0.5倍或1.8倍ATR)
|
||||
2. ❌ 固定风险百分比可能没有生效
|
||||
3. ❌ 胜率太低(33.68%)
|
||||
4. ❌ 盈亏比太低(1.22:1)
|
||||
5. ❌ 同步平仓太多(49笔,45.8%)
|
||||
|
||||
**解决方案**:
|
||||
1. ✅ 应用新的策略配置(ATR_STOP_LOSS_MULTIPLIER = 2.5)
|
||||
2. ✅ 确保固定风险百分比生效
|
||||
3. ✅ 提高信号质量门槛
|
||||
4. ✅ 优化同步平仓识别逻辑
|
||||
|
||||
**预期改善**:
|
||||
- 胜率:33.68% → **50-60%**
|
||||
- 盈亏比:1.22:1 → **1.5:1以上**
|
||||
- 单笔亏损:15-31% → **2%左右**(相对于总资金)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**当前配置可能仍在使用旧参数**(ATR_STOP_LOSS_MULTIPLIER = 0.5或1.8),导致止损距离太紧。
|
||||
|
||||
**必须在前端重新应用策略方案**,确保数据库和Redis中的配置更新为最新值。
|
||||
89
docs/archive/TRADING_LOSS_ANALYSIS_2026-02-04.md
Normal file
89
docs/archive/TRADING_LOSS_ANALYSIS_2026-02-04.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# 交易异常与数据库优化分析报告 (2026-02-04)
|
||||
|
||||
## 1. 数据库连接异常分析
|
||||
|
||||
### 问题描述
|
||||
用户反馈在 2026-02-04 02:28 左右出现 `(1040, 'Too many connections')` 错误。
|
||||
|
||||
### 原因分析
|
||||
检查 `backend/database/connection.py` 代码发现,原有的数据库连接管理使用了 `pymysql.connect` 直接建立连接,虽然使用了 `contextmanager` 确保关闭,但在高并发或频繁请求下(如策略扫描、推荐系统同时运行),会频繁创建和销毁连接。
|
||||
MySQL 建立连接开销较大,且如果不使用连接池,每一个 API 请求或后台任务都会占用一个物理连接。当并发量瞬间增加时,很容易达到 MySQL 的最大连接数限制(默认通常是 151)。
|
||||
|
||||
### 解决方案(已实施)
|
||||
已修改 `backend/database/connection.py`,引入了 `SQLAlchemy` 的连接池 (`QueuePool`) 机制。
|
||||
- **连接池配置**:
|
||||
- `pool_size=20`: 基础连接池大小保持 20 个活跃连接。
|
||||
- `max_overflow=30`: 高峰期可临时增加 30 个连接(总计 50 个)。
|
||||
- `pool_recycle=3600`: 连接存活 1 小时后回收,防止 MySQL 8小时超时断开问题。
|
||||
- `pool_pre_ping=True`: 每次获取连接前自动检测有效性,防止获取到已断开的连接。
|
||||
|
||||
此优化将显著降低数据库连接开销,彻底解决 "Too many connections" 问题。
|
||||
|
||||
---
|
||||
|
||||
## 2. ZROUSDT 50% 亏损交易分析
|
||||
|
||||
### 交易详情
|
||||
- **交易对**: ZROUSDT
|
||||
- **方向**: 做多 (BUY)
|
||||
- **开仓时间**: 2026-02-04 02:28 (大致)
|
||||
- **入场价**: 1.7978
|
||||
- **止损价**: 1.68307
|
||||
- **止损触发价**: 1.6819 (实际平仓价)
|
||||
- **杠杆倍数**: 8x
|
||||
- **盈亏比例**: -51.57%
|
||||
|
||||
### 亏损原因深度拆解
|
||||
1. **价格波动幅度**:
|
||||
止损距离 = (1.7978 - 1.68307) / 1.7978 ≈ **6.38%**
|
||||
实际平仓跌幅 = (1.7978 - 1.6819) / 1.7978 ≈ **6.45%**
|
||||
|
||||
2. **杠杆放大效应**:
|
||||
盈亏比例 = 价格跌幅 × 杠杆倍数
|
||||
盈亏比例 = 6.45% × 8 ≈ **51.6%**
|
||||
|
||||
**结论**: 此次 -51.57% 的亏损在数学上是完全符合预期的。
|
||||
当策略允许 **6.4%** 的止损宽度,并且强制使用 **8倍** 杠杆时,一旦止损触发,本金(保证金)必然损失 **51%**。
|
||||
|
||||
3. **资金风险 vs 本金风险**:
|
||||
虽然单笔交易损失了 50% 的保证金,但系统是基于 `FIXED_RISK_PERCENT` (默认 2%) 来计算仓位的。
|
||||
- 假设账户余额 100 U,风险 2 U。
|
||||
- 止损距离 6.4%,风险 2 U => 仓位价值 = 2 / 6.4% ≈ 31.25 U。
|
||||
- 杠杆 8x => 占用保证金 = 31.25 / 8 ≈ 3.9 U。
|
||||
- 亏损 2 U。
|
||||
- 亏损占保证金比例 = 2 / 3.9 ≈ **51%**。
|
||||
|
||||
**关键点**: 实际上账户总资产仅损失了预设的 **2%**(符合风控),但对于这笔具体的交易单,其保证金损失过半,视觉冲击力极强,且接近强平线(8倍杠杆强平线约在 -12.5% 跌幅,6.4% 已经走了一半)。
|
||||
|
||||
### 优化建议
|
||||
|
||||
为了避免出现单笔交易亏损 > 30% 甚至接近强平的情况,建议引入 **动态杠杆 (Dynamic Leverage)** 机制。
|
||||
|
||||
#### 建议方案:基于止损宽度的动态杠杆
|
||||
不应固定使用 8x 杠杆,而应根据止损距离自动调整杠杆。
|
||||
|
||||
**公式**: `建议杠杆 = 目标最大单单亏损率 / 止损宽度`
|
||||
|
||||
假设我们希望单笔交易止损时,保证金亏损不超过 **20%** (MAX_ROE_LOSS = 0.2):
|
||||
|
||||
- **当前案例 (ZRO)**: 止损宽度 6.4%。
|
||||
- 建议杠杆 = 20% / 6.4% ≈ **3.1倍**。
|
||||
- 如果使用 3x 杠杆:
|
||||
- 仓位价值不变(仍由 2% 总账户风险决定)。
|
||||
- 保证金占用增加(从 3.9U 变为 10.4U)。
|
||||
- 亏损仍为 2U。
|
||||
- **保证金亏损率 = 2 / 10.4 ≈ 19.2%**。
|
||||
|
||||
**优点**:
|
||||
1. 降低强平风险(3x 杠杆强平线在 -33%,远低于 -6.4%)。
|
||||
2. 心理体验更好(亏损比例控制在 20% 以内)。
|
||||
3. 资金利用率更健康(高波动币种自动降低杠杆,低波动币种可维持高杠杆)。
|
||||
|
||||
#### 后续行动计划
|
||||
1. 在 `config.py` 中添加 `DYNAMIC_LEVERAGE_ENABLED = True` 和 `MAX_SINGLE_TRADE_LOSS_PERCENT = 20` (单单最大本金亏损率)。
|
||||
2. 修改 `risk_manager.py`,在计算仓位前,先根据 `stop_loss_price` 计算最大允许杠杆,并取 `min(CONFIG_LEVERAGE, dynamic_leverage)`。
|
||||
|
||||
---
|
||||
**总结**:
|
||||
- 数据库连接问题已通过引入连接池修复。
|
||||
- ZROUSDT 交易逻辑正常,大比例亏损源于 **宽止损 + 高固定杠杆** 的组合。建议实施动态杠杆优化。
|
||||
290
docs/archive/TRADING_PERFORMANCE_ANALYSIS.md
Normal file
290
docs/archive/TRADING_PERFORMANCE_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# 交易表现分析 - 2026-01-23
|
||||
|
||||
## 📊 今日统计
|
||||
|
||||
- **总交易数**:35
|
||||
- **胜率**:44.00%
|
||||
- **总盈亏**:2.03 USDT
|
||||
- **平均盈亏**:0.08 USDT
|
||||
- **平均持仓时长**:35分钟
|
||||
- **平仓原因**:止盈 1 / 手动 9 / 同步 15
|
||||
- **平均盈利/平均亏损**:1.35 : 1(期望 3:1)
|
||||
- **总交易量(名义)**:3512.05 USDT
|
||||
|
||||
## ⚠️ 严重问题分析
|
||||
|
||||
### 问题1:盈亏比严重失衡(1.35:1 vs 期望3:1)
|
||||
|
||||
**现状**:
|
||||
- 平均盈利/平均亏损 = 1.35:1
|
||||
- 胜率 = 44%
|
||||
- 期望盈亏比 = 3:1
|
||||
|
||||
**数学分析**:
|
||||
- 盈亏平衡点 = 1 / (1 + 盈亏比) = 1 / (1 + 1.35) = **42.55%**
|
||||
- 当前胜率 44% 仅略高于盈亏平衡点,所以总盈亏只有 2.03 USDT(几乎不盈利)
|
||||
- 如果盈亏比达到 3:1,盈亏平衡点 = 1 / (1 + 3) = **25%**
|
||||
- 在胜率 44% 的情况下,盈亏比 3:1 的期望收益 = (0.44 × 3) - (0.56 × 1) = **0.76**(每笔亏损赚0.76倍)
|
||||
|
||||
**结论**:盈亏比 1.35:1 太低了,必须提升到至少 2:1 才能稳定盈利。
|
||||
|
||||
---
|
||||
|
||||
### 问题2:大额亏损(30-50%)说明止损失效
|
||||
|
||||
**具体案例**:
|
||||
- #1138 DUSKUSDT: **-31.28%**(同步平仓)
|
||||
- #1135 RIVERUSDT: **-49.06%**(同步平仓)
|
||||
- #1133 BDXNUSDT: **-31.69%**(手动平仓)
|
||||
|
||||
**问题分析**:
|
||||
1. **固定风险百分比应该限制亏损为2%**,但实际亏损达到30-50%
|
||||
2. **说明止损没有及时执行**,或者止损价格计算错误
|
||||
3. **"同步平仓"** 可能是在止损触发后,系统同步币安状态时发现已经亏损很大
|
||||
|
||||
**可能原因**:
|
||||
1. 止损单没有正确挂到交易所
|
||||
2. 止损价格计算错误(可能基于价格百分比而不是保证金百分比)
|
||||
3. WebSocket 监控断线,没有及时触发止损
|
||||
4. 固定风险百分比计算时,止损距离估算错误
|
||||
|
||||
---
|
||||
|
||||
### 问题3:止盈太少(35笔只有1笔止盈)
|
||||
|
||||
**现状**:
|
||||
- 35笔交易,只有1笔止盈(2.86%)
|
||||
- 15笔同步平仓,9笔手动平仓
|
||||
|
||||
**问题分析**:
|
||||
1. **止盈目标可能设置太高**:`ATR_TAKE_PROFIT_MULTIPLIER = 1.5` 可能仍然太高
|
||||
2. **大部分订单被提前平仓**:15笔同步平仓可能是止损触发,9笔手动平仓可能是用户干预
|
||||
3. **止盈单可能没有正确挂到交易所**
|
||||
|
||||
---
|
||||
|
||||
### 问题4:固定风险百分比可能没有生效
|
||||
|
||||
**理论**:
|
||||
- 固定风险百分比 = 2%
|
||||
- 如果止损距离 = 5%,那么仓位 = (总资金 × 2%) / 5% = 总资金的 40%
|
||||
- 如果止损触发,亏损 = 总资金的 2%(符合预期)
|
||||
|
||||
**实际情况**:
|
||||
- 亏损达到 30-50%,说明:
|
||||
1. 固定风险百分比没有生效
|
||||
2. 或者止损距离计算错误(止损距离太小,导致仓位过大)
|
||||
3. 或者止损没有及时触发
|
||||
|
||||
---
|
||||
|
||||
## 🔍 根本原因分析
|
||||
|
||||
### 1. 止损执行问题
|
||||
|
||||
**可能原因**:
|
||||
- 止损单没有正确挂到交易所
|
||||
- WebSocket 监控断线,没有及时触发止损
|
||||
- 止损价格计算错误
|
||||
|
||||
**验证方法**:
|
||||
- 查看日志,确认止损单是否成功挂到交易所
|
||||
- 检查 WebSocket 监控是否正常运行
|
||||
- 检查止损价格计算逻辑
|
||||
|
||||
### 2. 固定风险百分比可能没有生效
|
||||
|
||||
**验证方法**:
|
||||
- 检查 `USE_FIXED_RISK_SIZING` 是否启用
|
||||
- 检查开仓日志,确认是否使用了固定风险计算
|
||||
- 检查止损距离估算是否准确
|
||||
|
||||
### 3. 止盈目标设置问题
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER = 1.5`
|
||||
- `TAKE_PROFIT_PERCENT = 25%`(相对于保证金)
|
||||
|
||||
**问题**:
|
||||
- 如果 ATR 很大,1.5倍 ATR 的止盈目标可能很难达到
|
||||
- 25% 的止盈目标对于小币种可能太高
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案
|
||||
|
||||
### 方案1:确保止损正确执行(最高优先级)
|
||||
|
||||
1. **检查止损单是否挂到交易所**
|
||||
- 在开仓后立即检查止损单状态
|
||||
- 如果挂单失败,重试或报警
|
||||
|
||||
2. **增强 WebSocket 监控可靠性**
|
||||
- 增加心跳检测
|
||||
- 增加断线重连机制
|
||||
- 增加兜底巡检(每1-2分钟检查一次)
|
||||
|
||||
3. **修复止损价格计算**
|
||||
- 确保止损基于保证金百分比,而不是价格百分比
|
||||
- 确保止损距离估算准确
|
||||
|
||||
### 方案2:验证并修复固定风险百分比
|
||||
|
||||
1. **检查配置**
|
||||
- 确认 `USE_FIXED_RISK_SIZING = True`
|
||||
- 确认 `FIXED_RISK_PERCENT = 0.02`(2%)
|
||||
|
||||
2. **检查计算逻辑**
|
||||
- 确认止损距离估算准确
|
||||
- 确认仓位计算使用了固定风险公式
|
||||
|
||||
3. **增加日志**
|
||||
- 记录固定风险计算的详细过程
|
||||
- 记录实际止损距离和仓位大小
|
||||
|
||||
### 方案3:调整止盈目标
|
||||
|
||||
1. **降低止盈目标**
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER` 从 1.5 降到 1.2
|
||||
- `TAKE_PROFIT_PERCENT` 从 25% 降到 20%
|
||||
|
||||
2. **确保止盈单正确挂到交易所**
|
||||
- 在开仓后立即挂止盈单
|
||||
- 检查止盈单状态
|
||||
|
||||
---
|
||||
|
||||
## 📋 立即行动清单
|
||||
|
||||
### 高优先级(立即执行)
|
||||
|
||||
1. ✅ **检查止损单挂单状态**
|
||||
- 在开仓后立即检查止损单是否成功挂到交易所
|
||||
- 如果失败,重试或报警
|
||||
|
||||
2. ✅ **验证固定风险百分比是否生效**
|
||||
- 检查开仓日志,确认是否使用了固定风险计算
|
||||
- 如果未生效,修复计算逻辑
|
||||
|
||||
3. ✅ **增强止损执行可靠性**
|
||||
- 增加 WebSocket 心跳检测
|
||||
- 增加兜底巡检(每1-2分钟检查一次)
|
||||
|
||||
### 中优先级(本周执行)
|
||||
|
||||
4. ⏳ **调整止盈目标**
|
||||
- 降低 `ATR_TAKE_PROFIT_MULTIPLIER` 到 1.2
|
||||
- 降低 `TAKE_PROFIT_PERCENT` 到 20%
|
||||
|
||||
5. ⏳ **增加诊断日志**
|
||||
- 记录止损单挂单状态
|
||||
- 记录固定风险计算过程
|
||||
- 记录实际止损距离
|
||||
|
||||
---
|
||||
|
||||
## 🎯 目标指标
|
||||
|
||||
### 当前表现
|
||||
- 盈亏比:1.35:1 ❌
|
||||
- 胜率:44% ✅
|
||||
- 总盈亏:2.03 USDT(几乎不盈利)❌
|
||||
|
||||
### 目标表现
|
||||
- 盈亏比:≥ 2.0:1(理想 3:1)✅
|
||||
- 胜率:≥ 40% ✅
|
||||
- 单笔最大亏损:≤ 5%(固定风险2% + 滑点)✅
|
||||
- 止盈率:≥ 30%(35笔中至少10笔止盈)✅
|
||||
|
||||
---
|
||||
|
||||
## 📝 下一步
|
||||
|
||||
1. ✅ **已修复**:固定风险百分比计算逻辑(已添加到代码中)
|
||||
2. ✅ **已增强**:止损单挂单状态日志(成功/失败都会记录)
|
||||
3. ⏳ **待验证**:重新运行后观察是否还有30%以上的亏损
|
||||
4. ⏳ **待调整**:降低止盈目标,提高止盈率
|
||||
|
||||
---
|
||||
|
||||
## 🔧 已实施的修复
|
||||
|
||||
### 1. ✅ 修复固定风险百分比计算逻辑
|
||||
|
||||
**问题**:固定风险百分比计算逻辑缺失,导致系统没有使用固定风险公式计算仓位。
|
||||
|
||||
**修复**:
|
||||
- 在 `risk_manager.py` 的 `calculate_position_size()` 中添加了完整的固定风险百分比计算逻辑
|
||||
- 如果 `USE_FIXED_RISK_SIZING = True` 且提供了 `entry_price` 和 `side`,会使用固定风险公式
|
||||
- 公式:`quantity = (总资金 × 2%) / (入场价 - 止损价)`
|
||||
|
||||
### 2. ✅ 增强止损单挂单状态日志
|
||||
|
||||
**问题**:无法知道止损单是否成功挂到交易所。
|
||||
|
||||
**修复**:
|
||||
- 在 `position_manager.py` 的 `_ensure_exchange_sltp_orders()` 中增加了日志
|
||||
- 止损单和止盈单挂单成功/失败都会记录日志
|
||||
- 如果挂单失败,会明确提示将依赖WebSocket监控
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键发现
|
||||
|
||||
### 为什么会出现30-50%的亏损?
|
||||
|
||||
**根本原因**:
|
||||
1. **固定风险百分比没有生效**(已修复)
|
||||
- 如果固定风险百分比生效,每笔亏损应该限制在2%
|
||||
- 但实际亏损达到30-50%,说明固定风险百分比没有生效
|
||||
|
||||
2. **止损单可能没有正确挂到交易所**
|
||||
- 如果止损单挂单失败,系统只能依赖WebSocket监控
|
||||
- 如果WebSocket断线,止损可能无法及时执行
|
||||
|
||||
3. **止损价格计算可能有问题**
|
||||
- 止损可能基于价格百分比而不是保证金百分比
|
||||
- 或者止损距离估算错误
|
||||
|
||||
### 为什么盈亏比只有1.35:1?
|
||||
|
||||
**原因**:
|
||||
1. **止盈目标设置太高**:`ATR_TAKE_PROFIT_MULTIPLIER = 1.5` 可能仍然太高
|
||||
2. **止盈单可能没有正确挂到交易所**:只有1笔止盈,说明大部分订单没有达到止盈目标
|
||||
3. **大部分订单被提前平仓**:15笔同步平仓(可能是止损),9笔手动平仓(用户干预)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期改善
|
||||
|
||||
修复后,预期:
|
||||
- ✅ **单笔最大亏损**:从30-50%降低到≤5%(固定风险2% + 滑点)
|
||||
- ✅ **盈亏比**:从1.35:1提升到≥2.0:1(通过降低止盈目标)
|
||||
- ✅ **止盈率**:从2.86%提升到≥30%(通过降低止盈目标)
|
||||
|
||||
---
|
||||
|
||||
## 📋 建议的配置调整
|
||||
|
||||
### 立即调整(在GlobalConfig中)
|
||||
|
||||
1. **降低止盈目标**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 1.5 → **1.2**
|
||||
- `TAKE_PROFIT_PERCENT`: 25% → **20%**
|
||||
|
||||
2. **确保固定风险百分比启用**:
|
||||
- `USE_FIXED_RISK_SIZING`: **True**
|
||||
- `FIXED_RISK_PERCENT`: **0.02** (2%)
|
||||
|
||||
3. **确保止损单挂单**:
|
||||
- `EXCHANGE_SLTP_ENABLED`: **True**(默认已启用)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证方法
|
||||
|
||||
修复后,请观察:
|
||||
1. **开仓日志**:是否显示"使用固定风险百分比计算仓位"
|
||||
2. **止损单日志**:是否显示"止损单已成功挂到交易所"
|
||||
3. **实际亏损**:是否还有30%以上的亏损
|
||||
4. **止盈率**:是否提升到30%以上
|
||||
103
docs/archive/WEBSOCKET_HOLD_TIME_MINUTES_FIX.md
Normal file
103
docs/archive/WEBSOCKET_HOLD_TIME_MINUTES_FIX.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# WebSocket监控 hold_time_minutes 变量未初始化修复
|
||||
|
||||
## 🔍 问题描述
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
trading_system.position_manager - WARNING - FLUIDUSDT WebSocket监控出错 (重试 1/5):
|
||||
cannot access local variable 'hold_time_minutes' where it is not associated with a value
|
||||
```
|
||||
|
||||
**问题根源**:
|
||||
在 `_check_single_position()` 方法中,`hold_time_minutes` 变量只在**止损分支**(第2725-2734行)中被初始化,但在**止盈分支**(第2889行)中也被使用。当代码走止盈路径时,变量未被初始化,导致 `UnboundLocalError`。
|
||||
|
||||
## ✅ 修复方案
|
||||
|
||||
### 修复位置
|
||||
`trading_system/position_manager.py` 的 `_check_single_position()` 方法
|
||||
|
||||
### 修复内容
|
||||
|
||||
在止盈分支(第2877行之后)中,添加 `hold_time_minutes` 的初始化逻辑:
|
||||
|
||||
**修复前**:
|
||||
```python
|
||||
# 直接比较当前盈亏百分比与止盈目标(基于保证金)
|
||||
if pnl_percent_margin >= take_profit_pct_margin:
|
||||
should_close = True
|
||||
exit_reason = 'take_profit'
|
||||
|
||||
# 详细诊断日志:记录平仓时的所有关键信息
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"{symbol} [实时监控-平仓诊断日志] ===== 触发止盈平仓 =====")
|
||||
# ...
|
||||
logger.info(f" 持仓时间: {hold_time_minutes:.1f} 分钟") # ❌ 变量未初始化
|
||||
# ...
|
||||
```
|
||||
|
||||
**修复后**:
|
||||
```python
|
||||
# 直接比较当前盈亏百分比与止盈目标(基于保证金)
|
||||
if pnl_percent_margin >= take_profit_pct_margin:
|
||||
should_close = True
|
||||
exit_reason = 'take_profit'
|
||||
|
||||
# 计算持仓时间(用于日志)
|
||||
entry_time = position_info.get('entryTime')
|
||||
hold_time_minutes = 0
|
||||
if entry_time:
|
||||
try:
|
||||
if isinstance(entry_time, datetime):
|
||||
hold_time_sec = int((get_beijing_time() - entry_time).total_seconds())
|
||||
else:
|
||||
hold_time_sec = int(time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0))
|
||||
hold_time_minutes = hold_time_sec / 60.0
|
||||
except Exception:
|
||||
hold_time_minutes = 0
|
||||
|
||||
# 详细诊断日志:记录平仓时的所有关键信息
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"{symbol} [实时监控-平仓诊断日志] ===== 触发止盈平仓 =====")
|
||||
# ...
|
||||
logger.info(f" 持仓时间: {hold_time_minutes:.1f} 分钟") # ✅ 变量已初始化
|
||||
# ...
|
||||
```
|
||||
|
||||
## 📊 修复效果
|
||||
|
||||
### 修复前
|
||||
- ❌ 止盈触发时 → 尝试使用 `hold_time_minutes` → `UnboundLocalError` → WebSocket监控出错 → 重试
|
||||
|
||||
### 修复后
|
||||
- ✅ 止盈触发时 → `hold_time_minutes` 已初始化 → 正常记录日志 → WebSocket监控正常
|
||||
|
||||
## 🔄 相关代码路径
|
||||
|
||||
1. **止损分支**(第2725-2734行):已正确初始化 `hold_time_minutes`
|
||||
2. **止盈分支**(第2877-2907行):**已修复**,现在也会初始化 `hold_time_minutes`
|
||||
3. **第二目标止盈分支**(第2838-2846行):未使用 `hold_time_minutes`,无需修复
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **变量作用域**:`hold_time_minutes` 是局部变量,只在各自的代码分支内有效。
|
||||
2. **初始化逻辑**:与止损分支的初始化逻辑保持一致,确保计算方式统一。
|
||||
3. **异常处理**:如果计算持仓时间失败,默认设置为 0,避免程序崩溃。
|
||||
|
||||
## 🚀 部署建议
|
||||
|
||||
1. **重启交易进程**:修复后需要重启所有 `trading_system` 进程才能生效。
|
||||
```bash
|
||||
supervisorctl restart auto_sys_acc1 auto_sys_acc2 auto_sys_acc3 ...
|
||||
```
|
||||
|
||||
2. **验证修复**:查看日志,确认 WebSocket 监控不再出现 `hold_time_minutes` 相关错误。
|
||||
|
||||
## 📝 相关文件
|
||||
|
||||
- `trading_system/position_manager.py`:主要修复文件
|
||||
- `_check_single_position()` 方法(第2580-2960行)
|
||||
- `_monitor_position_price()` 方法(第2499-2578行)
|
||||
|
||||
## ✅ 修复完成时间
|
||||
|
||||
2026-01-25
|
||||
135
docs/archive/亏损分析_ZENUSDT.md
Normal file
135
docs/archive/亏损分析_ZENUSDT.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# 亏损分析 - ZENUSDT
|
||||
|
||||
## 📊 当前情况
|
||||
|
||||
### 交易信息
|
||||
|
||||
```
|
||||
ZENUSDT [实时监控] 诊断: 亏损-10.19% of margin |
|
||||
当前价: 9.6910 |
|
||||
入场价: 9.8160 |
|
||||
止损价: 9.6197 (目标: -16.00% of margin) |
|
||||
方向: BUY |
|
||||
是否触发: False |
|
||||
监控状态: 运行中
|
||||
```
|
||||
|
||||
### 计算验证
|
||||
|
||||
**价格变化**:
|
||||
- 入场价:9.8160
|
||||
- 当前价:9.6910
|
||||
- 价格跌幅 = (9.8160 - 9.6910) / 9.8160 = **1.27%**
|
||||
|
||||
**保证金亏损**:
|
||||
- 假设杠杆:8倍(山寨币策略默认)
|
||||
- 保证金亏损 = 1.27% × 8 = **10.16%**(接近-10.19%)
|
||||
|
||||
**止损价计算**:
|
||||
- 止损价:9.6197
|
||||
- 止损距离 = 9.8160 - 9.6197 = 0.1963
|
||||
- 止损百分比(价格)= 0.1963 / 9.8160 = **2.00%**
|
||||
- 止损目标(保证金)= 2.00% × 8 = **16.00%**
|
||||
|
||||
---
|
||||
|
||||
## ✅ 是否正常?
|
||||
|
||||
### 结论:**正常** ✅
|
||||
|
||||
**理由**:
|
||||
|
||||
1. **亏损-10.19%是正常的市场波动**
|
||||
- 价格只下跌了1.27%,这是正常的市场波动
|
||||
- 山寨币波动大,1-2%的价格波动很常见
|
||||
|
||||
2. **止损目标-16%符合策略配置**
|
||||
- 山寨币策略配置:`STOP_LOSS_PERCENT = 15%`(固定止损)
|
||||
- 实际止损目标-16%可能是因为:
|
||||
- ATR止损计算的结果(`ATR_STOP_LOSS_MULTIPLIER = 2.0`)
|
||||
- 止损价选择逻辑选择了ATR止损而不是固定止损
|
||||
- 16%接近15%,在合理范围内
|
||||
|
||||
3. **止损未触发是正常的**
|
||||
- 当前亏损-10.19% < 止损目标-16%
|
||||
- 止损机制正常工作,会在亏损达到-16%时触发
|
||||
|
||||
4. **符合山寨币策略设计**
|
||||
- 山寨币策略设计:宽止损(15%)+ 高盈亏比(4:1)
|
||||
- 允许较大的价格波动,避免被正常波动扫损
|
||||
|
||||
---
|
||||
|
||||
## 📈 止损价分析
|
||||
|
||||
### 止损价计算方式
|
||||
|
||||
根据山寨币策略配置:
|
||||
- `STOP_LOSS_PERCENT = 15%`(固定止损15%)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER = 2.0`(ATR止损2.0倍)
|
||||
|
||||
**实际止损价**:9.6197
|
||||
- 止损距离 = 9.8160 - 9.6197 = 0.1963
|
||||
- 止损百分比 = 0.1963 / 9.8160 = 2.00%
|
||||
- 保证金亏损 = 2.00% × 8 = 16.00%
|
||||
|
||||
**分析**:
|
||||
- 如果使用固定止损15%,止损价应该是:9.8160 × (1 - 0.15/8) = 9.8160 × 0.98125 = 9.6315
|
||||
- 实际止损价9.6197 < 9.6315,说明可能使用了ATR止损
|
||||
- ATR止损可能计算出了更远的止损价(更宽松)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 需要注意的情况
|
||||
|
||||
### 1. 如果亏损继续扩大
|
||||
|
||||
**如果价格继续下跌**:
|
||||
- 当前亏损:-10.19%
|
||||
- 止损目标:-16.00%
|
||||
- **还有5.81%的缓冲空间**
|
||||
|
||||
**建议**:
|
||||
- 如果亏损达到-15%,接近止损目标,可以关注
|
||||
- 如果亏损达到-16%,止损会自动触发
|
||||
|
||||
### 2. 如果止损未及时触发
|
||||
|
||||
**如果价格快速下跌,止损未及时触发**:
|
||||
- 检查WebSocket监控是否正常工作
|
||||
- 检查止损单是否正常挂到交易所
|
||||
- 如果止损单失效,系统会通过WebSocket监控触发平仓
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
### ✅ 当前情况:**正常**
|
||||
|
||||
1. **亏损-10.19%是正常的市场波动**
|
||||
- 价格只下跌了1.27%,这是正常的
|
||||
- 山寨币波动大,1-2%的价格波动很常见
|
||||
|
||||
2. **止损目标-16%符合策略配置**
|
||||
- 接近15%的固定止损设置
|
||||
- 可能是ATR止损计算的结果
|
||||
|
||||
3. **止损机制正常工作**
|
||||
- 当前亏损-10.19% < 止损目标-16%
|
||||
- 止损会在亏损达到-16%时自动触发
|
||||
|
||||
4. **符合山寨币策略设计**
|
||||
- 宽止损(15%)允许较大的价格波动
|
||||
- 避免被正常波动扫损
|
||||
|
||||
### 💡 建议
|
||||
|
||||
1. **继续观察**:如果亏损继续扩大,接近-15%时可以关注
|
||||
2. **信任策略**:止损机制正常工作,会在达到-16%时自动触发
|
||||
3. **不要手动干预**:除非系统故障,否则不要手动平仓
|
||||
|
||||
---
|
||||
|
||||
## ✅ 完成时间
|
||||
|
||||
2026-01-25
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user