Compare commits

...

172 Commits

Author SHA1 Message Date
薇薇安
d3f2cce922 a 2026-02-01 22:49:07 +08:00
薇薇安
8ff8cd4ebc a 2026-02-01 22:37:27 +08:00
薇薇安
18257b7d8a a 2026-02-01 22:36:52 +08:00
薇薇安
0a4bbd3132 a 2026-02-01 22:30:53 +08:00
薇薇安
ce3a4953f5 a 2026-02-01 22:19:59 +08:00
薇薇安
4da3e0bd48 a 2026-02-01 22:15:35 +08:00
薇薇安
c01f681dec a 2026-02-01 22:04:43 +08:00
薇薇安
0a0bcd941b a 2026-02-01 20:45:18 +08:00
薇薇安
cb8b393550 a 2026-02-01 12:35:56 +08:00
薇薇安
cf86c64296 a 2026-01-31 10:35:55 +08:00
薇薇安
380ce7cda9 a 2026-01-31 10:14:57 +08:00
薇薇安
aaca165f55 a 2026-01-31 10:12:09 +08:00
薇薇安
6e23c924b2 a 2026-01-31 09:57:11 +08:00
薇薇安
4f21240116 a 2026-01-30 11:03:30 +08:00
薇薇安
9490207537 a 2026-01-29 23:34:15 +08:00
薇薇安
53396adf26 a 2026-01-29 18:45:32 +08:00
薇薇安
f1a82f53e0 a 2026-01-29 09:00:41 +08:00
薇薇安
e328272701 a 2026-01-29 08:55:09 +08:00
薇薇安
15394445b4 a 2026-01-28 21:53:41 +08:00
薇薇安
8337893b0c a 2026-01-28 20:27:34 +08:00
薇薇安
8422e93aa2 a 2026-01-28 19:05:18 +08:00
薇薇安
461aeaf359 a 2026-01-28 17:37:04 +08:00
薇薇安
8eb2476192 a 2026-01-28 17:12:45 +08:00
薇薇安
3865e25a2b a 2026-01-28 10:13:30 +08:00
薇薇安
dfd899256b a 2026-01-27 23:04:42 +08:00
薇薇安
cf678569ee a 2026-01-27 22:40:23 +08:00
薇薇安
5faf3e103d a 2026-01-27 19:04:15 +08:00
薇薇安
fb04f69965 a 2026-01-27 16:19:23 +08:00
薇薇安
1d25b3cb79 a 2026-01-27 16:06:28 +08:00
薇薇安
4c5d040746 a 2026-01-27 16:03:10 +08:00
薇薇安
8667c07134 a 2026-01-27 15:56:59 +08:00
薇薇安
8e365b3a9a a 2026-01-27 11:28:57 +08:00
薇薇安
16c4cfbdd8 a 2026-01-27 11:11:03 +08:00
薇薇安
9fe028d704 a 2026-01-27 10:36:56 +08:00
薇薇安
d4edc16f43 a 2026-01-27 08:44:35 +08:00
薇薇安
88ed3bfab4 a 2026-01-27 08:32:29 +08:00
薇薇安
9e0f180229 a 2026-01-26 22:14:35 +08:00
薇薇安
042cb02563 a 2026-01-26 21:08:06 +08:00
薇薇安
3057ce0e8b a 2026-01-26 21:00:43 +08:00
薇薇安
1eb5c618eb a 2026-01-26 20:26:21 +08:00
薇薇安
51fd3c6550 a 2026-01-26 16:22:18 +08:00
薇薇安
be6459d5dd a 2026-01-26 16:08:18 +08:00
薇薇安
9448996837 a 2026-01-26 15:50:49 +08:00
薇薇安
ed994e6e8e a 2026-01-26 15:50:06 +08:00
薇薇安
7f736c9081 a 2026-01-26 14:25:59 +08:00
薇薇安
7947101bc4 a 2026-01-25 16:55:14 +08:00
薇薇安
c3a14f0f1a a 2026-01-25 16:53:40 +08:00
薇薇安
83e628b611 a 2026-01-25 16:32:08 +08:00
薇薇安
07d3bf4398 a 2026-01-25 16:31:37 +08:00
薇薇安
00751298bb a 2026-01-25 15:59:53 +08:00
薇薇安
4c640f780b a 2026-01-25 12:51:14 +08:00
薇薇安
86b85c2609 a 2026-01-25 11:19:39 +08:00
薇薇安
096b838769 a 2026-01-25 11:04:50 +08:00
薇薇安
10fd7a7d60 a 2026-01-25 10:59:34 +08:00
薇薇安
04f222875a a 2026-01-25 09:23:01 +08:00
薇薇安
1032295052 a 2026-01-25 09:16:16 +08:00
薇薇安
d9270ad6b4 a 2026-01-25 09:12:22 +08:00
薇薇安
762e9c4b38 a 2026-01-25 08:41:01 +08:00
薇薇安
731e71aae8 a 2026-01-24 19:08:55 +08:00
薇薇安
8d2fb4b9af a 2026-01-24 18:56:01 +08:00
薇薇安
f716ea69d5 a 2026-01-24 16:09:39 +08:00
薇薇安
81c73eb9cd a 2026-01-24 15:57:18 +08:00
薇薇安
f7c68efb3e a 2026-01-24 11:00:32 +08:00
薇薇安
b01920cadf a 2026-01-24 10:56:48 +08:00
薇薇安
f2d71d3390 a 2026-01-24 10:32:41 +08:00
薇薇安
27ddbcb8c1 a 2026-01-24 10:29:40 +08:00
薇薇安
6504efbf15 a 2026-01-23 23:46:29 +08:00
薇薇安
fb3d1a0dda a 2026-01-23 21:43:36 +08:00
薇薇安
14b5acae09 a 2026-01-23 21:29:31 +08:00
薇薇安
6341bacc20 a 2026-01-23 21:24:14 +08:00
薇薇安
aca1cf26b7 a 2026-01-23 21:07:35 +08:00
薇薇安
7847d3100b a 2026-01-23 20:54:37 +08:00
薇薇安
8d3991c74c a 2026-01-23 20:47:11 +08:00
薇薇安
211ef38ee9 a 2026-01-23 20:42:05 +08:00
薇薇安
fad8a1d6fd a 2026-01-23 20:35:11 +08:00
薇薇安
150eea7a28 a 2026-01-23 20:29:59 +08:00
薇薇安
e1c6cc2681 a 2026-01-23 20:24:06 +08:00
薇薇安
0c98bfe236 a 2026-01-23 20:12:32 +08:00
薇薇安
7adf5c7126 a 2026-01-23 20:03:07 +08:00
薇薇安
7a64ff44c2 a 2026-01-23 19:46:43 +08:00
薇薇安
2ee6e7a009 a 2026-01-23 19:41:44 +08:00
薇薇安
1fcd692368 a 2026-01-23 19:31:20 +08:00
薇薇安
cb7b091280 a 2026-01-23 19:21:37 +08:00
薇薇安
95abbf50be a 2026-01-23 19:08:56 +08:00
薇薇安
fe420ad1a4 a 2026-01-23 17:36:31 +08:00
薇薇安
efbd149f1f a 2026-01-23 17:31:47 +08:00
薇薇安
9e70f90260 a 2026-01-23 17:27:41 +08:00
薇薇安
f1bc8413df a 2026-01-23 17:20:55 +08:00
薇薇安
0c489bfdee a 2026-01-23 14:59:57 +08:00
薇薇安
f798782a6d a 2026-01-23 14:36:16 +08:00
薇薇安
f9ce156e9a a 2026-01-23 14:30:50 +08:00
薇薇安
63f1ea05f0 a 2026-01-23 13:53:36 +08:00
薇薇安
6aeed50d83 a 2026-01-23 13:49:46 +08:00
薇薇安
7a72cfb30c a 2026-01-23 13:24:01 +08:00
薇薇安
cdbb660c1d a 2026-01-23 09:36:39 +08:00
薇薇安
84c4af5ff5 a 2026-01-23 09:21:14 +08:00
薇薇安
2ba8d69ee0 a 2026-01-23 09:08:35 +08:00
薇薇安
ae953d119e a 2026-01-22 23:40:53 +08:00
薇薇安
3154dd5518 a 2026-01-22 23:35:35 +08:00
薇薇安
fc128f98b4 a 2026-01-22 23:26:29 +08:00
薇薇安
7ec1ae32d7 a 2026-01-22 23:03:32 +08:00
薇薇安
ba818b480d a 2026-01-22 22:37:44 +08:00
薇薇安
972694e98f a 2026-01-22 22:36:25 +08:00
薇薇安
76bde06a4f a 2026-01-22 22:32:49 +08:00
薇薇安
a7e1e6ca0a a 2026-01-22 22:15:33 +08:00
薇薇安
651e736474 a 2026-01-22 22:13:36 +08:00
薇薇安
e7efc5e8fa a 2026-01-22 22:08:32 +08:00
薇薇安
c581174747 a 2026-01-22 22:03:25 +08:00
薇薇安
1c1580b344 a 2026-01-22 21:49:42 +08:00
薇薇安
d051be3f65 a 2026-01-22 21:30:21 +08:00
薇薇安
d6cdc2f055 a 2026-01-22 21:14:53 +08:00
薇薇安
971d4e3a39 a 2026-01-22 20:59:21 +08:00
薇薇安
c9120294c9 a 2026-01-22 20:54:00 +08:00
薇薇安
18eeac233a a 2026-01-22 20:31:10 +08:00
薇薇安
d914b64294 a 2026-01-22 20:30:02 +08:00
薇薇安
078576ddf8 a 2026-01-22 20:27:29 +08:00
薇薇安
3e9d24ebea a 2026-01-22 20:24:28 +08:00
薇薇安
43d54bad97 a 2026-01-22 20:09:53 +08:00
薇薇安
5d7166d404 a 2026-01-22 19:52:46 +08:00
薇薇安
14773e530a a 2026-01-22 19:38:12 +08:00
薇薇安
717f51b015 a 2026-01-22 19:36:02 +08:00
薇薇安
5503860a04 a 2026-01-22 19:35:46 +08:00
薇薇安
d354aec939 a 2026-01-22 19:32:57 +08:00
薇薇安
8afd282ca5 a 2026-01-22 19:31:56 +08:00
薇薇安
e5a281569c a 2026-01-22 19:30:57 +08:00
薇薇安
b1f4cbddac a 2026-01-22 18:53:32 +08:00
薇薇安
3baa00c851 a 2026-01-22 18:52:57 +08:00
薇薇安
06272b6922 a 2026-01-22 18:51:26 +08:00
薇薇安
244ef6f4ba a 2026-01-22 18:49:35 +08:00
薇薇安
3e1e3392b7 a 2026-01-22 17:36:32 +08:00
薇薇安
9ed4d4259a a 2026-01-22 17:11:21 +08:00
薇薇安
5717614f61 a 2026-01-22 13:26:01 +08:00
薇薇安
0ecdff4530 a 2026-01-22 13:05:42 +08:00
薇薇安
7d3d9c7de1 a 2026-01-22 11:50:32 +08:00
薇薇安
fac2651911 a 2026-01-22 11:44:03 +08:00
薇薇安
3ef66fb54a a 2026-01-22 11:41:32 +08:00
薇薇安
490e94f7c7 a 2026-01-22 11:39:47 +08:00
薇薇安
9a62528c93 a 2026-01-22 11:35:38 +08:00
薇薇安
66d68e319f a 2026-01-22 11:32:53 +08:00
薇薇安
3bac042273 a 2026-01-22 11:24:57 +08:00
薇薇安
28bce8f02b a 2026-01-22 09:28:58 +08:00
薇薇安
352d36e7a5 a 2026-01-22 09:11:20 +08:00
薇薇安
156acc92e0 a 2026-01-22 09:06:10 +08:00
薇薇安
dc49c2717b a 2026-01-22 08:50:42 +08:00
薇薇安
5b1370a5a2 a 2026-01-21 23:44:37 +08:00
薇薇安
87e7865cbb a 2026-01-21 22:48:01 +08:00
薇薇安
414607d566 a 2026-01-21 22:38:24 +08:00
薇薇安
2d17406799 a 2026-01-21 22:13:52 +08:00
薇薇安
5a00dc75d7 a 2026-01-21 22:02:25 +08:00
薇薇安
45a654f654 a 2026-01-21 21:45:10 +08:00
薇薇安
e80fd1059b a 2026-01-21 21:26:44 +08:00
薇薇安
d75293e037 a 2026-01-21 19:37:22 +08:00
薇薇安
fe855df566 a 2026-01-21 19:29:10 +08:00
薇薇安
18cfeaf5db a 2026-01-21 19:06:49 +08:00
薇薇安
fe60f12ee0 a 2026-01-21 17:40:25 +08:00
薇薇安
3b0ff0227e a 2026-01-21 17:15:58 +08:00
薇薇安
8d8bc409a6 a 2026-01-21 17:11:13 +08:00
薇薇安
d7813bbc80 a 2026-01-21 14:48:33 +08:00
薇薇安
ed5ea8b733 a 2026-01-21 14:43:58 +08:00
薇薇安
52e849a586 a 2026-01-21 14:36:01 +08:00
薇薇安
2d9adddb91 a 2026-01-21 14:17:21 +08:00
薇薇安
9bc73b63a3 a 2026-01-21 13:19:10 +08:00
薇薇安
c28400e51e a 2026-01-21 13:09:10 +08:00
薇薇安
11e6361235 a 2026-01-21 13:01:44 +08:00
薇薇安
4d26777845 a 2026-01-21 11:48:41 +08:00
薇薇安
6d48dc98d2 a 2026-01-21 11:04:44 +08:00
薇薇安
1fdcb9c8b7 a 2026-01-20 22:17:09 +08:00
薇薇安
ad63dbd234 a 2026-01-20 21:06:17 +08:00
薇薇安
7fb0ed39a7 a 2026-01-20 20:59:49 +08:00
薇薇安
4c20a7a488 a 2026-01-20 19:03:19 +08:00
薇薇安
8832b83ced a 2026-01-20 18:16:28 +08:00
薇薇安
746c8ac25b 增加多账号的支持体系 2026-01-20 15:55:34 +08:00
187 changed files with 32347 additions and 1500 deletions

14
.cursorrules Normal file
View File

@ -0,0 +1,14 @@
# 交易系统开发最高准则
## 1. 风险控制(核心)
- **止损高于一切**:严禁在任何平仓逻辑前添加时间限制。任何情况下,只要触发止损条件,必须立即执行平仓。
- **严禁恢复时间锁**:绝对不允许重新启用 `MIN_HOLD_TIME_SEC` 来限制止损或止盈。
- **异常处理**:所有涉及 `binance.create_order` 的操作必须包含 try-catch 逻辑,并有重试机制或错误预警。
## 2. 币安合约逻辑
- **挂单确认**:在开仓订单成交后,必须立即调用 `_ensure_exchange_sltp_orders` 在交易所侧挂好止损单。
- **价格类型**:区分 Mark Price标记价格和 Last Price最新价格止损逻辑应优先参考标记价格以防插针。
## 3. 代码风格
- 使用 Python 异步编程 (asyncio)。
- 所有的交易日志必须记录 Symbol、价格、原因和时间戳。

120
backend/api/auth_deps.py Normal file
View File

@ -0,0 +1,120 @@
"""
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__)
# 注意x_account_id 可能是 None需要处理
raw_header_value = x_account_id
aid = int(x_account_id or 1)
logger.info(f"get_account_id: X-Account-Id header={raw_header_value}, parsed account_id={aid}, user_id={user.get('id')}, username={user.get('username')}")
result = require_account_access(aid, user)
logger.info(f"get_account_id: 最终返回 account_id={result}")
return result
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
View 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

View File

@ -3,7 +3,7 @@ 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
import os
import logging
from pathlib import Path
@ -141,12 +141,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 +154,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 +165,45 @@ app = FastAPI(
redirect_slashes=False # 禁用自动重定向避免307重定向问题
)
# 启动时:确保存在一个初始管理员(通过环境变量配置)
@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=1default
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}")
# 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 +219,16 @@ 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(public.router)
@app.get("/")

View File

@ -1,7 +1,7 @@
"""
账户实时数据API - 从币安API获取实时账户和订单数据
"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Header, Depends
from fastapi import Query
import sys
from pathlib import Path
@ -13,23 +13,24 @@ sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / 'backend'))
sys.path.insert(0, str(project_root / 'trading_system'))
from database.models import TradingConfig
from database.models import TradingConfig, Account
from api.auth_deps import get_account_id
logger = logging.getLogger(__name__)
router = APIRouter()
async def _ensure_exchange_sltp_for_symbol(symbol: str):
async def _ensure_exchange_sltp_for_symbol(symbol: str, account_id: int = 1):
"""
在币安侧补挂该 symbol 的止损/止盈保护单STOP_MARKET + TAKE_PROFIT_MARKET
该接口用于手动补挂不依赖 trading_system 的监控任务
"""
# 从数据库读取API密钥
api_key = TradingConfig.get_value('BINANCE_API_KEY')
api_secret = TradingConfig.get_value('BINANCE_API_SECRET')
use_testnet = TradingConfig.get_value('USE_TESTNET', False)
if not api_key or not api_secret:
raise HTTPException(status_code=400, detail="API密钥未配置")
# 从 accounts 表读取账号私有API密钥
account_id_int = int(account_id or 1)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id_int)
if (not api_key or not api_secret) and status == "active":
logger.error(f"[account_id={account_id_int}] API密钥未配置")
raise HTTPException(status_code=400, detail=f"API密钥未配置account_id={account_id_int}")
# 导入交易系统的BinanceClient复用其精度/持仓模式处理)
try:
@ -262,12 +263,12 @@ async def _ensure_exchange_sltp_for_symbol(symbol: str):
@router.post("/positions/{symbol}/sltp/ensure")
async def ensure_position_sltp(symbol: str):
async def ensure_position_sltp(symbol: str, account_id: int = Depends(get_account_id)):
"""
手动补挂该 symbol 的止盈止损保护单币安侧可见
"""
try:
return await _ensure_exchange_sltp_for_symbol(symbol)
return await _ensure_exchange_sltp_for_symbol(symbol, account_id=int(account_id))
except HTTPException:
raise
except Exception as e:
@ -277,16 +278,18 @@ async def ensure_position_sltp(symbol: str):
@router.post("/positions/sltp/ensure-all")
async def ensure_all_positions_sltp(limit: int = Query(50, ge=1, le=200, description="最多处理多少个持仓symbol")):
async def ensure_all_positions_sltp(
limit: int = Query(50, ge=1, le=200, description="最多处理多少个持仓symbol"),
account_id: int = Depends(get_account_id),
):
"""
批量补挂当前所有持仓的止盈止损保护单
"""
# 先拿当前持仓symbol列表
api_key = TradingConfig.get_value('BINANCE_API_KEY')
api_secret = TradingConfig.get_value('BINANCE_API_SECRET')
use_testnet = TradingConfig.get_value('USE_TESTNET', False)
if not api_key or not api_secret:
raise HTTPException(status_code=400, detail="API密钥未配置")
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if (not api_key or not api_secret) and status == "active":
logger.error(f"[account_id={account_id}] API密钥未配置")
raise HTTPException(status_code=400, detail=f"API密钥未配置account_id={account_id}")
try:
from binance_client import BinanceClient
@ -308,7 +311,7 @@ async def ensure_all_positions_sltp(limit: int = Query(50, ge=1, le=200, descrip
errors = []
for sym in symbols:
try:
res = await _ensure_exchange_sltp_for_symbol(sym)
res = await _ensure_exchange_sltp_for_symbol(sym, account_id=account_id)
results.append(
{
"symbol": sym,
@ -339,37 +342,36 @@ async def ensure_all_positions_sltp(limit: int = Query(50, ge=1, le=200, descrip
}
async def get_realtime_account_data():
async def get_realtime_account_data(account_id: int = 1):
"""从币安API实时获取账户数据"""
logger.info("=" * 60)
logger.info("开始获取实时账户数据")
logger.info("=" * 60)
try:
# 从数据库读取API密钥
logger.info("步骤1: 从数据库读取API配置...")
api_key = TradingConfig.get_value('BINANCE_API_KEY')
api_secret = TradingConfig.get_value('BINANCE_API_SECRET')
use_testnet = TradingConfig.get_value('USE_TESTNET', False)
# 从 accounts 表读取账号私有API密钥
logger.info(f"步骤1: 从accounts读取API配置... (account_id={account_id})")
logger.info(f" - 请求的 account_id: {account_id}")
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
logger.info(f" - 获取到的 account_id 状态: {status}")
logger.info(f" - API密钥存在: {bool(api_key)}")
if api_key:
logger.info(f" - API密钥长度: {len(api_key)} 字符")
logger.info(f" - API密钥前缀: {api_key[:10]}...")
else:
logger.warning(" - API密钥为空!")
logger.info(f" - API密钥存在: {bool(api_secret)}")
logger.info(f" - API密钥Secret存在: {bool(api_secret)}")
if api_secret:
logger.info(f" - API密钥长度: {len(api_secret)} 字符")
logger.info(f" - API密钥前缀: {api_secret[:10]}...")
logger.info(f" - API密钥Secret长度: {len(api_secret)} 字符")
else:
logger.warning(" - API密钥为空!")
logger.warning(" - API密钥Secret为空!")
logger.info(f" - 使用测试网: {use_testnet}")
if not api_key or not api_secret:
error_msg = "API密钥未配置请在配置界面设置BINANCE_API_KEY和BINANCE_API_SECRET"
error_msg = f"API密钥未配置account_id={account_id}请在配置界面设置该账号的BINANCE_API_KEY和BINANCE_API_SECRET"
logger.error(f"[account_id={account_id}] API密钥未配置")
logger.error(f"{error_msg}")
raise HTTPException(
status_code=400,
@ -396,12 +398,25 @@ async def get_realtime_account_data():
# 创建客户端
logger.info("步骤3: 创建BinanceClient实例...")
logger.info(f" - 使用的 account_id: {account_id}")
logger.info(f" - API Key 前4位: {api_key[:4] if api_key and len(api_key) >= 4 else 'N/A'}...")
logger.info(f" - API Key 后4位: ...{api_key[-4:] if api_key and len(api_key) >= 4 else 'N/A'}")
logger.info(f" - API Secret 前4位: {api_secret[:4] if api_secret and len(api_secret) >= 4 else 'N/A'}...")
logger.info(f" - API Secret 后4位: ...{api_secret[-4:] if api_secret and len(api_secret) >= 4 else 'N/A'}")
logger.info(f" - testnet: {use_testnet}")
# 确保传递了正确的 api_key 和 api_secret避免 BinanceClient 从 config 读取
if not api_key or not api_secret:
error_msg = f"API密钥为空 (account_id={account_id})无法创建BinanceClient"
logger.error(f"{error_msg}")
raise HTTPException(status_code=400, detail=error_msg)
client = BinanceClient(
api_key=api_key,
api_secret=api_secret,
api_key=api_key, # 明确传递,避免从 config 读取
api_secret=api_secret, # 明确传递,避免从 config 读取
testnet=use_testnet
)
logger.info(f" ✓ 客户端创建成功 (testnet={use_testnet})")
logger.info(f" ✓ 客户端创建成功 (testnet={use_testnet}, account_id={account_id})")
# 连接币安API
logger.info("步骤4: 连接币安API...")
@ -519,9 +534,32 @@ async def get_realtime_account_data():
logger.warning(f" ⚠ 断开连接时出错: {e}")
# 构建返回结果
# 注意:币安合约账户的余额字段(根据官方文档):
# - walletBalance: 钱包余额(不包括未实现盈亏,只反映已实现的盈亏、转账、手续费等)
# - marginBalance: 保证金余额(钱包余额 + 未实现盈亏),这是账户的总权益,用户看到的"总余额"
# - availableBalance: 可用余额(可用于开仓的余额)
# 这里使用 marginBalance 作为 total_balance因为这才是用户看到的"总余额"(包括未实现盈亏)
wallet_balance = balance.get('walletBalance') if balance and 'walletBalance' in balance else balance.get('total', 0) if balance else 0
available_balance = balance.get('availableBalance') if balance and 'availableBalance' in balance else balance.get('available', 0) if balance else 0
margin_balance = balance.get('marginBalance') if balance and 'marginBalance' in balance else balance.get('margin', 0) if balance else 0
unrealized_profit = balance.get('unrealizedProfit', 0) if balance else 0
# 如果没有 marginBalance尝试从 total 字段获取(向后兼容)
if margin_balance == 0 and balance and 'total' in balance:
margin_balance = balance.get('total', 0)
logger.info(f"构建返回结果:")
logger.info(f" - wallet_balance (钱包余额,不包括未实现盈亏): {wallet_balance}")
logger.info(f" - margin_balance (保证金余额,总权益,包括未实现盈亏): {margin_balance}")
logger.info(f" - available_balance (可用余额): {available_balance}")
logger.info(f" - unrealized_profit (未实现盈亏): {unrealized_profit}")
result = {
"total_balance": balance.get('total', 0) if balance else 0,
"available_balance": balance.get('available', 0) if balance else 0,
"total_balance": margin_balance, # 使用保证金余额作为总余额(包括未实现盈亏),这是用户看到的"总余额"
"available_balance": available_balance,
"margin_balance": margin_balance, # 添加保证金余额字段
"wallet_balance": wallet_balance, # 添加钱包余额字段(不包括未实现盈亏)
"unrealized_profit": unrealized_profit, # 添加未实现盈亏字段
# 名义仓位(按标记价汇总)
"total_position_value": total_position_value,
# 保证金占用(名义/杠杆汇总)
@ -534,8 +572,8 @@ async def get_realtime_account_data():
}
logger.info("=" * 60)
logger.info("账户数据获取成功!")
logger.info(f"最终结果: {result}")
logger.info(f"账户数据获取成功! (account_id={account_id})")
logger.info(f"最终结果 - total_balance={result.get('total_balance', 'N/A')}, available_balance={result.get('available_balance', 'N/A')}, open_positions={result.get('open_positions', 'N/A')}")
logger.info("=" * 60)
return result
@ -555,26 +593,26 @@ async def get_realtime_account_data():
@router.get("/realtime")
async def get_realtime_account():
async def get_realtime_account(account_id: int = Depends(get_account_id)):
"""获取实时账户数据"""
return await get_realtime_account_data()
return await get_realtime_account_data(account_id=account_id)
@router.get("/positions")
async def get_realtime_positions():
async def get_realtime_positions(account_id: int = Depends(get_account_id)):
"""获取实时持仓数据"""
client = None
try:
# 从数据库读取API密钥
api_key = TradingConfig.get_value('BINANCE_API_KEY')
api_secret = TradingConfig.get_value('BINANCE_API_SECRET')
use_testnet = TradingConfig.get_value('USE_TESTNET', False)
logger.info(f"get_realtime_positions: 请求的 account_id={account_id}")
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
logger.info(f"get_realtime_positions: 获取到的 account_id={account_id}, status={status}, api_key exists={bool(api_key)}")
logger.info(f"get_realtime_positions: API Key 前4位={api_key[:4] if api_key and len(api_key) >= 4 else 'N/A'}, 后4位=...{api_key[-4:] if api_key and len(api_key) >= 4 else 'N/A'}")
logger.info(f"尝试获取实时持仓数据 (testnet={use_testnet})")
logger.info(f"尝试获取实时持仓数据 (testnet={use_testnet}, account_id={account_id})")
if not api_key or not api_secret:
error_msg = "API密钥未配置"
logger.warning(error_msg)
error_msg = f"API密钥未配置account_id={account_id}"
logger.warning(f"[account_id={account_id}] {error_msg}")
raise HTTPException(
status_code=400,
detail=error_msg
@ -588,11 +626,13 @@ async def get_realtime_positions():
sys.path.insert(0, str(trading_system_path))
from binance_client import BinanceClient
# 确保传递了正确的 api_key 和 api_secret避免 BinanceClient 从 config 读取
client = BinanceClient(
api_key=api_key,
api_secret=api_secret,
api_key=api_key, # 明确传递,避免从 config 读取
api_secret=api_secret, # 明确传递,避免从 config 读取
testnet=use_testnet
)
logger.info(f"BinanceClient 创建成功 (account_id={account_id})")
logger.info("连接币安API获取持仓...")
await client.connect()
@ -734,21 +774,18 @@ async def get_realtime_positions():
@router.post("/positions/{symbol}/close")
async def close_position(symbol: str):
async def close_position(symbol: str, account_id: int = Depends(get_account_id)):
"""手动平仓指定交易对的持仓"""
try:
logger.info(f"=" * 60)
logger.info(f"收到平仓请求: {symbol}")
logger.info(f"=" * 60)
# 从数据库读取API密钥
api_key = TradingConfig.get_value('BINANCE_API_KEY')
api_secret = TradingConfig.get_value('BINANCE_API_SECRET')
use_testnet = TradingConfig.get_value('USE_TESTNET', False)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if not api_key or not api_secret:
error_msg = "API密钥未配置"
logger.warning(error_msg)
if (not api_key or not api_secret) and status == "active":
error_msg = f"API密钥未配置account_id={account_id}"
logger.warning(f"[account_id={account_id}] {error_msg}")
raise HTTPException(status_code=400, detail=error_msg)
# 导入必要的模块
@ -981,7 +1018,7 @@ async def close_position(symbol: str):
fallback_exit_price = None
# 更新数据库记录
open_trades = Trade.get_by_symbol(symbol, status='open')
open_trades = Trade.get_by_symbol(symbol, status='open', account_id=account_id)
if open_trades:
# 对冲模式可能有多条 tradeBUY/LONG 和 SELL/SHORT尽量按方向匹配订单更新
used_order_ids = set()
@ -1047,22 +1084,201 @@ async def close_position(symbol: str):
raise HTTPException(status_code=500, detail=error_msg)
@router.post("/positions/sync")
async def sync_positions():
"""同步币安实际持仓状态与数据库状态"""
@router.post("/positions/close-all")
async def close_all_positions(account_id: int = Depends(get_account_id)):
"""一键全平:平仓所有持仓"""
try:
logger.info("=" * 60)
logger.info("收到持仓状态同步请求")
logger.info("收到一键全平请求")
logger.info("=" * 60)
# 从数据库读取API密钥
api_key = TradingConfig.get_value('BINANCE_API_KEY')
api_secret = TradingConfig.get_value('BINANCE_API_SECRET')
use_testnet = TradingConfig.get_value('USE_TESTNET', False)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if not api_key or not api_secret:
error_msg = "API密钥未配置"
logger.warning(error_msg)
if (not api_key or not api_secret) and status == "active":
error_msg = f"API密钥未配置account_id={account_id}"
logger.warning(f"[account_id={account_id}] {error_msg}")
raise HTTPException(status_code=400, detail=error_msg)
# 导入必要的模块
try:
from binance_client import BinanceClient
logger.info("✓ 成功导入交易系统模块")
except ImportError as import_error:
logger.warning(f"首次导入失败: {import_error}尝试从trading_system路径导入")
trading_system_path = project_root / 'trading_system'
sys.path.insert(0, str(trading_system_path))
from binance_client import BinanceClient
logger.info("✓ 从trading_system路径导入成功")
# 导入数据库模型
from database.models import Trade
# 创建客户端
logger.info(f"创建BinanceClient (testnet={use_testnet})...")
client = BinanceClient(
api_key=api_key,
api_secret=api_secret,
testnet=use_testnet
)
logger.info("连接币安API...")
await client.connect()
logger.info("✓ 币安API连接成功")
try:
# 获取所有持仓
positions = await client.get_open_positions()
if not positions:
logger.info("当前没有持仓")
return {
"message": "当前没有持仓",
"closed": 0,
"failed": 0,
"results": []
}
logger.info(f"发现 {len(positions)} 个持仓,开始逐一平仓...")
results = []
closed_count = 0
failed_count = 0
for position in positions:
symbol = position.get('symbol')
position_amt = float(position.get('positionAmt', 0))
if abs(position_amt) <= 0:
continue
try:
logger.info(f"开始平仓 {symbol} (数量: {position_amt})...")
# 确定平仓方向
side = 'SELL' if position_amt > 0 else 'BUY'
# 使用市价单平仓
order = await client.place_order(
symbol=symbol,
side=side,
order_type='MARKET',
quantity=abs(position_amt),
reduce_only=True
)
if order and order.get('orderId'):
logger.info(f"{symbol} 平仓订单已提交: {order.get('orderId')}")
# 获取成交价格
exit_price = float(order.get('avgPrice', 0)) or float(order.get('price', 0))
if not exit_price:
# 如果订单中没有价格,获取当前价格
ticker = await client.get_ticker_24h(symbol)
exit_price = float(ticker['price']) if ticker else 0
# 更新数据库记录
open_trades = Trade.get_by_symbol(symbol, status='open')
for trade in open_trades:
entry_price = float(trade['entry_price'])
quantity = float(trade['quantity'])
if trade['side'] == 'BUY':
pnl = (exit_price - entry_price) * quantity
pnl_percent = ((exit_price - entry_price) / entry_price) * 100
else:
pnl = (entry_price - exit_price) * quantity
pnl_percent = ((entry_price - exit_price) / entry_price) * 100
Trade.update_exit(
trade_id=trade['id'],
exit_price=exit_price,
exit_reason='manual',
pnl=pnl,
pnl_percent=pnl_percent,
exit_order_id=order.get('orderId')
)
logger.info(f"✓ 已更新数据库记录 trade_id={trade['id']} (盈亏: {pnl:.2f} USDT)")
closed_count += 1
results.append({
"symbol": symbol,
"status": "success",
"order_id": order.get('orderId'),
"message": f"{symbol} 平仓成功"
})
else:
logger.warning(f"{symbol} 平仓订单提交失败")
failed_count += 1
results.append({
"symbol": symbol,
"status": "failed",
"message": f"{symbol} 平仓失败: 订单未提交"
})
except Exception as e:
logger.error(f"{symbol} 平仓失败: {e}")
failed_count += 1
results.append({
"symbol": symbol,
"status": "failed",
"message": f"{symbol} 平仓失败: {str(e)}"
})
logger.info(f"一键全平完成: 成功 {closed_count} / 失败 {failed_count}")
return {
"message": f"一键全平完成: 成功 {closed_count} / 失败 {failed_count}",
"closed": closed_count,
"failed": failed_count,
"results": results
}
finally:
logger.info("断开币安API连接...")
await client.disconnect()
logger.info("✓ 已断开连接")
except HTTPException:
raise
except Exception as e:
error_msg = f"一键全平失败: {str(e)}"
logger.error("=" * 60)
logger.error(f"一键全平操作异常: {error_msg}")
logger.error(f"错误类型: {type(e).__name__}")
logger.error("=" * 60, exc_info=True)
raise HTTPException(status_code=500, detail=error_msg)
@router.post("/positions/{symbol}/open")
async def open_position_from_recommendation(
symbol: str,
entry_price: float = Query(..., description="入场价格"),
stop_loss_price: float = Query(..., description="止损价格"),
direction: str = Query(..., description="交易方向: BUY 或 SELL"),
notional_usdt: float = Query(..., description="下单名义价值USDT"),
leverage: int = Query(10, description="杠杆倍数"),
account_id: int = Depends(get_account_id)
):
"""根据推荐信息手动开仓"""
try:
logger.info("=" * 60)
logger.info(f"收到手动开仓请求: {symbol}")
logger.info(f" 入场价: {entry_price}, 止损价: {stop_loss_price}")
logger.info(f" 方向: {direction}, 名义价值: {notional_usdt} USDT, 杠杆: {leverage}x")
logger.info("=" * 60)
if direction not in ('BUY', 'SELL'):
raise HTTPException(status_code=400, detail="交易方向必须是 BUY 或 SELL")
if notional_usdt <= 0:
raise HTTPException(status_code=400, detail="下单名义价值必须大于0")
if entry_price <= 0 or stop_loss_price <= 0:
raise HTTPException(status_code=400, detail="入场价和止损价必须大于0")
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if (not api_key or not api_secret) and status == "active":
error_msg = f"API密钥未配置account_id={account_id}"
logger.warning(f"[account_id={account_id}] {error_msg}")
raise HTTPException(status_code=400, detail=error_msg)
# 导入必要的模块
@ -1077,12 +1293,187 @@ async def sync_positions():
from database.models import Trade
# 创建客户端
logger.info(f"创建BinanceClient (testnet={use_testnet})...")
client = BinanceClient(
api_key=api_key,
api_secret=api_secret,
testnet=use_testnet
)
logger.info("连接币安API...")
await client.connect()
logger.info("✓ 币安API连接成功")
try:
# 设置杠杆
await client.set_leverage(symbol, leverage)
logger.info(f"✓ 已设置杠杆: {leverage}x")
# 获取交易对信息
symbol_info = await client.get_symbol_info(symbol)
if not symbol_info:
raise HTTPException(status_code=400, detail=f"无法获取 {symbol} 的交易对信息")
# 计算下单数量:数量 = 名义价值 / 入场价
quantity = notional_usdt / entry_price
logger.info(f"计算下单数量: {quantity:.8f} (名义价值: {notional_usdt} USDT / 入场价: {entry_price})")
# 调整数量精度
adjusted_quantity = client._adjust_quantity_precision(quantity, symbol_info)
if adjusted_quantity <= 0:
raise HTTPException(status_code=400, detail=f"调整后的数量无效: {adjusted_quantity}")
logger.info(f"调整后的数量: {adjusted_quantity:.8f}")
# 检查最小名义价值
min_notional = symbol_info.get('minNotional', 5.0)
actual_notional = adjusted_quantity * entry_price
if actual_notional < min_notional:
raise HTTPException(
status_code=400,
detail=f"订单名义价值不足: {actual_notional:.2f} USDT < 最小要求: {min_notional:.2f} USDT"
)
# 下 limit 订单
logger.info(f"开始下 limit 订单: {symbol} {direction} {adjusted_quantity} @ {entry_price}")
order = await client.place_order(
symbol=symbol,
side=direction,
quantity=adjusted_quantity,
order_type='LIMIT',
price=entry_price,
reduce_only=False
)
if not order:
raise HTTPException(status_code=500, detail="下单失败币安API返回None")
order_id = order.get('orderId')
logger.info(f"✓ 订单已提交: orderId={order_id}")
# 等待订单成交最多等待30秒
import asyncio
filled_order = None
for i in range(30):
await asyncio.sleep(1)
try:
order_status = await client.client.futures_get_order(symbol=symbol, orderId=order_id)
if order_status.get('status') == 'FILLED':
filled_order = order_status
logger.info(f"✓ 订单已成交: orderId={order_id}")
break
elif order_status.get('status') in ('CANCELED', 'EXPIRED', 'REJECTED'):
raise HTTPException(status_code=400, detail=f"订单未成交,状态: {order_status.get('status')}")
except Exception as e:
if i == 29: # 最后一次尝试
logger.warning(f"订单状态查询失败或未成交: {e}")
continue
if not filled_order:
logger.warning(f"订单 {order_id} 在30秒内未成交但订单已提交")
return {
"message": f"{symbol} 订单已提交但未成交(请稍后检查)",
"symbol": symbol,
"order_id": order_id,
"status": "pending"
}
# 订单已成交,保存到数据库
avg_price = float(filled_order.get('avgPrice', entry_price))
executed_qty = float(filled_order.get('executedQty', adjusted_quantity))
# 计算实际使用的名义价值和保证金
actual_notional = executed_qty * avg_price
actual_margin = actual_notional / leverage
# 保存交易记录
# trade_id = Trade.create(
# account_id=account_id,
# symbol=symbol,
# side=direction,
# quantity=executed_qty,
# entry_price=avg_price,
# leverage=leverage,
# entry_order_id=order_id,
# entry_reason='manual_from_recommendation',
# notional_usdt=actual_notional,
# margin_usdt=actual_margin,
# stop_loss_price=stop_loss_price,
# # 如果有推荐中的止盈价,也可以传入,这里先不传
# )
# logger.info(f"✓ 交易记录已保存: trade_id={trade_id}")
# 尝试挂止损/止盈保护单(如果系统支持)
# try:
# # 这里可以调用 _ensure_exchange_sltp_for_symbol 来挂保护单
# # 但需要先获取持仓信息来确定方向
# positions = await client.get_open_positions()
# position = next((p for p in positions if p['symbol'] == symbol), None)
# if position:
# # 可以在这里挂止损单,但需要知道 take_profit_price
# # 暂时只记录止损价到数据库,由系统自动监控
# logger.info(f"止损价已记录到数据库: {stop_loss_price}")
# except Exception as e:
# logger.warning(f"挂保护单失败(不影响开仓): {e}")
return {
"message": f"{symbol} 开仓成功",
"symbol": symbol,
"order_id": order_id,
"trade_id": None,
"quantity": executed_qty,
"entry_price": avg_price,
"notional_usdt": actual_notional,
"margin_usdt": actual_margin,
"status": "filled"
}
finally:
logger.info("断开币安API连接...")
await client.disconnect()
logger.info("✓ 已断开连接")
except HTTPException:
raise
except Exception as e:
error_msg = f"开仓失败: {str(e)}"
logger.error("=" * 60)
logger.error(f"开仓操作异常: {error_msg}")
logger.error(f"错误类型: {type(e).__name__}")
logger.error("=" * 60, exc_info=True)
raise HTTPException(status_code=500, detail=error_msg)
@router.post("/positions/sync")
async def sync_positions(account_id: int = Depends(get_account_id)):
"""同步币安实际持仓状态与数据库状态"""
try:
logger.info("=" * 60)
logger.info("收到持仓状态同步请求")
logger.info("=" * 60)
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if (not api_key or not api_secret) and status == "active":
error_msg = f"API密钥未配置account_id={account_id}"
logger.warning(f"[account_id={account_id}] {error_msg}")
raise HTTPException(status_code=400, detail=error_msg)
# 导入必要的模块
try:
from binance_client import BinanceClient
except ImportError:
trading_system_path = project_root / 'trading_system'
sys.path.insert(0, str(trading_system_path))
from binance_client import BinanceClient
# 导入数据库模型
from database.models import Trade
# 创建客户端
client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet)
logger.info("连接币安API...")
await client.connect()
@ -1095,7 +1486,7 @@ async def sync_positions():
logger.info(f" 持仓列表: {', '.join(binance_symbols)}")
# 2. 获取数据库中状态为open的交易记录
db_open_trades = Trade.get_all(status='open')
db_open_trades = Trade.get_all(status='open', account_id=account_id)
db_open_symbols = {t['symbol'] for t in db_open_trades}
logger.info(f"数据库open状态: {len(db_open_symbols)}")
if db_open_symbols:
@ -1166,6 +1557,9 @@ async def sync_positions():
except Exception:
exit_time_ts = None
# 检查订单的 reduceOnly 字段:如果是 true说明是自动平仓不应该标记为 manual
is_reduce_only = latest_close_order.get("reduceOnly", False) if latest_close_order else False
if "TRAILING" in otype:
exit_reason = "trailing_stop"
elif "TAKE_PROFIT" in otype:
@ -1173,7 +1567,44 @@ async def sync_positions():
elif "STOP" in otype:
exit_reason = "stop_loss"
elif otype in ("MARKET", "LIMIT"):
exit_reason = "manual"
# 如果是 reduceOnly 订单,说明是自动平仓(可能是保护单触发的),先标记为 sync后续用价格判断
if is_reduce_only:
exit_reason = "sync" # 临时标记,后续用价格判断
else:
exit_reason = "manual" # 非 reduceOnly 的 MARKET/LIMIT 订单才是真正的手动平仓
# 价格兜底:如果能明显命中止损/止盈价,则覆盖 exit_reason
# 这对于保护单触发的 MARKET 订单特别重要
if exit_reason == "sync" or exit_reason == "manual":
try:
def _close_to(a: float, b: float, max_pct: float = 0.02) -> bool:
if a <= 0 or b <= 0:
return False
return abs((a - b) / b) <= max_pct
ep = float(exit_price or 0)
if ep > 0:
sl = trade.get("stop_loss_price")
tp = trade.get("take_profit_price")
tp1 = trade.get("take_profit_1")
tp2 = trade.get("take_profit_2")
# 优先检查止损
if sl is not None and _close_to(ep, float(sl), max_pct=0.02):
exit_reason = "stop_loss"
# 然后检查止盈
elif tp is not None and _close_to(ep, float(tp), max_pct=0.02):
exit_reason = "take_profit"
elif tp1 is not None and _close_to(ep, float(tp1), max_pct=0.02):
exit_reason = "take_profit"
elif tp2 is not None and _close_to(ep, float(tp2), max_pct=0.02):
exit_reason = "take_profit"
# 如果价格接近入场价,可能是移动止损触发的
elif is_reduce_only:
entry_price_val = float(trade.get("entry_price", 0) or 0)
if entry_price_val > 0 and _close_to(ep, entry_price_val, max_pct=0.01):
exit_reason = "trailing_stop"
except Exception:
pass
if not exit_price or exit_price <= 0:
ticker = await client.get_ticker_24h(symbol)

View File

@ -0,0 +1,433 @@
"""
账号管理 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
def _mask(s: str) -> str:
s = "" if s is None else str(s)
if not s:
return ""
if len(s) <= 8:
return "****"
return f"{s[:4]}...{s[-4:]}"
def _ensure_account_active_for_start(account_id: int):
row = Account.get(int(account_id))
if not row:
raise HTTPException(status_code=404, detail="账号不存在")
status = (row.get("status") or "active").strip().lower()
if status != "active":
raise HTTPException(status_code=400, detail="账号已禁用,不能启动/重启交易进程")
@router.get("")
@router.get("/")
async def list_accounts(user: Dict[str, Any] = Depends(get_current_user)) -> List[Dict[str, Any]]:
try:
is_admin = (user.get("role") or "user") == "admin"
out: List[Dict[str, Any]] = []
if is_admin:
rows = Account.list_all()
for r in rows or []:
aid = int(r.get("id"))
api_key, api_secret, use_testnet = Account.get_credentials(aid)
out.append(
{
"id": aid,
"name": r.get("name") or "",
"status": r.get("status") or "active",
"use_testnet": bool(use_testnet),
"has_api_key": bool(api_key),
"has_api_secret": bool(api_secret),
"api_key_masked": _mask(api_key),
}
)
return out
memberships = UserAccountMembership.list_for_user(int(user["id"]))
membership_map = {int(m.get("account_id")): (m.get("role") or "viewer") for m in (memberships or []) if m.get("account_id") is not None}
account_ids = list(membership_map.keys())
for aid in account_ids:
r = Account.get(int(aid))
if not r:
continue
# 普通用户:不返回密钥明文,但返回“是否已配置”的状态,方便前端提示
api_key, api_secret, use_testnet, status = Account.get_credentials(int(aid))
out.append(
{
"id": int(aid),
"name": r.get("name") or "",
"status": status or r.get("status") or "active",
"use_testnet": bool(use_testnet),
"role": membership_map.get(int(aid), "viewer"),
"has_api_key": bool(api_key),
"has_api_secret": bool(api_secret),
}
)
return out
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取账号列表失败: {e}")
@router.post("")
@router.post("/")
async def create_account(payload: AccountCreate, _admin: Dict[str, Any] = Depends(get_admin_user)):
try:
aid = Account.create(
name=payload.name,
api_key=payload.api_key or "",
api_secret=payload.api_secret or "",
use_testnet=bool(payload.use_testnet),
status=payload.status,
)
# 自动为该账号生成 supervisor program 配置(失败不影响账号创建)
sup = ensure_account_program(int(aid))
return {
"success": True,
"id": int(aid),
"message": "账号已创建",
"supervisor": {
"ok": bool(sup.ok),
"program": sup.program,
"program_dir": sup.program_dir,
"ini_path": sup.ini_path,
"supervisor_conf": sup.supervisor_conf,
"reread": sup.reread,
"update": sup.update,
"error": sup.error,
"note": "如需自动启停:请确保 backend 进程有写入 program_dir 权限,并允许执行 supervisorctl可选 sudo -n",
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建账号失败: {e}")
@router.put("/{account_id}")
async def update_account(account_id: int, payload: AccountUpdate, _admin: Dict[str, Any] = Depends(get_admin_user)):
try:
row = Account.get(int(account_id))
if not row:
raise HTTPException(status_code=404, detail="账号不存在")
# name/status
fields = []
params = []
if payload.name is not None:
fields.append("name = %s")
params.append(payload.name)
if payload.status is not None:
fields.append("status = %s")
params.append(payload.status)
if payload.use_testnet is not None:
fields.append("use_testnet = %s")
params.append(bool(payload.use_testnet))
if fields:
params.append(int(account_id))
from database.connection import db
db.execute_update(f"UPDATE accounts SET {', '.join(fields)} WHERE id = %s", tuple(params))
return {"success": True, "message": "账号已更新"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"更新账号失败: {e}")
@router.put("/{account_id}/credentials")
async def update_credentials(account_id: int, payload: AccountCredentialsUpdate, user: Dict[str, Any] = Depends(get_current_user)):
try:
if (user.get("role") or "user") != "admin":
require_account_owner(int(account_id), user)
row = Account.get(int(account_id))
if not row:
raise HTTPException(status_code=404, detail="账号不存在")
Account.update_credentials(
int(account_id),
api_key=payload.api_key,
api_secret=payload.api_secret,
use_testnet=payload.use_testnet,
)
return {"success": True, "message": "账号密钥已更新(建议重启该账号交易进程)"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"更新账号密钥失败: {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")
# 允许管理员或该账号 owner 执行owner 用于“我重建配置再启动”)
if (user.get("role") or "user") != "admin":
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,
}
@router.get("/{account_id}/trading/status")
async def trading_status_for_account(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_access(int(account_id), user)
program = program_name_for_account(int(account_id))
try:
raw = run_supervisorctl(["status", program])
running, pid, state = parse_supervisor_status(raw)
# 仅 owner/admin 可看 tail便于自助排障
stderr_tail = ""
stdout_tail = ""
stderr_tail_error = ""
supervisord_tail = ""
logfile_tail: Dict[str, Any] = {}
try:
is_admin = (user.get("role") or "user") == "admin"
role = UserAccountMembership.get_role(int(user["id"]), int(account_id)) if not is_admin else "admin"
if is_admin or role == "owner":
if state in {"FATAL", "EXITED", "BACKOFF"}:
stderr_tail = tail_supervisor(program, "stderr", 120)
stdout_tail = tail_supervisor(program, "stdout", 200)
try:
logfile_tail = tail_trading_log_files(int(account_id), lines=200)
except Exception:
logfile_tail = {}
if not stderr_tail:
try:
supervisord_tail = tail_supervisord_log(80)
except Exception:
supervisord_tail = ""
except Exception as te:
stderr_tail_error = str(te)
stderr_tail = ""
stdout_tail = ""
# spawn error 时 program stderr 可能为空,尝试给 supervisord 主日志做兜底
try:
supervisord_tail = tail_supervisord_log(80)
except Exception:
supervisord_tail = ""
resp = {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}
if stderr_tail:
resp["stderr_tail"] = stderr_tail
if stdout_tail:
resp["stdout_tail"] = stdout_tail
if logfile_tail:
resp["logfile_tail"] = logfile_tail
if stderr_tail_error:
resp["stderr_tail_error"] = stderr_tail_error
if supervisord_tail:
resp["supervisord_tail"] = supervisord_tail
return resp
except Exception as e:
raise HTTPException(status_code=500, detail=f"读取交易进程状态失败: {e}")
@router.get("/{account_id}/trading/tail")
async def trading_tail_for_account(
account_id: int,
stream: str = "stderr",
lines: int = 200,
user: Dict[str, Any] = Depends(get_current_user),
):
"""
读取该账号交易进程日志尾部用于排障 owner/admin 可读
"""
if int(account_id) <= 0:
raise HTTPException(status_code=400, detail="account_id 必须 >= 1")
require_account_owner(int(account_id), user)
program = program_name_for_account(int(account_id))
try:
out = tail_supervisor(program, stream=stream, lines=lines)
return {"program": program, "stream": stream, "lines": lines, "tail": out}
except Exception as e:
raise HTTPException(status_code=500, detail=f"读取交易进程日志失败: {e}")
@router.post("/{account_id}/trading/start")
async def trading_start_for_account(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)
_ensure_account_active_for_start(int(account_id))
program = program_name_for_account(int(account_id))
try:
out = run_supervisorctl(["start", program])
raw = run_supervisorctl(["status", program])
running, pid, state = parse_supervisor_status(raw)
return {"message": "已启动", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}}
except Exception as e:
tail = ""
out_tail = ""
tail_err = ""
status_raw = ""
supervisord_tail = ""
logfile_tail: Dict[str, Any] = {}
try:
tail = tail_supervisor(program, "stderr", 120)
except Exception as te:
tail_err = str(te)
tail = ""
try:
out_tail = tail_supervisor(program, "stdout", 200)
except Exception:
out_tail = ""
try:
logfile_tail = tail_trading_log_files(int(account_id), lines=200)
except Exception:
logfile_tail = {}
try:
status_raw = run_supervisorctl(["status", program])
except Exception:
status_raw = ""
if not tail:
try:
supervisord_tail = tail_supervisord_log(120)
except Exception:
supervisord_tail = ""
raise HTTPException(
status_code=500,
detail={
"error": f"启动交易进程失败: {e}",
"program": program,
"hint": "如果是 spawn error重点看 stderr_tail常见python 路径不可执行/依赖缺失/权限/工作目录不存在)",
"stderr_tail": tail,
"stdout_tail": out_tail,
"logfile_tail": logfile_tail,
"stderr_tail_error": tail_err,
"status_raw": status_raw,
"supervisord_tail": supervisord_tail,
},
)
@router.post("/{account_id}/trading/stop")
async def trading_stop_for_account(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)
program = program_name_for_account(int(account_id))
try:
out = run_supervisorctl(["stop", program])
raw = run_supervisorctl(["status", program])
running, pid, state = parse_supervisor_status(raw)
return {"message": "已停止", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}}
except Exception as e:
raise HTTPException(status_code=500, detail=f"停止交易进程失败: {e}")
@router.post("/{account_id}/trading/restart")
async def trading_restart_for_account(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)
_ensure_account_active_for_start(int(account_id))
program = program_name_for_account(int(account_id))
try:
out = run_supervisorctl(["restart", program])
raw = run_supervisorctl(["status", program])
running, pid, state = parse_supervisor_status(raw)
return {"message": "已重启", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}}
except Exception as e:
tail = ""
out_tail = ""
tail_err = ""
status_raw = ""
supervisord_tail = ""
logfile_tail: Dict[str, Any] = {}
try:
tail = tail_supervisor(program, "stderr", 120)
except Exception as te:
tail_err = str(te)
tail = ""
try:
out_tail = tail_supervisor(program, "stdout", 200)
except Exception:
out_tail = ""
try:
logfile_tail = tail_trading_log_files(int(account_id), lines=200)
except Exception:
logfile_tail = {}
try:
status_raw = run_supervisorctl(["status", program])
except Exception:
status_raw = ""
if not tail:
try:
supervisord_tail = tail_supervisord_log(120)
except Exception:
supervisord_tail = ""
raise HTTPException(
status_code=500,
detail={
"error": f"重启交易进程失败: {e}",
"program": program,
"hint": "如果是 spawn error重点看 stderr_tail常见python 路径不可执行/依赖缺失/权限/工作目录不存在)",
"stderr_tail": tail,
"stdout_tail": out_tail,
"logfile_tail": logfile_tail,
"stderr_tail_error": tail_err,
"status_raw": status_raw,
"supervisord_tail": supervisord_tail,
},
)

125
backend/api/routes/admin.py Normal file
View File

@ -0,0 +1,125 @@
"""
管理员接口用户管理 / 授权管理
"""
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.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="账号不存在")
UserAccountMembership.add(int(user_id), int(account_id), role=payload.role)
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}

View File

@ -0,0 +1,71 @@
"""
登录鉴权 APIJWT
"""
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",
}

View File

@ -1,11 +1,12 @@
"""
配置管理API
"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Header, Depends
from api.models.config import ConfigItem, ConfigUpdate
import sys
from pathlib import Path
import logging
from typing import Dict, Any
# 添加项目根目录到路径
project_root = Path(__file__).parent.parent.parent.parent
@ -13,11 +14,63 @@ sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / 'backend'))
sys.path.insert(0, str(project_root / 'trading_system'))
from database.models import TradingConfig
from database.models import TradingConfig, Account
from api.auth_deps import get_current_user, get_account_id, require_admin, require_account_owner
logger = logging.getLogger(__name__)
router = APIRouter()
# 全局策略账号(管理员统一维护策略核心)。默认 1可用环境变量覆盖。
def _global_strategy_account_id() -> int:
try:
return int((__import__("os").getenv("ATS_GLOBAL_STRATEGY_ACCOUNT_ID") or "1").strip() or "1")
except Exception:
return 1
# 产品模式:平台兜底(策略核心由管理员统一控制),普通用户仅能调“风险旋钮”
# - admin可修改所有配置
# - 非 adminaccount owner只允许修改少量风险类配置 + 账号私有密钥/测试网
USER_RISK_KNOBS = {
# 风险暴露(保证金占用比例/最小保证金)
"MIN_MARGIN_USDT",
"MIN_POSITION_PERCENT",
"MAX_POSITION_PERCENT",
"MAX_TOTAL_POSITION_PERCENT",
# 行为控制(傻瓜化)
"AUTO_TRADE_ENABLED", # 总开关:关闭则只生成推荐不自动下单
"MAX_OPEN_POSITIONS", # 同时持仓数量上限
"MAX_DAILY_ENTRIES", # 每日最多开仓次数
}
RISK_KNOBS_DEFAULTS = {
"AUTO_TRADE_ENABLED": {
"value": True,
"type": "boolean",
"category": "risk",
"description": "自动交易总开关:关闭后仅生成推荐,不会自动下单(适合先观察/体验)。",
},
"MAX_OPEN_POSITIONS": {
"value": 3,
"type": "number",
"category": "risk",
"description": "同时持仓数量上限(防止仓位过多/难管理)。建议 1-5。",
},
"MAX_DAILY_ENTRIES": {
"value": 8,
"type": "number",
"category": "risk",
"description": "每日最多开仓次数(防止高频下单/过度交易)。建议 3-15。",
},
}
# API key/secret 脱敏
def _mask(s: str) -> str:
s = "" if s is None else str(s)
if not s:
return ""
if len(s) <= 8:
return "****"
return f"{s[:4]}...{s[-4:]}"
# 智能入场方案C配置为了“配置页可见”即使数据库尚未创建也在 GET /api/config 返回默认项
SMART_ENTRY_CONFIG_DEFAULTS = {
"SMART_ENTRY_ENABLED": {
@ -98,13 +151,61 @@ AUTO_TRADE_FILTER_DEFAULTS = {
},
}
# 风险/策略预设(用于一键切换“稳健 / 快速验证”等模式)
PROFILE_CONFIG_DEFAULTS = {
"TRADING_PROFILE": {
"value": "conservative",
"type": "string",
"category": "strategy",
"description": "交易预设conservative(稳健,低频+高门槛) / fast(快速验证,高频+宽松过滤)。仅作为默认值,具体参数仍可单独调整。",
},
}
# 核心策略参数(仅管理员可见/在全局策略账号中修改)
CORE_STRATEGY_CONFIG_DEFAULTS = {
"ATR_STOP_LOSS_MULTIPLIER": {
"value": 2.0, # 2026-01-29优化从1.5提高到2.0,减少被正常波动扫出
"type": "number",
"category": "strategy",
"description": "ATR止损倍数。建议 2.0-3.0。放宽止损可以给波动留出空间提高胜率。2026-01-29优化默认值从1.5提高到2.0。",
},
"ATR_TAKE_PROFIT_MULTIPLIER": {
"value": 1.5,
"type": "number",
"category": "strategy",
"description": "ATR止盈倍数。建议 1.0-2.0。对应盈亏比 1:1 到 2:1更容易触及目标。",
},
"RISK_REWARD_RATIO": {
"value": 1.5,
"type": "number",
"category": "strategy",
"description": "目标盈亏比止损距离的倍数。配合ATR止盈使用。",
},
"MIN_STOP_LOSS_PRICE_PCT": {
"value": 0.025, # 2026-01-29优化从0.02提高到0.025,给波动更多空间
"type": "number",
"category": "strategy",
"description": "最小止损距离(%)。例如 0.025 表示 2.5%。防止止损过紧。2026-01-29优化默认值从2%提高到2.5%",
},
"MIN_TAKE_PROFIT_PRICE_PCT": {
"value": 0.02,
"type": "number",
"category": "strategy",
"description": "最小止盈距离(%)。例如 0.02 表示 2%。防止止盈过近。",
},
}
@router.get("")
@router.get("/")
async def get_all_configs():
async def get_all_configs(
user: Dict[str, Any] = Depends(get_current_user),
account_id: int = Depends(get_account_id),
):
"""获取所有配置"""
try:
configs = TradingConfig.get_all()
configs = TradingConfig.get_all(account_id=account_id)
result = {}
for config in configs:
result[config['config_key']] = {
@ -117,6 +218,31 @@ async def get_all_configs():
'description': config['description']
}
# 合并账号级 API Key/Secret从 accounts 表读,避免把密钥当普通配置存)
try:
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
except Exception:
api_key, api_secret, use_testnet, status = "", "", False, "active"
# 仅用于配置页展示/更新:不返回 secret 明文api_key 仅脱敏展示
result["BINANCE_API_KEY"] = {
"value": _mask(api_key or ""),
"type": "string",
"category": "api",
"description": "币安API密钥账号私有仅脱敏展示账号 owner/admin 可修改)",
}
result["BINANCE_API_SECRET"] = {
"value": "",
"type": "string",
"category": "api",
"description": "币安API密钥Secret账号私有不回传明文账号 owner/admin 可修改)",
}
result["USE_TESTNET"] = {
"value": bool(use_testnet),
"type": "boolean",
"category": "api",
"description": "是否使用测试网(账号私有)",
}
# 合并“默认但未入库”的配置项(用于新功能上线时直接在配置页可见)
for k, meta in SMART_ENTRY_CONFIG_DEFAULTS.items():
if k not in result:
@ -125,13 +251,265 @@ async def get_all_configs():
for k, meta in AUTO_TRADE_FILTER_DEFAULTS.items():
if k not in result:
result[k] = meta
# 交易预设profile用于前端一键切换“稳健 / 快速验证”
for k, meta in PROFILE_CONFIG_DEFAULTS.items():
if k not in result:
result[k] = meta
for k, meta in RISK_KNOBS_DEFAULTS.items():
if k not in result:
result[k] = meta
# 固定风险百分比配置(策略核心,仅管理员可见)
FIXED_RISK_CONFIG_DEFAULTS = {
"USE_FIXED_RISK_SIZING": {
"value": True,
"type": "boolean",
"category": "risk",
"description": "使用固定风险百分比计算仓位(凯利公式)。启用后,每笔单子承受的风险固定为 FIXED_RISK_PERCENT避免大额亏损。",
},
"FIXED_RISK_PERCENT": {
"value": 0.02, # 2%
"type": "number",
"category": "risk",
"description": "每笔单子承受的风险百分比(相对于总资金)。例如 0.02 表示 2%。启用固定风险后,每笔亏损限制在该百分比内。",
},
}
for k, meta in FIXED_RISK_CONFIG_DEFAULTS.items():
if k not in result:
result[k] = meta
for k, meta in CORE_STRATEGY_CONFIG_DEFAULTS.items():
if k not in result:
result[k] = meta
# 普通用户:只展示风险旋钮 + 账号密钥(尽量傻瓜化,避免改坏策略)
# 管理员:若当前不是“全局策略账号”,同样只展示风险旋钮,避免误以为这里改策略能生效
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}
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ⚠️ 重要:全局配置路由必须在 /{key} 之前,否则会被动态路由匹配
@router.get("/global")
async def get_global_configs(
user: Dict[str, Any] = Depends(get_current_user),
):
"""获取全局策略配置(仅管理员)"""
if (user.get("role") or "user") != "admin":
raise HTTPException(status_code=403, detail="仅管理员可访问全局策略配置")
try:
from database.models import GlobalStrategyConfig
configs = GlobalStrategyConfig.get_all()
logger.info(f"从数据库加载了 {len(configs)} 个全局配置项")
result = {}
for config in configs:
key = config['config_key']
value = GlobalStrategyConfig._convert_value(
config['config_value'],
config['config_type']
)
result[key] = {
"value": value,
"type": config['config_type'],
"category": config['category'],
"description": config.get('description'),
}
logger.debug(f"加载配置项: {key} = {value} (type: {config['config_type']}, category: {config['category']})")
# 添加默认配置(如果数据库中没有)
for k, meta in CORE_STRATEGY_CONFIG_DEFAULTS.items():
if k not in result:
result[k] = meta
# 全局交易预设profile用于控制一组参数的默认值
for k, meta in PROFILE_CONFIG_DEFAULTS.items():
if k not in result:
result[k] = meta
# 固定风险百分比配置
FIXED_RISK_CONFIG_DEFAULTS = {
"USE_FIXED_RISK_SIZING": {
"value": True,
"type": "boolean",
"category": "risk",
"description": "使用固定风险百分比计算仓位(凯利公式)。启用后,每笔单子承受的风险固定为 FIXED_RISK_PERCENT避免大额亏损。",
},
"FIXED_RISK_PERCENT": {
"value": 0.02,
"type": "number",
"category": "risk",
"description": "每笔单子承受的风险百分比(相对于总资金)。例如 0.02 表示 2%。启用固定风险后,每笔亏损限制在该百分比内。",
},
}
for k, meta in FIXED_RISK_CONFIG_DEFAULTS.items():
if k not in result:
result[k] = meta
# 添加更多核心策略配置的默认值(确保前端能显示所有重要配置)
ADDITIONAL_STRATEGY_DEFAULTS = {
"BETA_FILTER_ENABLED": {
"value": True,
"type": "boolean",
"category": "strategy",
"description": "大盘共振过滤BTC/ETH 下跌时屏蔽多单",
},
"BETA_FILTER_THRESHOLD": {
"value": -0.005,
"type": "number",
"category": "strategy",
"description": "大盘共振阈值(比例,如 -0.005 表示 -0.5%",
},
"USE_ATR_STOP_LOSS": {
"value": True,
"type": "boolean",
"category": "strategy",
"description": "是否使用ATR动态止损优先于固定百分比",
},
"ATR_PERIOD": {
"value": 14,
"type": "number",
"category": "strategy",
"description": "ATR计算周期默认14",
},
"USE_DYNAMIC_ATR_MULTIPLIER": {
"value": False,
"type": "boolean",
"category": "strategy",
"description": "是否根据波动率动态调整ATR倍数",
},
"ATR_MULTIPLIER_MIN": {
"value": 1.5,
"type": "number",
"category": "strategy",
"description": "动态ATR倍数最小值",
},
"ATR_MULTIPLIER_MAX": {
"value": 2.5,
"type": "number",
"category": "strategy",
"description": "动态ATR倍数最大值",
},
"MIN_SIGNAL_STRENGTH": {
"value": 8, # 2026-01-29优化从7提高到8减少低质量信号
"type": "number",
"category": "strategy",
"description": "最小信号强度0-10只交易高质量信号。2026-01-29优化默认值从7提高到8。",
},
"SCAN_INTERVAL": {
"value": 1800,
"type": "number",
"category": "scan",
"description": "市场扫描间隔默认30分钟",
},
"TOP_N_SYMBOLS": {
"value": 8,
"type": "number",
"category": "scan",
"description": "每次扫描后优先处理的交易对数量",
},
"SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT": {
"value": 8,
"type": "number",
"category": "scan",
"description": "智能补单:多返回的候选数量。当前 TOP_N 中部分因冷却等被跳过时,仍会尝试这批额外候选,避免无单可下。",
},
"TAKE_PROFIT_1_PERCENT": {
"value": 0.15,
"type": "number",
"category": "strategy",
"description": "分步止盈第一目标(保证金百分比,如 0.15=15%。第一目标触发后了结50%仓位,剩余追求第二目标。",
},
"MIN_VOLUME_24H_STRICT": {
"value": 10000000,
"type": "number",
"category": "scan",
"description": "严格成交量过滤24H Volume低于此值USD直接剔除",
},
"EXCLUDE_MAJOR_COINS": {
"value": True,
"type": "boolean",
"category": "scan",
"description": "是否排除主流币BTC、ETH、BNB等专注于山寨币。山寨币策略建议开启。",
},
"USE_TRAILING_STOP": {
"value": False,
"type": "boolean",
"category": "strategy",
"description": "是否启用移动止损(默认关闭,让利润奔跑)",
},
"SMART_ENTRY_ENABLED": {
"value": False,
"type": "boolean",
"category": "strategy",
"description": "智能入场开关。关闭后回归纯限价单模式",
},
"SYMBOL_LOSS_COOLDOWN_ENABLED": {
"value": True,
"type": "boolean",
"category": "strategy",
"description": "是否启用同一交易对连续亏损后的冷却避免连续亏损后继续交易。2026-01-29新增。",
},
"SYMBOL_MAX_CONSECUTIVE_LOSSES": {
"value": 2,
"type": "number",
"category": "strategy",
"description": "最大允许连续亏损次数超过则禁止交易该交易对一段时间。2026-01-29新增。",
},
"SYMBOL_LOSS_COOLDOWN_SEC": {
"value": 3600,
"type": "number",
"category": "strategy",
"description": "连续亏损后的冷却时间默认1小时。2026-01-29新增。",
},
"MAX_RSI_FOR_LONG": {
"value": 70,
"type": "number",
"category": "strategy",
"description": "做多时 RSI 超过此值则不开多避免超买区追多。2026-01-31新增。",
},
"MAX_CHANGE_PERCENT_FOR_LONG": {
"value": 25,
"type": "number",
"category": "strategy",
"description": "做多时 24h 涨跌幅超过此值则不开多(避免追大涨)。单位:百分比数值,如 25 表示 25%。2026-01-31新增。",
},
"MIN_RSI_FOR_SHORT": {
"value": 30,
"type": "number",
"category": "strategy",
"description": "做空时 RSI 低于此值则不做空避免深超卖反弹。2026-01-31新增。",
},
"MAX_CHANGE_PERCENT_FOR_SHORT": {
"value": 10,
"type": "number",
"category": "strategy",
"description": "做空时 24h 涨跌幅超过此值则不做空24h 仍大涨时不做空。单位百分比数值。2026-01-31新增。",
},
}
for k, meta in ADDITIONAL_STRATEGY_DEFAULTS.items():
if k not in result:
result[k] = meta
logger.info(f"返回全局配置项数量: {len(result)}")
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/feasibility-check")
async def check_config_feasibility():
async def check_config_feasibility(
user: Dict[str, Any] = Depends(get_current_user),
account_id: int = Depends(get_account_id),
):
"""
检查配置可行性基于当前账户余额和杠杆倍数计算可行的配置建议
"""
@ -139,7 +517,7 @@ async def check_config_feasibility():
# 获取账户余额
try:
from api.routes.account import get_realtime_account_data
account_data = await get_realtime_account_data()
account_data = await get_realtime_account_data(account_id=account_id)
available_balance = account_data.get('available_balance', 0)
total_balance = account_data.get('total_balance', 0)
except Exception as e:
@ -154,13 +532,27 @@ async def check_config_feasibility():
"suggestions": []
}
# 获取当前配置
min_margin_usdt = TradingConfig.get_value('MIN_MARGIN_USDT', 5.0)
min_position_percent = TradingConfig.get_value('MIN_POSITION_PERCENT', 0.02)
max_position_percent = TradingConfig.get_value('MAX_POSITION_PERCENT', 0.08)
base_leverage = TradingConfig.get_value('LEVERAGE', 10)
max_leverage = TradingConfig.get_value('MAX_LEVERAGE', 15)
use_dynamic_leverage = TradingConfig.get_value('USE_DYNAMIC_LEVERAGE', True)
# 获取当前“有效配置”(平台兜底:策略核心可能来自全局账号)
try:
import config_manager as _cfg_mgr # type: ignore
mgr = _cfg_mgr.ConfigManager.for_account(int(account_id)) if hasattr(_cfg_mgr, "ConfigManager") else None
tc = mgr.get_trading_config() if mgr else {}
except Exception:
tc = {}
def _tc(key: str, default):
try:
return tc.get(key, default)
except Exception:
return default
min_margin_usdt = float(_tc('MIN_MARGIN_USDT', 5.0))
min_position_percent = float(_tc('MIN_POSITION_PERCENT', 0.02))
max_position_percent = float(_tc('MAX_POSITION_PERCENT', 0.08))
base_leverage = int(_tc('LEVERAGE', 10))
max_leverage = int(_tc('MAX_LEVERAGE', 15))
use_dynamic_leverage = bool(_tc('USE_DYNAMIC_LEVERAGE', True))
# 检查所有可能的杠杆倍数(考虑动态杠杆)
leverage_to_check = [base_leverage]
@ -385,6 +777,13 @@ async def check_config_feasibility():
"leverage_results": leverage_results
})
# 普通用户:只返回可调整的风险旋钮建议,避免前端一键应用时触发 403
if (user.get("role") or "user") != "admin":
try:
suggestions = [s for s in (suggestions or []) if s.get("config_key") in USER_RISK_KNOBS]
except Exception:
suggestions = []
return {
"feasible": is_feasible,
"account_balance": available_balance,
@ -417,10 +816,23 @@ async def check_config_feasibility():
@router.get("/{key}")
async def get_config(key: str):
async def get_config(
key: str,
user: Dict[str, Any] = Depends(get_current_user),
account_id: int = Depends(get_account_id),
):
"""获取单个配置"""
try:
config = TradingConfig.get(key)
# 虚拟字段:从 accounts 表读取
if key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}:
api_key, api_secret, use_testnet, status = Account.get_credentials(account_id)
if key == "BINANCE_API_KEY":
return {"key": key, "value": _mask(api_key or ""), "type": "string", "category": "api", "description": "币安API密钥仅脱敏展示"}
if key == "BINANCE_API_SECRET":
return {"key": key, "value": "", "type": "string", "category": "api", "description": "币安API密钥Secret不回传明文"}
return {"key": key, "value": bool(use_testnet), "type": "boolean", "category": "api", "description": "是否使用测试网(账号私有)"}
config = TradingConfig.get(key, account_id=account_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
@ -441,18 +853,59 @@ async def get_config(key: str):
@router.put("/{key}")
async def update_config(key: str, item: ConfigUpdate):
async def update_config(
key: str,
item: ConfigUpdate,
user: Dict[str, Any] = Depends(get_current_user),
account_id: int = Depends(get_account_id),
):
"""更新配置"""
try:
# 非管理员:必须是该账号 owner 才允许修改配置
if (user.get("role") or "user") != "admin":
require_account_owner(account_id, user)
# 管理员:若不是全局策略账号,则禁止修改策略核心(避免误操作)
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="该配置由平台统一管理(仅管理员可修改)")
# API Key/Secret/Testnet写入 accounts 表(账号私有)
if key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}:
if (user.get("role") or "user") != "admin":
require_account_owner(account_id, user)
try:
if key == "BINANCE_API_KEY":
Account.update_credentials(account_id, api_key=str(item.value or ""))
elif key == "BINANCE_API_SECRET":
Account.update_credentials(account_id, api_secret=str(item.value or ""))
else:
Account.update_credentials(account_id, use_testnet=bool(item.value))
except Exception as e:
raise HTTPException(status_code=500, detail=f"更新账号API配置失败: {e}")
return {
"message": "配置已更新",
"key": key,
"value": item.value,
"note": "账号API配置已更新建议重启对应账号的交易进程以立即生效",
}
# 获取现有配置以确定类型和分类
existing = TradingConfig.get(key)
existing = TradingConfig.get(key, account_id=account_id)
if existing:
config_type = item.type or existing['config_type']
category = item.category or existing['category']
description = item.description or existing['description']
else:
# 允许创建新配置用于新功能首次上线DB 里还没有 key 的情况)
meta = SMART_ENTRY_CONFIG_DEFAULTS.get(key) or AUTO_TRADE_FILTER_DEFAULTS.get(key)
meta = SMART_ENTRY_CONFIG_DEFAULTS.get(key) or AUTO_TRADE_FILTER_DEFAULTS.get(key) or RISK_KNOBS_DEFAULTS.get(key)
config_type = item.type or (meta.get("type") if meta else "string")
category = item.category or (meta.get("category") if meta else "strategy")
description = item.description or (meta.get("description") if meta else f"{key}配置")
@ -481,12 +934,16 @@ async def update_config(key: str, item: ConfigUpdate):
)
# 更新配置会同时更新数据库和Redis缓存
TradingConfig.set(key, item.value, config_type, category, description)
TradingConfig.set(key, item.value, config_type, category, description, account_id=account_id)
# 更新config_manager的缓存包括Redis
try:
import config_manager
if hasattr(config_manager, 'config_manager') and config_manager.config_manager:
if hasattr(config_manager, 'ConfigManager') and hasattr(config_manager.ConfigManager, "for_account"):
mgr = config_manager.ConfigManager.for_account(account_id)
mgr.set(key, item.value, config_type, category, description)
logger.info(f"配置已更新到Redis缓存(account_id={account_id}): {key} = {item.value}")
elif hasattr(config_manager, 'config_manager') and config_manager.config_manager:
# 调用set方法会同时更新数据库、Redis和本地缓存
config_manager.config_manager.set(key, item.value, config_type, category, description)
logger.info(f"配置已更新到Redis缓存: {key} = {item.value}")
@ -506,14 +963,44 @@ async def update_config(key: str, item: ConfigUpdate):
@router.post("/batch")
async def update_configs_batch(configs: list[ConfigItem]):
async def update_configs_batch(
configs: list[ConfigItem],
user: Dict[str, Any] = Depends(get_current_user),
account_id: int = Depends(get_account_id),
):
"""批量更新配置"""
try:
# 非管理员:必须是该账号 owner 才允许修改配置
if (user.get("role") or "user") != "admin":
require_account_owner(account_id, user)
# 管理员:若不是全局策略账号,则批量只允许风险旋钮/密钥
if (user.get("role") or "user") == "admin":
gid = _global_strategy_account_id()
if int(account_id) != int(gid):
# 直接过滤掉不允许的项(给出 errors避免“部分成功但实际无效”的错觉
pass
updated_count = 0
errors = []
for item in configs:
try:
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
if item.key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}:
if (user.get("role") or "user") != "admin":
require_account_owner(account_id, user)
# 验证配置值
if item.type == 'number':
try:
@ -528,13 +1015,23 @@ async def update_configs_batch(configs: list[ConfigItem]):
errors.append(f"{item.key}: Must be between 0 and 1")
continue
TradingConfig.set(
item.key,
item.value,
item.type,
item.category,
item.description
)
if item.key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}:
# 账号私有API配置写入 accounts
if item.key == "BINANCE_API_KEY":
Account.update_credentials(account_id, api_key=str(item.value or ""))
elif item.key == "BINANCE_API_SECRET":
Account.update_credentials(account_id, api_secret=str(item.value or ""))
else:
Account.update_credentials(account_id, use_testnet=bool(item.value))
else:
TradingConfig.set(
item.key,
item.value,
item.type,
item.category,
item.description,
account_id=account_id,
)
updated_count += 1
except Exception as e:
errors.append(f"{item.key}: {str(e)}")
@ -554,3 +1051,158 @@ async def update_configs_batch(configs: list[ConfigItem]):
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/meta")
async def get_config_meta(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
is_admin = (user.get("role") or "user") == "admin"
return {
"is_admin": bool(is_admin),
"user_risk_knobs": sorted(list(USER_RISK_KNOBS)),
"note": "平台兜底模式:策略核心由全局配置表统一管理(管理员专用);普通用户仅可调整风险旋钮。",
}
@router.put("/global/{key}")
async def update_global_config(
key: str,
item: ConfigUpdate,
user: Dict[str, Any] = Depends(get_current_user),
):
"""更新全局策略配置(仅管理员)"""
if (user.get("role") or "user") != "admin":
raise HTTPException(status_code=403, detail="仅管理员可修改全局策略配置")
try:
from database.models import GlobalStrategyConfig
# 获取现有配置以确定类型和分类
existing = GlobalStrategyConfig.get(key)
if existing:
config_type = item.type or existing['config_type']
category = item.category or existing['category']
description = item.description or existing['description']
else:
# 从默认配置获取
meta = CORE_STRATEGY_CONFIG_DEFAULTS.get(key) or {
"USE_FIXED_RISK_SIZING": {"type": "boolean", "category": "risk", "description": "使用固定风险百分比计算仓位"},
"FIXED_RISK_PERCENT": {"type": "number", "category": "risk", "description": "每笔单子承受的风险百分比"},
}.get(key)
config_type = item.type or (meta.get("type") if meta else "string")
category = item.category or (meta.get("category") if meta else "strategy")
description = item.description or (meta.get("description") if meta else f"{key}配置")
# 验证配置值
if config_type == 'number':
try:
float(item.value)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail=f"Invalid number value for {key}")
elif config_type == 'boolean':
if not isinstance(item.value, bool):
item.value = str(item.value).lower() in ('true', '1', 'yes', 'on')
# 更新全局配置
GlobalStrategyConfig.set(
key,
item.value,
config_type,
category,
description,
updated_by=user.get("username")
)
# 更新Redis缓存
try:
from config_manager import GlobalStrategyConfigManager
global_mgr = GlobalStrategyConfigManager()
if isinstance(item.value, (dict, list, bool, int, float)):
import json
value_str = json.dumps(item.value, ensure_ascii=False)
else:
value_str = str(item.value)
global_mgr._set_to_redis(key, item.value)
except Exception as e:
logger.warning(f"更新全局配置Redis缓存失败: {e}")
return {"message": f"全局配置 {key} 已更新", "key": key, "value": item.value}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/global/batch")
async def update_global_configs_batch(
configs: list[ConfigItem],
user: Dict[str, Any] = Depends(get_current_user),
):
"""批量更新全局策略配置(仅管理员)"""
if (user.get("role") or "user") != "admin":
raise HTTPException(status_code=403, detail="仅管理员可修改全局策略配置")
try:
from database.models import GlobalStrategyConfig
from config_manager import GlobalStrategyConfigManager
updated_count = 0
errors = []
global_mgr = GlobalStrategyConfigManager()
for item in configs:
try:
# 获取现有配置
existing = GlobalStrategyConfig.get(item.key)
if existing:
config_type = item.type or existing['config_type']
category = item.category or existing['category']
description = item.description or existing['description']
else:
# 从默认配置获取
meta = CORE_STRATEGY_CONFIG_DEFAULTS.get(item.key) or {
"USE_FIXED_RISK_SIZING": {"type": "boolean", "category": "risk"},
"FIXED_RISK_PERCENT": {"type": "number", "category": "risk"},
}.get(item.key)
config_type = item.type or (meta.get("type") if meta else "string")
category = item.category or (meta.get("category") if meta else "strategy")
description = item.description or (meta.get("description") if meta else f"{item.key}配置")
# 验证配置值
if config_type == 'number':
try:
float(item.value)
except (ValueError, TypeError):
errors.append(f"{item.key}: Invalid number value")
continue
# 更新全局配置
GlobalStrategyConfig.set(
item.key,
item.value,
config_type,
category,
description,
updated_by=user.get("username")
)
# 更新Redis缓存
global_mgr._set_to_redis(item.key, item.value)
updated_count += 1
except Exception as e:
errors.append(f"{item.key}: {str(e)}")
if errors:
return {
"message": f"成功更新 {updated_count} 个配置,{len(errors)} 个失败",
"updated": updated_count,
"errors": errors,
"note": "交易系统将在下次扫描时自动使用新配置"
}
return {
"message": f"成功更新 {updated_count} 个全局配置",
"updated": updated_count,
"note": "交易系统将在下次扫描时自动使用新配置"
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View 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可用性不暴露密码
- recommendationssnapshot 最新生成时间若推荐进程在跑会持续更新
"""
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"},
},
}

View File

@ -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,80 @@ 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
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)):
"""获取管理员仪表板数据(所有用户统计)"""
try:
accounts = Account.list_all()
stats = []
total_assets = 0
total_pnl = 0
active_accounts = 0
for acc in accounts:
aid = acc['id']
# 获取最新快照
snapshots = AccountSnapshot.get_recent(1, 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"])
total_pnl += float(acc_stat["total_pnl"])
if acc['status'] == 'active':
active_accounts += 1
stats.append(acc_stat)
return {
"summary": {
"total_accounts": len(accounts),
"active_accounts": active_accounts,
"total_assets_usdt": total_assets,
"total_pnl_usdt": total_pnl
},
"accounts": stats
}
except Exception as e:
logger.error(f"获取管理员仪表板数据失败: {e}", exc_info=True)
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)
trades = Trade.get_all(start_date=start_date, account_id=account_id)
return {
"snapshots": snapshots,
@ -39,8 +96,11 @@ 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
@ -48,15 +108,16 @@ async def get_dashboard_data():
# 优先尝试获取实时账户数据
try:
from api.routes.account import get_realtime_account_data
account_data = await get_realtime_account_data()
logger.info("成功获取实时账户数据")
logger.info(f"调用 get_realtime_account_data(account_id={account_id})")
account_data = await get_realtime_account_data(account_id=account_id)
logger.info(f"成功获取实时账户数据,返回的 total_balance={account_data.get('total_balance', 'N/A') if account_data else 'N/A'}")
except HTTPException as e:
# HTTPException 需要特殊处理,提取错误信息
account_error = e.detail
logger.warning(f"获取实时账户数据失败 (HTTP {e.status_code}): {account_error}")
# 回退到数据库快照
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),
@ -75,7 +136,7 @@ async def get_dashboard_data():
logger.warning(f"获取实时账户数据失败: {account_error}", exc_info=True)
# 回退到数据库快照
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),
@ -93,16 +154,17 @@ async def get_dashboard_data():
positions_error = None
try:
from api.routes.account import get_realtime_positions
positions = await get_realtime_positions()
logger.info(f"调用 get_realtime_positions(account_id={account_id})")
positions = await get_realtime_positions(account_id=account_id)
# 转换为前端需要的格式
open_trades = positions
logger.info(f"成功获取实时持仓数据: {len(open_trades)} 个持仓")
logger.info(f"成功获取实时持仓数据: {len(open_trades)} 个持仓 (account_id={account_id})")
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]
db_trades = Trade.get_all(status='open', account_id=account_id)[:10]
# 格式化数据库记录,添加 entry_value_usdt 字段
open_trades = []
for trade in db_trades:
@ -131,7 +193,7 @@ async def get_dashboard_data():
logger.warning(f"获取实时持仓失败: {positions_error}", exc_info=True)
# 回退到数据库记录
try:
db_trades = Trade.get_all(status='open')[:10]
db_trades = Trade.get_all(status='open', account_id=account_id)[:10]
# 格式化数据库记录,添加 entry_value_usdt 字段
open_trades = []
for trade in db_trades:
@ -176,7 +238,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 +299,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']),
@ -252,7 +314,12 @@ async def get_dashboard_data():
"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": len(open_trades),
}
}
# 如果有错误,在响应中包含错误信息(但不影响返回)
@ -263,6 +330,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)

View File

@ -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
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 条测试日志到 Rediserror/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 不可用,无法写入测试日志")
@ -311,7 +313,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 +324,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 +332,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 +394,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 +426,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 +483,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 +534,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 +580,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 +605,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]
@ -677,16 +709,22 @@ def _action_with_fallback(action: str, program: str) -> Tuple[str, Optional[str]
@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 +748,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:
@ -743,8 +787,7 @@ async def clear_cache(x_admin_token: Optional[str] = Header(default=None, alias=
@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 +813,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 +839,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 +865,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 +907,190 @@ 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("/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 +1098,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 +1109,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 +1122,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 +1163,37 @@ 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 界面将无法访问,请手动在服务器启动!",
}

View File

@ -1,10 +1,11 @@
"""
交易记录API
"""
from fastapi import APIRouter, Query, HTTPException
from fastapi import APIRouter, Query, HTTPException, Header, Depends
from typing import Optional
from datetime import datetime, timedelta
from collections import Counter
import json
import sys
from pathlib import Path
import logging
@ -17,6 +18,7 @@ sys.path.insert(0, str(project_root))
sys.path.insert(0, str(project_root / 'backend'))
from database.models import Trade
from api.auth_deps import get_account_id
router = APIRouter()
# 在模块级别创建logger与其他路由文件保持一致
@ -69,6 +71,7 @@ def get_timestamp_range(period: Optional[str] = None):
@router.get("")
@router.get("/")
async def get_trades(
account_id: int = Depends(get_account_id),
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
period: Optional[str] = Query(None, description="快速时间段筛选: '1d'(最近1天), '7d'(最近7天), '30d'(最近30天), 'today'(今天), 'week'(本周), 'month'(本月)"),
@ -122,7 +125,7 @@ async def get_trades(
except ValueError:
logger.warning(f"无效的结束日期格式: {end_date}")
trades = Trade.get_all(start_timestamp, end_timestamp, symbol, status, trade_type, exit_reason)
trades = Trade.get_all(start_timestamp, end_timestamp, symbol, status, trade_type, exit_reason, account_id=account_id)
logger.info(f"查询到 {len(trades)} 条交易记录")
# 格式化交易记录,添加平仓类型的中文显示
@ -144,6 +147,13 @@ async def get_trades(
else:
formatted_trade['exit_reason_display'] = ''
# 入场思路 entry_context 可能从 DB 以 JSON 字符串返回,解析为对象便于前端/分析使用
if formatted_trade.get('entry_context') is not None and isinstance(formatted_trade['entry_context'], str):
try:
formatted_trade['entry_context'] = json.loads(formatted_trade['entry_context'])
except Exception:
pass
formatted_trades.append(formatted_trade)
result = {
@ -169,6 +179,7 @@ async def get_trades(
@router.get("/stats")
async def get_trade_stats(
account_id: int = Depends(get_account_id),
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)"),
period: Optional[str] = Query(None, description="快速时间段筛选: '1d', '7d', '30d', 'today', 'week', 'month'"),
@ -209,7 +220,7 @@ async def get_trade_stats(
except ValueError:
logger.warning(f"无效的结束日期格式: {end_date}")
trades = Trade.get_all(start_timestamp, end_timestamp, symbol, None)
trades = Trade.get_all(start_timestamp, end_timestamp, symbol, None, account_id=account_id)
closed_trades = [t for t in trades if t['status'] == 'closed']
# 排除0盈亏的订单abs(pnl) < 0.01 USDT视为0盈亏这些订单不应该影响胜率统计
@ -227,6 +238,14 @@ async def get_trade_stats(
sum(abs(float(t["pnl"])) for t in loss_trades) / len(loss_trades) if loss_trades else 0.0
)
win_loss_ratio = (avg_win_pnl / avg_loss_pnl_abs) if avg_loss_pnl_abs > 0 else None
# 实际盈亏比(所有盈利单的总盈利 / 所有亏损单的总亏损,必须 > 1.5,目标 2.5-3.0
total_win_pnl = sum(float(t["pnl"]) for t in win_trades) if win_trades else 0.0
total_loss_pnl_abs = sum(abs(float(t["pnl"])) for t in loss_trades) if loss_trades else 0.0
actual_profit_loss_ratio = (total_win_pnl / total_loss_pnl_abs) if total_loss_pnl_abs > 0 else None
# 盈利因子(总盈利金额 / 总亏损金额,必须 > 1.1,目标 1.5+
profit_factor = (total_win_pnl / total_loss_pnl_abs) if total_loss_pnl_abs > 0 else None
# 平仓原因分布(用来快速定位胜率低的主要来源:止损/止盈/同步等)
exit_reason_counts = Counter((t.get("exit_reason") or "unknown") for t in meaningful_trades)
@ -274,6 +293,14 @@ async def get_trade_stats(
"avg_loss_pnl_abs": avg_loss_pnl_abs,
"avg_win_loss_ratio": win_loss_ratio,
"avg_win_loss_ratio_target": 3.0,
# 实际盈亏比(所有盈利单总盈利 / 所有亏损单总亏损,目标 > 2.0
"actual_profit_loss_ratio": actual_profit_loss_ratio,
"actual_profit_loss_ratio_target": 2.0,
"total_win_pnl": total_win_pnl,
"total_loss_pnl_abs": total_loss_pnl_abs,
# 盈利因子(总盈利 / 总亏损,目标 > 1.2
"profit_factor": profit_factor,
"profit_factor_target": 1.2,
"exit_reason_counts": dict(exit_reason_counts),
"avg_duration_minutes": avg_duration_minutes,
# 总交易量(名义下单量口径):优先使用 notional_usdt新字段否则回退 entry_price * quantity
@ -441,6 +468,9 @@ async def sync_trades_from_binance(
# 细分 exit_reason优先使用币安订单类型其次用价格接近止损/止盈做兜底
exit_reason = "sync"
# 检查订单的 reduceOnly 字段:如果是 true说明是自动平仓不应该标记为 manual
is_reduce_only = order.get("reduceOnly", False) if isinstance(order, dict) else False
if "TRAILING" in otype:
exit_reason = "trailing_stop"
elif "TAKE_PROFIT" in otype:
@ -448,10 +478,14 @@ async def sync_trades_from_binance(
elif "STOP" in otype:
exit_reason = "stop_loss"
elif otype in ("MARKET", "LIMIT"):
exit_reason = "manual"
# 如果是 reduceOnly 订单,说明是自动平仓(可能是保护单触发的),先标记为 sync后续用价格判断
if is_reduce_only:
exit_reason = "sync" # 临时标记,后续用价格判断
else:
exit_reason = "manual" # 非 reduceOnly 的 MARKET/LIMIT 订单才是真正的手动平仓
try:
def _close_to(a: float, b: float, max_pct: float = 0.01) -> bool:
def _close_to(a: float, b: float, max_pct: float = 0.02) -> bool: # 放宽到2%,因为滑点可能导致价格不完全一致
if a <= 0 or b <= 0:
return False
return abs((a - b) / b) <= max_pct
@ -462,14 +496,21 @@ async def sync_trades_from_binance(
tp = trade.get("take_profit_price")
tp1 = trade.get("take_profit_1")
tp2 = trade.get("take_profit_2")
if sl is not None and _close_to(ep, float(sl), max_pct=0.01):
# 优先检查止损
if sl is not None and _close_to(ep, float(sl), max_pct=0.02):
exit_reason = "stop_loss"
elif tp is not None and _close_to(ep, float(tp), max_pct=0.01):
# 然后检查止盈
elif tp is not None and _close_to(ep, float(tp), max_pct=0.02):
exit_reason = "take_profit"
elif tp1 is not None and _close_to(ep, float(tp1), max_pct=0.01):
elif tp1 is not None and _close_to(ep, float(tp1), max_pct=0.02):
exit_reason = "take_profit"
elif tp2 is not None and _close_to(ep, float(tp2), max_pct=0.01):
elif tp2 is not None and _close_to(ep, float(tp2), max_pct=0.02):
exit_reason = "take_profit"
# 如果价格接近入场价,可能是移动止损触发的
elif is_reduce_only and exit_reason == "sync":
entry_price_val = float(trade.get("entry_price", 0) or 0)
if entry_price_val > 0 and _close_to(ep, entry_price_val, max_pct=0.01):
exit_reason = "trailing_stop"
except Exception:
pass

View File

@ -0,0 +1,529 @@
"""
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
DEFAULT_CANDIDATE_CONFS = [
"/www/server/panel/plugin/supervisor/supervisord.conf",
"/www/server/panel/plugin/supervisor/supervisor.conf",
"/etc/supervisor/supervisord.conf",
"/etc/supervisord.conf",
]
# 常见 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)
# 当前进程 pythonbackend 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 = os.getenv("SUPERVISORCTL_PATH", "supervisorctl")
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 超时")
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),
)

View File

@ -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,21 @@ import logging
logger = logging.getLogger(__name__)
# 平台兜底策略核心使用全局配置表global_strategy_config普通用户账号只允许调整“风险旋钮”
# - 风险旋钮:每个账号独立(仓位/频次等)
# - 其它策略参数:统一从全局配置表读取,避免每个用户乱改导致策略不可控
# 注意不再依赖account_id=1全局配置存储在独立的global_strategy_config表中
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",
}
# 尝试导入同步Redis客户端用于配置缓存
try:
import redis
@ -55,16 +71,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" # 独立的Redis键
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:
@ -114,7 +356,13 @@ class ConfigManager:
# 从环境变量获取SSL配置如果未设置使用默认值
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 +399,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 +467,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 +495,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 +511,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 +546,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 +567,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 +585,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:
@ -341,9 +628,24 @@ class ConfigManager:
# 4. 返回默认值
return default
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 +655,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 +689,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 +710,220 @@ class ConfigManager:
def get_trading_config(self):
"""获取交易配置字典兼容原有config.py的TRADING_CONFIG"""
# 全局策略配置管理器从独立的global_strategy_config表读取
global_config_mgr = GlobalStrategyConfigManager()
try:
global_config_mgr.reload_from_redis()
except Exception:
pass
def eff_get(key: str, default: Any):
"""
策略核心从全局配置表读取global_strategy_config
风险旋钮从当前账号读取
"""
# API key/secret/testnet 永远按账号读取(在 get() 内部已处理)
if key in RISK_KNOBS_KEYS:
value = self.get(key, default)
else:
# 从全局配置表读取
try:
value = global_config_mgr.get(key, default)
except Exception:
value = self.get(key, 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',
'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
# 静默更新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}")
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
return {
# 仓位控制
'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.08), # 单笔最大保证金占比
'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', 5.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),
# 涨跌幅阈值
'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.10), # 默认10%(第二目标/单目标止盈)
'TAKE_PROFIT_1_PERCENT': eff_get('TAKE_PROFIT_1_PERCENT', 0.15), # 默认15%(分步止盈第一目标,提高整体盈亏比)
'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', 2.0), # ATR止损倍数2.02026-01-29优化从1.5提高到2.0,减少被正常波动扫出)
'ATR_TAKE_PROFIT_MULTIPLIER': eff_get('ATR_TAKE_PROFIT_MULTIPLIER', 2.0), # ATR止盈倍数2.02026-01-27优化降低止盈目标更容易触发
'RISK_REWARD_RATIO': eff_get('RISK_REWARD_RATIO', 3.0), # 盈亏比3:12026-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%
# 市场扫描30分钟主周期
'SCAN_INTERVAL': eff_get('SCAN_INTERVAL', scan_interval_default), # 30分钟增加交易机会
'TOP_N_SYMBOLS': eff_get('TOP_N_SYMBOLS', 8), # 每次扫描后优先处理的交易对数量
'SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT': eff_get('SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT', 8), # 智能补单:多返回的候选数量,冷却时仍可尝试后续交易对
'MAX_SCAN_SYMBOLS': eff_get('MAX_SCAN_SYMBOLS', 250), # 扫描的最大交易对数量增加到250提升覆盖率到46%
'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', 15), # 降低到15更保守配合更大的保证金
# 移动止损:默认关闭(避免过早截断利润,让利润奔跑)
'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_4H_NEUTRAL': eff_get('AUTO_TRADE_ALLOW_4H_NEUTRAL', allow_neutral_default),
# 智能入场/限价偏移(部分逻辑会直接读取 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),
# 动态过滤优化
'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', 70), # 做多时 RSI 超过此值则不开多
'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 涨跌幅超过此值则不做空
# 趋势尾部入场过滤 & 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),
}
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}")
# 全局配置管理器实例
config_manager = ConfigManager()
# 全局配置管理器实例默认账号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):

View 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='用户-交易账号授权';

View File

@ -0,0 +1,21 @@
-- 为 trades 表添加「入场思路/过程」字段,便于事后分析策略执行效果
-- 存储 JSONsignal_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;

View 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;

View 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 KEYenc:v1:...',
`api_secret_enc` TEXT NULL COMMENT '加密后的 API SECRETenc: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);

View 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%仓位)后,剩余仓位移动止损
--
-- 这些状态用于更准确地统计胜率和盈亏比:
-- - 第一目标止盈后剩余仓位止损,应该算作"部分成功"(第一目标已达成)
-- - 第一目标止盈后剩余仓位第二目标止盈,应该算作"完整成功"

View File

@ -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 KEYenc:v1:...',
`api_secret_enc` TEXT NULL COMMENT '加密后的 API SECRETenc: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倍数最小值'),

View 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;

View File

@ -5,6 +5,7 @@ from database.connection import db
from datetime import datetime, timezone, timedelta
import json
import logging
import os
# 北京时间时区UTC+8
BEIJING_TZ = timezone(timedelta(hours=8))
@ -15,46 +16,266 @@ def get_beijing_time():
logger = logging.getLogger(__name__)
def _resolve_default_account_id() -> int:
"""
默认账号ID
- trading_system 多进程每个进程可通过 ATS_ACCOUNT_ID 指定自己的 account_id
- backend未传 account_id 时默认走 1兼容单账号
"""
for k in ("ATS_ACCOUNT_ID", "ACCOUNT_ID", "ATS_DEFAULT_ACCOUNT_ID"):
v = (os.getenv(k, "") or "").strip()
if v:
try:
return int(v)
except Exception:
continue
return 1
DEFAULT_ACCOUNT_ID = _resolve_default_account_id()
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
class Account:
"""
账号模型多账号
- API Key/Secret 建议加密存储在 accounts 表中而不是 trading_config
"""
@staticmethod
def get(account_id: int):
import logging
logger = logging.getLogger(__name__)
logger.info(f"Account.get called with account_id={account_id}")
row = db.execute_one("SELECT * FROM accounts WHERE id = %s", (int(account_id),))
if row:
logger.info(f"Account.get: found account_id={account_id}, name={row.get('name', 'N/A')}, status={row.get('status', 'N/A')}")
else:
logger.warning(f"Account.get: account_id={account_id} not found in database")
return row
@staticmethod
def list_all():
return db.execute_query("SELECT id, name, status, created_at, updated_at FROM accounts ORDER BY id ASC")
@staticmethod
def create(name: str, api_key: str = "", api_secret: str = "", use_testnet: bool = False, status: str = "active"):
from security.crypto import encrypt_str # 延迟导入,避免无依赖时直接崩
api_key_enc = encrypt_str(api_key or "")
api_secret_enc = encrypt_str(api_secret or "")
db.execute_update(
"""INSERT INTO accounts (name, status, api_key_enc, api_secret_enc, use_testnet)
VALUES (%s, %s, %s, %s, %s)""",
(name, status, api_key_enc, api_secret_enc, bool(use_testnet)),
)
return db.execute_one("SELECT LAST_INSERT_ID() as id")["id"]
@staticmethod
def update_credentials(account_id: int, api_key: str = None, api_secret: str = None, use_testnet: bool = None):
from security.crypto import encrypt_str # 延迟导入
fields = []
params = []
if api_key is not None:
fields.append("api_key_enc = %s")
params.append(encrypt_str(api_key))
if api_secret is not None:
fields.append("api_secret_enc = %s")
params.append(encrypt_str(api_secret))
if use_testnet is not None:
fields.append("use_testnet = %s")
params.append(bool(use_testnet))
if not fields:
return
params.append(int(account_id))
db.execute_update(f"UPDATE accounts SET {', '.join(fields)} WHERE id = %s", tuple(params))
@staticmethod
def get_credentials(account_id: int):
"""
返回 (api_key, api_secret, use_testnet, status)密文字段会自动解密
若未配置 master key 且库里是明文仍可工作但不安全
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Account.get_credentials called with account_id={account_id}")
row = Account.get(account_id)
if not row:
logger.warning(f"Account.get_credentials: account_id={account_id} not found in database")
return "", "", False, "disabled"
try:
from security.crypto import decrypt_str
status = row.get("status") or "active"
api_key = decrypt_str(row.get("api_key_enc") or "")
api_secret = decrypt_str(row.get("api_secret_enc") or "")
except Exception:
# 兼容:无 cryptography 或未配 master key 时:
# - 若库里是明文,仍可工作
# - 若库里是 enc:v1 密文但未配 ATS_MASTER_KEY则不能解密也不能把密文当作 Key 使用
status = "disabled"
api_key_raw = row.get("api_key_enc") or ""
api_secret_raw = row.get("api_secret_enc") or ""
api_key = "" if str(api_key_raw).startswith("enc:v1:") else str(api_key_raw)
api_secret = "" if str(api_secret_raw).startswith("enc:v1:") else str(api_secret_raw)
use_testnet = bool(row.get("use_testnet") or False)
return api_key, api_secret, use_testnet, status
class User:
"""登录用户(管理员/普通用户)"""
@staticmethod
def get_by_username(username: str):
return db.execute_one("SELECT * FROM users WHERE username = %s", (str(username),))
@staticmethod
def get_by_id(user_id: int):
return db.execute_one("SELECT * FROM users WHERE id = %s", (int(user_id),))
@staticmethod
def list_all():
return db.execute_query("SELECT id, username, role, status, created_at, updated_at FROM users ORDER BY id ASC")
@staticmethod
def create(username: str, password_hash: str, role: str = "user", status: str = "active"):
db.execute_update(
"INSERT INTO users (username, password_hash, role, status) VALUES (%s, %s, %s, %s)",
(username, password_hash, role, status),
)
return db.execute_one("SELECT LAST_INSERT_ID() as id")["id"]
@staticmethod
def set_password(user_id: int, password_hash: str):
db.execute_update("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, int(user_id)))
@staticmethod
def set_status(user_id: int, status: str):
db.execute_update("UPDATE users SET status = %s WHERE id = %s", (status, int(user_id)))
@staticmethod
def set_role(user_id: int, role: str):
db.execute_update("UPDATE users SET role = %s WHERE id = %s", (role, int(user_id)))
class UserAccountMembership:
"""用户-交易账号授权关系"""
@staticmethod
def add(user_id: int, account_id: int, role: str = "viewer"):
db.execute_update(
"""INSERT INTO user_account_memberships (user_id, account_id, role)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE role = VALUES(role)""",
(int(user_id), int(account_id), role),
)
@staticmethod
def remove(user_id: int, account_id: int):
db.execute_update(
"DELETE FROM user_account_memberships WHERE user_id = %s AND account_id = %s",
(int(user_id), int(account_id)),
)
@staticmethod
def list_for_user(user_id: int):
return db.execute_query(
"SELECT * FROM user_account_memberships WHERE user_id = %s ORDER BY account_id ASC",
(int(user_id),),
)
@staticmethod
def list_for_account(account_id: int):
return db.execute_query(
"SELECT * FROM user_account_memberships WHERE account_id = %s ORDER BY user_id ASC",
(int(account_id),),
)
@staticmethod
def has_access(user_id: int, account_id: int) -> bool:
row = db.execute_one(
"SELECT 1 as ok FROM user_account_memberships WHERE user_id = %s AND account_id = %s",
(int(user_id), int(account_id)),
)
return bool(row)
@staticmethod
def get_role(user_id: int, account_id: int) -> str:
row = db.execute_one(
"SELECT role FROM user_account_memberships WHERE user_id = %s AND account_id = %s",
(int(user_id), int(account_id)),
)
return (row.get("role") if isinstance(row, dict) else None) or ""
class TradingConfig:
"""交易配置模型"""
@staticmethod
def get_all():
def get_all(account_id: int = None):
"""获取所有配置"""
return db.execute_query(
"SELECT * FROM trading_config ORDER BY category, config_key"
)
aid = int(account_id or DEFAULT_ACCOUNT_ID)
if _table_has_column("trading_config", "account_id"):
return db.execute_query(
"SELECT * FROM trading_config WHERE account_id = %s ORDER BY category, config_key",
(aid,),
)
return db.execute_query("SELECT * FROM trading_config ORDER BY category, config_key")
@staticmethod
def get(key):
def get(key, account_id: int = None):
"""获取单个配置"""
return db.execute_one(
"SELECT * FROM trading_config WHERE config_key = %s",
(key,)
)
aid = int(account_id or DEFAULT_ACCOUNT_ID)
if _table_has_column("trading_config", "account_id"):
return db.execute_one(
"SELECT * FROM trading_config WHERE account_id = %s AND config_key = %s",
(aid, key),
)
return db.execute_one("SELECT * FROM trading_config WHERE config_key = %s", (key,))
@staticmethod
def set(key, value, config_type, category, description=None):
def set(key, value, config_type, category, description=None, account_id: int = None):
"""设置配置"""
value_str = TradingConfig._convert_to_string(value, config_type)
db.execute_update(
"""INSERT INTO trading_config
(config_key, config_value, config_type, category, description)
VALUES (%s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
config_value = VALUES(config_value),
config_type = VALUES(config_type),
category = VALUES(category),
description = VALUES(description),
updated_at = CURRENT_TIMESTAMP""",
(key, value_str, config_type, category, description)
)
aid = int(account_id or DEFAULT_ACCOUNT_ID)
if _table_has_column("trading_config", "account_id"):
db.execute_update(
"""INSERT INTO trading_config
(account_id, config_key, config_value, config_type, category, description)
VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
config_value = VALUES(config_value),
config_type = VALUES(config_type),
category = VALUES(category),
description = VALUES(description),
updated_at = CURRENT_TIMESTAMP""",
(aid, key, value_str, config_type, category, description),
)
else:
db.execute_update(
"""INSERT INTO trading_config
(config_key, config_value, config_type, category, description)
VALUES (%s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
config_value = VALUES(config_value),
config_type = VALUES(config_type),
category = VALUES(category),
description = VALUES(description),
updated_at = CURRENT_TIMESTAMP""",
(key, value_str, config_type, category, description),
)
@staticmethod
def get_value(key, default=None):
def get_value(key, default=None, account_id: int = None):
"""获取配置值(自动转换类型)"""
result = TradingConfig.get(key)
result = TradingConfig.get(key, account_id=account_id)
if result:
return TradingConfig._convert_value(result['config_value'], result['config_type'])
return default
@ -84,6 +305,74 @@ class TradingConfig:
return str(value)
class GlobalStrategyConfig:
"""全局策略配置模型(独立于账户,管理员专用)"""
@staticmethod
def get_all():
"""获取所有全局配置"""
if not _table_has_column("global_strategy_config", "config_key"):
return []
return db.execute_query(
"SELECT * FROM global_strategy_config ORDER BY category, config_key"
)
@staticmethod
def get(key):
"""获取单个全局配置"""
if not _table_has_column("global_strategy_config", "config_key"):
return None
return db.execute_one(
"SELECT * FROM global_strategy_config WHERE config_key = %s",
(key,)
)
@staticmethod
def set(key, value, config_type, category, description=None, updated_by=None):
"""设置全局配置"""
if not _table_has_column("global_strategy_config", "config_key"):
# 表不存在时回退到trading_config兼容旧系统
return TradingConfig.set(key, value, config_type, category, description, account_id=1)
value_str = TradingConfig._convert_to_string(value, config_type)
db.execute_update(
"""INSERT INTO global_strategy_config
(config_key, config_value, config_type, category, description, updated_by)
VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
config_value = VALUES(config_value),
config_type = VALUES(config_type),
category = VALUES(category),
description = VALUES(description),
updated_by = VALUES(updated_by),
updated_at = CURRENT_TIMESTAMP""",
(key, value_str, config_type, category, description, updated_by),
)
@staticmethod
def get_value(key, default=None):
"""获取全局配置值(自动转换类型)"""
result = GlobalStrategyConfig.get(key)
if result:
return GlobalStrategyConfig._convert_value(result['config_value'], result['config_type'])
return default
@staticmethod
def _convert_value(value, config_type):
"""转换配置值类型复用TradingConfig的逻辑"""
return TradingConfig._convert_value(value, config_type)
@staticmethod
def delete(key):
"""删除全局配置"""
if not _table_has_column("global_strategy_config", "config_key"):
return
db.execute_update(
"DELETE FROM global_strategy_config WHERE config_key = %s",
(key,)
)
class Trade:
"""交易记录模型"""
@ -103,6 +392,8 @@ class Trade:
atr=None,
notional_usdt=None,
margin_usdt=None,
account_id: int = None,
entry_context=None,
):
"""创建交易记录(使用北京时间)
@ -112,7 +403,7 @@ class Trade:
quantity: 数量
entry_price: 入场价
leverage: 杠杆
entry_reason: 入场原因
entry_reason: 入场原因简短文本
entry_order_id: 币安开仓订单号可选用于对账
stop_loss_price: 实际使用的止损价格考虑了ATR等动态计算
take_profit_price: 实际使用的止盈价格考虑了ATR等动态计算
@ -121,6 +412,7 @@ class Trade:
atr: 开仓时使用的ATR值可选
notional_usdt: 名义下单量USDT可选
margin_usdt: 保证金USDT可选
entry_context: 入场思路/过程dict将存为 JSON信号强度市场状态趋势过滤通过情况等便于事后分析策略执行效果
"""
entry_time = get_beijing_time()
@ -148,6 +440,10 @@ class Trade:
columns = ["symbol", "side", "quantity", "entry_price", "leverage", "entry_reason", "status", "entry_time"]
values = [symbol, side, quantity, entry_price, leverage, entry_reason, "open", entry_time]
if _has_column("account_id"):
columns.insert(0, "account_id")
values.insert(0, int(account_id or DEFAULT_ACCOUNT_ID))
if _has_column("entry_order_id"):
columns.append("entry_order_id")
values.append(entry_order_id)
@ -180,6 +476,15 @@ class Trade:
columns.append("take_profit_2")
values.append(take_profit_2)
if _has_column("entry_context") and entry_context is not None:
try:
entry_context_str = json.dumps(entry_context, ensure_ascii=False) if isinstance(entry_context, dict) else str(entry_context)
except Exception:
entry_context_str = None
if entry_context_str is not None:
columns.append("entry_context")
values.append(entry_context_str)
placeholders = ", ".join(["%s"] * len(columns))
sql = f"INSERT INTO trades ({', '.join(columns)}) VALUES ({placeholders})"
db.execute_update(sql, tuple(values))
@ -325,7 +630,7 @@ class Trade:
)
@staticmethod
def get_all(start_timestamp=None, end_timestamp=None, symbol=None, status=None, trade_type=None, exit_reason=None):
def get_all(start_timestamp=None, end_timestamp=None, symbol=None, status=None, trade_type=None, exit_reason=None, account_id: int = None):
"""获取交易记录
Args:
@ -338,6 +643,14 @@ class Trade:
"""
query = "SELECT * FROM trades WHERE 1=1"
params = []
# 多账号隔离兼容旧schema
try:
if _table_has_column("trades", "account_id"):
query += " AND account_id = %s"
params.append(int(account_id or DEFAULT_ACCOUNT_ID))
except Exception:
pass
if start_timestamp is not None:
query += " AND created_at >= %s"
@ -366,11 +679,17 @@ class Trade:
return result
@staticmethod
def get_by_symbol(symbol, status='open'):
def get_by_symbol(symbol, status='open', account_id: int = None):
"""根据交易对获取持仓"""
aid = int(account_id or DEFAULT_ACCOUNT_ID)
if _table_has_column("trades", "account_id"):
return db.execute_query(
"SELECT * FROM trades WHERE account_id = %s AND symbol = %s AND status = %s",
(aid, symbol, status),
)
return db.execute_query(
"SELECT * FROM trades WHERE symbol = %s AND status = %s",
(symbol, status)
(symbol, status),
)
@ -378,24 +697,40 @@ class AccountSnapshot:
"""账户快照模型"""
@staticmethod
def create(total_balance, available_balance, total_position_value, total_pnl, open_positions):
def create(total_balance, available_balance, total_position_value, total_pnl, open_positions, account_id: int = None):
"""创建账户快照(使用北京时间)"""
snapshot_time = get_beijing_time()
db.execute_update(
"""INSERT INTO account_snapshots
(total_balance, available_balance, total_position_value, total_pnl, open_positions, snapshot_time)
VALUES (%s, %s, %s, %s, %s, %s)""",
(total_balance, available_balance, total_position_value, total_pnl, open_positions, snapshot_time)
)
if _table_has_column("account_snapshots", "account_id"):
db.execute_update(
"""INSERT INTO account_snapshots
(account_id, total_balance, available_balance, total_position_value, total_pnl, open_positions, snapshot_time)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(int(account_id or DEFAULT_ACCOUNT_ID), total_balance, available_balance, total_position_value, total_pnl, open_positions, snapshot_time),
)
else:
db.execute_update(
"""INSERT INTO account_snapshots
(total_balance, available_balance, total_position_value, total_pnl, open_positions, snapshot_time)
VALUES (%s, %s, %s, %s, %s, %s)""",
(total_balance, available_balance, total_position_value, total_pnl, open_positions, snapshot_time),
)
@staticmethod
def get_recent(days=7):
def get_recent(days=7, account_id: int = None):
"""获取最近的快照"""
aid = int(account_id or DEFAULT_ACCOUNT_ID)
if _table_has_column("account_snapshots", "account_id"):
return db.execute_query(
"""SELECT * FROM account_snapshots
WHERE account_id = %s AND snapshot_time >= DATE_SUB(NOW(), INTERVAL %s DAY)
ORDER BY snapshot_time DESC""",
(aid, days),
)
return db.execute_query(
"""SELECT * FROM account_snapshots
WHERE snapshot_time >= DATE_SUB(NOW(), INTERVAL %s DAY)
ORDER BY snapshot_time DESC""",
(days,)
(days,),
)

View File

@ -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

View File

@ -0,0 +1,4 @@
"""
安全相关工具加密/解密等
"""

119
backend/security/crypto.py Normal file
View 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")

View File

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
缺省全局配置项同步到数据库 global_strategy_config
- 已在 UI 保存过的项不会覆盖只插入缺失的 key
- 用于新上线配置项 MAX_RSI_FOR_LONGMIN_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": "70", "config_type": "number", "category": "strategy",
"description": "做多时 RSI 超过此值则不开多避免超买区追多。2026-01-31新增。"},
{"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.15", "config_type": "number", "category": "strategy",
"description": "分步止盈第一目标(保证金百分比,如 0.15=15%。第一目标触发后了结50%仓位,剩余追求第二目标。"},
{"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%)。"},
]
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()

View 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笔交易确保新策略按预期运行。如有异常立即暂停并检查日志。

View File

View 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.1212%
- `TAKE_PROFIT_PERCENT`: 0.2020%
---
### 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%
---
## 🚨 核心问题
### 问题1ATR止损倍数可能过宽
**当前配置**
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
**问题**
- 如果ATR = 5%,止损距离 = 5% × 2.0 = 10%
- 对于8倍杠杆10%的价格变动 = 80%的保证金变动
- 这可能导致巨额亏损(如-65.84%
**建议**
- 收紧ATR止损倍数2.0 → **1.5**
- 既能容忍波动,又能控制风险
---
### 问题2ATR止盈倍数可能过高
**当前配置**
- `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.1212%
- `TAKE_PROFIT_PERCENT`: 0.2020%
### 建议配置(优化)
- `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%
**预期效果**
- ✅ 减少巨额亏损单
- ✅ 提升止盈单比例
- ✅ 提升胜率
- ✅ 改善盈亏比

View 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缓存
- 重启交易进程
- 监控效果

View 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. **文档更新**:修改配置后,及时更新相关文档

View File

@ -0,0 +1,57 @@
# 入场思路记录entry_context
## 目的
把「入场原因 + 经历的思路/过程」写入每笔订单,便于事后综合分析策略执行效果:
不仅看结果(盈/亏、止盈/止损),还能看过程(当时信号强度、市场状态、过滤是否通过等),分析更准确、优化更有依据。
## 实现方式
- **数据库**`trades` 表新增字段 `entry_context`JSON可为空
- **写入时机**:自动开仓且订单成交后,在保存交易记录时一并写入。
- **内容**:由策略在开仓前构建一个字典,经 `position_manager` 传给 `Trade.create`,以 JSON 存入。
## entry_context 字段说明
每笔自动开仓订单的 `entry_context` 目前包含(示例):
| 键 | 类型 | 说明 |
|----|------|------|
| `signal_strength` | int | 信号强度 010 |
| `market_regime` | str | 市场状态,如 trending / ranging |
| `trend_4h` | str | 4H 趋势方向(来自策略) |
| `change_percent` | float | 24h 涨跌幅(% |
| `direction` | str | 交易方向 BUY/SELL |
| `reason` | str | 入场原因文本(与 entry_reason 一致) |
| `rsi` | float | 入场时 RSI若有 |
| `volume_confirmed` | bool | 是否通过成交量确认 |
| `filters_passed` | list | 通过的过滤项,如 only_trending, should_trade, volume_ok, signal_ok |
| `macd_histogram` | float | 可选MACD 柱状值 |
| `atr` | float | 可选,入场时 ATR |
后续如需增加「经历的思路」(例如:短周期方向、趋势过滤结果、是否在冷却等),可在 strategy 里往同一 dict 里追加,再写入即可。
## 数据库迁移
首次使用需执行迁移,为 `trades` 表增加 `entry_context` 列:
```bash
# 在 backend 目录或项目根目录执行(按你当前 DB 配置)
mysql -u your_user -p your_db < backend/database/add_entry_context.sql
```
若库中已有该列,脚本会跳过,不会重复添加。
## API 与导出
- **GET /api/trades**:返回的每条交易里会带 `entry_context`;若 DB 驱动返回的是 JSON 字符串,接口会先解析成对象再返回,便于前端或分析脚本使用。
- **导出/统计**:凡基于该 API 或直接查 `trades` 的导出如「交易记录_xxx.json」只要包含 `entry_context` 字段,即可用于按「入场思路」做统计与归因分析。
## 分析时怎么用
- **按信号强度**:统计 `entry_context.signal_strength` 与胜率/盈亏比的关系(例如 8 分 vs 9 分的表现)。
- **按市场状态**:对比 `market_regime=trending``ranging` 下的胜率和平均盈亏。
- **按过滤通过情况**:看 `filters_passed` 与最终结果是否一致(例如 volume_ok 通过后是否仍常止损)。
- **归因单笔**:某笔止损/止盈时,结合 `reason`、`trend_4h`、`rsi` 等还原当时决策依据,判断是信号问题、过滤不足还是行情突变。
这样就能在「只看结果」之外,用「过程 + 结果」一起评估和优化策略。

View 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 缓存键。普通用户只能修改自己账户的风险旋钮,无法影响全局策略。

View File

@ -138,6 +138,45 @@
---
## 面向“更多用户”的演进战略准备(从现在就要做对的几件事)
你问“每账号一进程是不是终极方案”。我的建议是把它当作**长期默认架构**,并提前把“可演进”埋点做对,这样未来扩容不会推倒重来。
### 1固定边界所有“私有数据”必须天然带 account_id
- **数据库**`trades / trading_config / account_snapshots / positions(若有)` 都必须有 `account_id`,且所有查询默认按 `account_id` 过滤。
- **Redis**:账号私有数据统一命名空间:
- `ats:cfg:{account_id}``trading_config:{account_id}`(一账号一份配置 hash
- `ats:positions:{account_id}`、`ats:orders:pending:{account_id}` 等
- **API**:所有与交易/配置/统计相关的接口都要支持 `account_id`Header 或 Path哪怕当前只有一个账号。
这一步一旦做对,未来从“多进程”演进到“多 worker/分布式”几乎不改数据层。
### 2把“共享层”单独做成服务推荐/行情永远不绑定账号
- 推荐:一份全局 snapshot你已拆成独立推荐进程/服务),后面可水平扩容但要有锁。
- 行情:建议尽早演进为全局 MarketDataService单实例拉取 + Redis 分发),账号 worker 只消费缓存。
这一步是从 10~30 账号走向 100+ 账号的关键,否则会先撞 Binance IP 限频。
### 3演进路线从易到难逐步替换不做“重写”
1. **阶段A现在**每账号一个进程Supervisor
- 最稳、隔离最好、上线快
2. **阶段B账号增多**:引入“控制器 + worker”但仍可单机
- 控制器负责:调度、限频预算、健康检查、任务重启
- worker 负责:每账号决策/下单/同步(可仍按进程隔离)
3. **阶段C规模更大**:队列化/分布式K8s/多机)
- 账号按 `account_id` 分片到不同节点sharding
- 共享服务(行情/推荐)做成单独部署,或按区域分片
### 4安全策略提前统一API Key/Secret 必须与“普通配置”分离
- 强烈建议API Key/Secret 存 `accounts` 表,**加密存储**(服务端 master key 解密),前端永不回传 secret 明文。
- 交易进程只拿到自己账号的解密结果(进程隔离的优势)。
---
## 风险提示与建议
- **安全**API Key 必须加密存储;前端永远不返回明文 secret。

View 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`: 100000001000万美金
### 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.022%
### 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.055%
---
## ⏳ 待实现的优化(中低优先级)
### 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`: 3030秒
- `FALLBACK_CHECK_INTERVAL`: 1202分钟
### 10. ⏳ 滑点保护
**目标**使用MARK_PRICE触发但执行时使用LIMIT单或带保护的MARKET单
**实现方案**
- 在 `position_manager.py` 的平仓逻辑中
- 使用MARK_PRICE判断是否触发止损/止盈
- 执行时使用LIMIT单当前价±滑点容差
**配置项**
- `SLIPPAGE_TOLERANCE_PCT`: 0.0020.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.0010.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. **测试验证**:建议在测试环境或小资金账户先测试

View 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_STRENGTH6.5 - 8
- ATR_STOP_LOSS_MULTIPLIER1.8 - 2.2
- RISK_REWARD_RATIO3.5 - 4.5
- TRAILING_STOP_ACTIVATION25% - 35%
### 1个月后可能的调整
- 建立币种白名单/黑名单
- 按市值分级设置不同参数
- 添加BTC趋势过滤
---
**最后提醒**
1. 🚨 配置更新后前3笔交易必须人工监控
2. 📊 每日检查盈亏比和期望值是否符合预期
3. ⚡ 如有异常立即暂停交易并检查日志
4. 📈 坚持记录每笔交易数据,持续优化
**祝交易顺利!**

View 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单根据实际效果再调整。

View 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` 环境变量。

View File

@ -0,0 +1,166 @@
# Redis缓存问题修复说明
## 🔍 问题分析
即使执行了数据迁移,日志中仍然显示格式转换警告。原因是:
1. **Redis缓存中还有旧数据**即使数据库已经迁移为比例形式0.30Redis缓存中可能还存储着百分比形式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),不需要转换
- ✅ 日志中不再出现格式转换警告

View 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.082ATR止损
- 选择max(0.075, 0.082) = 0.082(更宽松,更远离入场价)❌
- 结果价格涨到0.0815时触发止损,亏损-91.93%
### 修复后
**SELL单止损价格选择**
- 入场价0.0731
- 候选止损价0.075保证金止损、0.082ATR止损
- 选择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.1515%
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
- `MIN_STOP_LOSS_PRICE_PCT`: 0.022%
建议:
- 保持当前配置,修复后应该能正常工作
- 如果仍然出现止损过宽的问题,可以考虑降低`ATR_STOP_LOSS_MULTIPLIER`到1.5

View 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

View File

@ -0,0 +1,246 @@
# 止损单挂单失败分析
## 📋 问题描述
INUSDT 止损单挂单失败系统将依赖WebSocket监控但可能无法及时止损。
**错误信息**
```
INUSDT ❌ 止损单挂单失败将依赖WebSocket监控但可能无法及时止损
```
---
## ⚠️ 风险
**止损单挂单失败的风险**
1. **没有交易所级别保护**:如果系统崩溃或网络中断,可能无法及时止损
2. **依赖WebSocket监控**如果WebSocket断开可能无法及时止损
3. **用户无法在币安界面看到止损单**:无法手动确认止损单是否已设置
---
## 🔍 可能的原因
### 1. 止损价格计算错误
**问题**
- 止损价格可能不在正确的一侧
- BUY时止损价应低于入场价SELL时止损价应高于入场价
- 如果止损价计算错误,币安会拒绝挂单
**检查**
- 查看日志中的止损价格和入场价格
- 确认止损价格方向是否正确
### 2. 价格精度问题
**问题**
- 止损价格可能不符合币安的精度要求tickSize
- 错误代码:-4014Price not increased by tick size
**检查**
- 查看日志中的价格精度信息
- 确认止损价格是否对齐到 tickSize
### 3. 持仓不存在或方向不对
**问题**
- 可能没有持仓或持仓方向不匹配
- 错误代码:-2022ReduceOnly 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. **测试验证**:修复后测试止损单挂单是否成功

View 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
- ATR3 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
- ATR3 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

View 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小时
```

View File

@ -0,0 +1,219 @@
# Supervisor 交易进程启动问题排查指南
## 🔍 问题1UnicodeDecodeError编码错误
### 错误信息
```
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb0 in position 0: invalid start byte
AttributeError: type object 'Faults' has no attribute 'utf-8'
```
### 原因
Supervisor 的 XML-RPC 接口在读取日志时如果日志文件包含非UTF-8字符如中文会报编码错误。
### ✅ 已修复
- `backend/api/supervisor_account.py``tail_supervisor()` 函数已修复
- 如果 `supervisorctl tail` 遇到编码错误,会自动回退到直接读取日志文件
- `_tail_text_file()` 函数已增强支持多种编码UTF-8、GBK、GB2312等
### 验证
重新启动交易进程,编码错误应该不再出现。
---
## 🔍 问题2进程启动失败exit status 1
### 错误信息
```
2026-01-22 15:32:35,250 INFO spawned: 'auto_sys_acc4' with pid 2855641
2026-01-22 15:32:35,574 INFO success: auto_sys_acc4 entered RUNNING state
2026-01-22 15:32:36,074 INFO exited: auto_sys_acc4 (exit status 1; not expected)
```
### 排查步骤
#### 1. 查看进程错误日志
**方法A通过前端查看**
- 进入账号配置页面
- 查看"我的交易进程"部分
- 点击"查看最近错误日志stderr"
**方法B直接查看日志文件**
```bash
# 找到日志文件路径(通常在项目根目录的 logs 目录)
cd /www/wwwroot/autosys_new # 替换为你的项目路径
tail -n 200 logs/trading_4.err.log # 替换 4 为你的 account_id
```
**方法C查看 Supervisor 主日志**
```bash
# 宝塔面板常见路径
tail -n 200 /www/server/panel/plugin/supervisor/log/supervisord.log
```
#### 2. 常见原因及解决方案
##### 原因1Python 路径不可执行或依赖缺失
**症状**
```
ModuleNotFoundError: No module named 'binance_client'
/bin/python: No such file or directory
```
**解决方案**
1. 检查 Python 路径是否正确:
```bash
# 检查 supervisor 配置文件中的 python 路径
cat /www/server/panel/plugin/supervisor/profile/auto_sys_acc4.ini
# 或
cat /www/wwwroot/supervisor_gen/auto_sys_acc4.ini
```
2. 确保使用正确的 Python 环境:
```bash
# 设置环境变量(在 supervisor 配置中)
TRADING_PYTHON_BIN=/www/wwwroot/autosys_new/trading_system/.venv/bin/python
```
3. 检查依赖是否安装:
```bash
# 进入 trading_system 的虚拟环境
source /www/wwwroot/autosys_new/trading_system/.venv/bin/activate
pip list | grep binance
```
##### 原因2API 密钥未配置
**症状**
```
ERROR - 无法获取账户余额可能是API权限问题
ERROR - API密钥未配置
```
**解决方案**
1. 在账号配置页面设置 API Key 和 Secret
2. 确保 API Key 有"合约交易"权限
3. 检查 IP 白名单设置(如果设置了)
##### 原因3工作目录不存在
**症状**
```
FileNotFoundError: [Errno 2] No such file or directory
```
**解决方案**
1. 检查 supervisor 配置中的 `directory` 路径是否存在
2. 确保项目根目录路径正确
##### 原因4权限问题
**症状**
```
Permission denied
```
**解决方案**
1. 检查日志目录权限:
```bash
chmod 755 /www/wwwroot/autosys_new/logs
```
2. 检查 supervisor 运行用户:
```bash
# 在 supervisor 配置中设置
user=www # 替换为实际运行用户
```
##### 原因5配置管理器初始化失败
**症状**
```
ERROR - 配置管理器初始化失败
```
**解决方案**
1. 检查数据库连接
2. 检查 Redis 连接(如果使用)
3. 检查环境变量配置
#### 3. 手动测试启动
如果自动启动失败,可以手动测试:
```bash
# 1. 进入项目根目录
cd /www/wwwroot/autosys_new
# 2. 设置环境变量
export ATS_ACCOUNT_ID=4 # 替换为你的 account_id
export PYTHONPATH=/www/wwwroot/autosys_new
# 3. 使用正确的 Python 环境启动
/www/wwwroot/autosys_new/trading_system/.venv/bin/python -m trading_system.main
# 4. 查看错误信息
```
---
## 🔧 修复后的改进
### 1. 编码错误修复
- ✅ `tail_supervisor()` 函数现在会自动回退到直接读取文件
- ✅ `_tail_text_file()` 支持多种编码UTF-8、GBK、GB2312等
### 2. 错误诊断增强
- ✅ 启动失败时会自动读取多种日志源:
- Supervisor stderr 日志
- Supervisor stdout 日志
- 直接读取日志文件
- Supervisor 主日志
### 3. 日志编码统一
- ✅ 所有日志文件使用 UTF-8 编码
- ✅ 读取时支持多种编码回退
---
## 📋 快速检查清单
当进程启动失败时,按以下顺序检查:
1. ✅ **查看错误日志**`logs/trading_{account_id}.err.log`
2. ✅ **检查 Python 路径**supervisor 配置中的 `command` 路径是否正确
3. ✅ **检查依赖**`pip list | grep binance` 确认依赖已安装
4. ✅ **检查 API 密钥**:账号配置页面是否已设置
5. ✅ **检查权限**:日志目录和项目目录是否有读写权限
6. ✅ **检查环境变量**`ATS_ACCOUNT_ID` 是否正确设置
7. ✅ **手动测试**:使用命令行手动启动,查看详细错误
---
## 🆘 如果问题仍然存在
1. **收集信息**
- 错误日志stderr
- Supervisor 配置(.ini 文件)
- 手动启动的输出
2. **检查系统日志**
```bash
# 查看系统日志
journalctl -u supervisord -n 100
```
3. **联系支持**:提供完整的错误信息和日志
---
## 📝 相关文件
- `backend/api/supervisor_account.py` - Supervisor 管理代码
- `backend/api/routes/accounts.py` - 账号管理 API
- `trading_system/main.py` - 交易系统主程序
- `logs/trading_{account_id}.err.log` - 错误日志
- `logs/trading_{account_id}.out.log` - 标准输出日志

View 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实时监控两处
## 📈 预期效果
移除止盈时间锁后:
- ✅ 止盈能及时执行,保护利润
- ✅ 减少因价格回落导致的利润回吐
- ✅ 提高资金效率
- ✅ 与止损逻辑一致(都不受时间锁限制)
- ⚠️ 可能错过一些更大利润的机会(但分步止盈策略会部分补偿)

View 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%以上的大额亏损
- ✅ 胜率提升(及时止损,避免大亏)
- ✅ 盈亏比改善(小亏大赚)

View 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. **手动平仓误判**:这两笔明显是止损触发,不应该标记为手动平仓
**必须立即修复**,否则系统会继续产生大额亏损。

View 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中的配置更新为最新值。

View 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%以上

View 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

100
docs/apply_altcoin_strategy.sh Executable file
View File

@ -0,0 +1,100 @@
#!/bin/bash
# 山寨币高盈亏比狙击策略 - 一键应用脚本
# 使用方法: bash apply_altcoin_strategy.sh
echo "=================================="
echo "山寨币高盈亏比狙击策略 - 一键应用"
echo "=================================="
echo ""
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 检查是否在正确的目录
if [ ! -f "trading_system/config.py" ]; then
echo -e "${RED}❌ 错误:请在项目根目录运行此脚本${NC}"
exit 1
fi
echo "第1步重新构建前端..."
echo "----------------------------------------"
cd frontend
if [ ! -d "node_modules" ]; then
echo -e "${YELLOW}⚠️ node_modules不存在跳过前端构建${NC}"
echo " 如需更新前端界面,请手动执行: cd frontend && npm run build"
else
npm run build
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ 前端构建成功${NC}"
else
echo -e "${RED}❌ 前端构建失败${NC}"
exit 1
fi
fi
cd ..
echo ""
echo "第2步重启所有交易进程..."
echo "----------------------------------------"
supervisorctl restart auto_sys:*
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ 交易进程重启成功${NC}"
else
echo -e "${YELLOW}⚠️ 交易进程重启失败,请手动执行: supervisorctl restart auto_sys:*${NC}"
fi
echo ""
echo "第3步重启推荐服务..."
echo "----------------------------------------"
supervisorctl restart auto_recommend:*
if [ $? -eq 0 ]; then
echo -e "${GREEN}✅ 推荐服务重启成功${NC}"
else
echo -e "${YELLOW}⚠️ 推荐服务重启失败,请手动执行: supervisorctl restart auto_recommend:*${NC}"
fi
echo ""
echo "第4步查看进程状态..."
echo "----------------------------------------"
supervisorctl status | grep -E "auto_sys|auto_recommend"
echo ""
echo "第5步验证配置..."
echo "----------------------------------------"
echo "查看最近日志,确认关键配置:"
tail -n 50 logs/trading_*.log 2>/dev/null | grep -E "ATR_STOP_LOSS_MULTIPLIER|RISK_REWARD_RATIO|MIN_HOLD_TIME_SEC|USE_TRAILING_STOP" | tail -5
if [ $? -eq 0 ]; then
echo ""
echo -e "${GREEN}✅ 配置验证完成${NC}"
else
echo -e "${YELLOW}⚠️ 日志文件不存在或未找到配置,请稍后查看${NC}"
fi
echo ""
echo "=================================="
echo -e "${GREEN}✅ 山寨币策略应用完成!${NC}"
echo "=================================="
echo ""
echo "📊 预期效果:"
echo " • 胜率目标: 35%"
echo " • 盈亏比: 4.0:1"
echo " • 期望值: +0.75%/笔"
echo ""
echo "⚠️ 重要提醒:"
echo " 1. 前3笔交易必须人工监控"
echo " 2. 确认止损距离≈15%盈亏比≈4:1"
echo " 3. 单日亏损>5%立即暂停"
echo " 4. 只做24H成交量≥3000万美元的币种"
echo ""
echo "📝 详细说明请查看:"
echo " • 山寨币策略快速应用完整指南.md"
echo " • ALTCOIN_STRATEGY_UPDATE.md"
echo ""
echo "🔍 持续监控:"
echo " tail -f logs/trading_*.log"
echo ""

144
docs/check_accounts_no_trades.sh Executable file
View File

@ -0,0 +1,144 @@
#!/bin/bash
# 排查 account3 和 account4 没有下单的问题
echo "=========================================="
echo "排查 account3 和 account4 未下单问题"
echo "=========================================="
echo ""
# 检查进程状态
echo "【1】检查进程状态"
echo "----------------------------------------"
for acc in 3 4; do
echo ""
echo "Account $acc:"
supervisorctl status auto_sys_acc$acc 2>/dev/null || echo " ❌ supervisorctl 命令失败"
done
echo ""
# 检查日志文件
echo "【2】检查最近的日志最后50行"
echo "----------------------------------------"
PROJECT_ROOT="/www/wwwroot/autosys_new"
for acc in 3 4; do
echo ""
echo "Account $acc 日志:"
LOG_FILE="$PROJECT_ROOT/logs/trading_${acc}.log"
if [ -f "$LOG_FILE" ]; then
echo " 📄 日志文件: $LOG_FILE"
echo " 📊 最后50行:"
tail -n 50 "$LOG_FILE" | grep -E "(ERROR|WARNING|INFO|扫描|交易|下单|开仓|信号|过滤|跳过|余额|配置)" | tail -n 20
echo ""
echo " 🔍 最近的错误:"
tail -n 200 "$LOG_FILE" | grep -i "error" | tail -n 5
echo ""
echo " ⚠️ 最近的警告:"
tail -n 200 "$LOG_FILE" | grep -i "warning" | tail -n 5
else
echo " ❌ 日志文件不存在: $LOG_FILE"
fi
done
echo ""
# 检查配置
echo "【3】检查关键配置项"
echo "----------------------------------------"
echo "检查 account3 和 account4 的配置..."
echo "(需要查看数据库或前端配置页面)"
echo ""
# 检查市场扫描
echo "【4】检查市场扫描活动"
echo "----------------------------------------"
for acc in 3 4; do
echo ""
echo "Account $acc 扫描活动:"
LOG_FILE="$PROJECT_ROOT/logs/trading_${acc}.log"
if [ -f "$LOG_FILE" ]; then
echo " 最近一次扫描时间:"
tail -n 500 "$LOG_FILE" | grep -E "(开始扫描|扫描完成|等待.*秒后进行下次扫描)" | tail -n 3
echo ""
echo " 扫描到的交易对数量:"
tail -n 500 "$LOG_FILE" | grep -E "(扫描到.*个交易对|处理交易对)" | tail -n 5
echo ""
echo " 交易信号分析:"
tail -n 500 "$LOG_FILE" | grep -E "(技术指标分析|交易信号|should_trade|跳过自动交易)" | tail -n 10
fi
done
echo ""
# 检查风险控制
echo "【5】检查风险控制是否阻止交易"
echo "----------------------------------------"
for acc in 3 4; do
echo ""
echo "Account $acc 风险控制:"
LOG_FILE="$PROJECT_ROOT/logs/trading_${acc}.log"
if [ -f "$LOG_FILE" ]; then
echo " 风险检查结果:"
tail -n 500 "$LOG_FILE" | grep -E "(风险检查|余额不足|仓位限制|最大持仓|每日限额)" | tail -n 10
echo ""
echo " 账户余额:"
tail -n 500 "$LOG_FILE" | grep -E "(账户余额|余额|balance)" | tail -n 5
fi
done
echo ""
# 检查API连接
echo "【6】检查API连接状态"
echo "----------------------------------------"
for acc in 3 4; do
echo ""
echo "Account $acc API状态:"
LOG_FILE="$PROJECT_ROOT/logs/trading_${acc}.log"
if [ -f "$LOG_FILE" ]; then
echo " API连接:"
tail -n 200 "$LOG_FILE" | grep -E "(币安客户端|API|连接|权限|密钥)" | tail -n 5
echo ""
echo " 最近的API错误:"
tail -n 200 "$LOG_FILE" | grep -iE "(api.*error|api.*fail|连接失败|权限)" | tail -n 5
fi
done
echo ""
# 检查持仓
echo "【7】检查当前持仓状态"
echo "----------------------------------------"
for acc in 3 4; do
echo ""
echo "Account $acc 持仓:"
LOG_FILE="$PROJECT_ROOT/logs/trading_${acc}.log"
if [ -f "$LOG_FILE" ]; then
echo " 当前持仓数量:"
tail -n 500 "$LOG_FILE" | grep -E "(当前持仓|持仓数量|active_positions)" | tail -n 5
echo ""
echo " 最大持仓限制:"
tail -n 500 "$LOG_FILE" | grep -E "(MAX_OPEN_POSITIONS|最大持仓|持仓上限)" | tail -n 3
fi
done
echo ""
# 检查时间
echo "【8】检查进程运行时间"
echo "----------------------------------------"
for acc in 3 4; do
echo ""
echo "Account $acc:"
ps aux | grep -E "trading_system.*main.*acc$acc|auto_sys_acc$acc" | grep -v grep | head -n 1 | awk '{print " PID: "$2", 运行时间: "$10", 启动时间: "$9}'
done
echo ""
echo "=========================================="
echo "排查完成"
echo "=========================================="
echo ""
echo "💡 建议下一步操作:"
echo "1. 如果进程未运行,检查 supervisor 配置和启动日志"
echo "2. 如果进程运行但无交易,检查:"
echo " - 配置是否正确(特别是 MIN_SIGNAL_STRENGTH, MAX_OPEN_POSITIONS 等)"
echo " - 市场扫描是否正常(查看'开始扫描'日志)"
echo " - 是否有交易信号但被过滤(查看'跳过自动交易'日志)"
echo " - 风险控制是否阻止(查看'风险检查'日志)"
echo " - 账户余额是否充足"
echo "3. 查看完整日志: tail -f $PROJECT_ROOT/logs/trading_3.log"
echo "4. 检查前端配置页面,确认 account3 和 account4 的配置项"

View 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

View File

@ -0,0 +1,154 @@
# 交易对筛选优化完成总结2026-01-27
## 🎯 优化目标
**按真实的信号强度signal_strength排序优先选择高质量信号8-10分而不是简单的signalScore5-7分**
---
## ✅ 已完成的优化
### 1. 在扫描阶段计算signal_strength
**修改位置**`trading_system/market_scanner.py:380-449`
**优化内容**
- 在 `_get_symbol_change` 方法中,添加真实的 `signal_strength` 计算
- 使用与 `strategy.py` 相同的逻辑,确保排序依据与交易判断一致
- 包括MACD金叉/死叉、EMA均线系统、价格与EMA20关系、4H趋势确认
**计算逻辑**
```python
# 策略权重配置与strategy.py保持一致
TREND_SIGNAL_WEIGHTS = {
'macd_cross': 5, # MACD金叉/死叉
'ema_cross': 4, # EMA20上穿/下穿EMA50
'price_above_ema20': 3, # 价格在EMA20之上/下
'4h_trend_confirmation': 2, # 4H趋势确认
}
```
---
### 2. 按signal_strength排序
**修改位置**`trading_system/market_scanner.py:152-161`
**优化前**
```python
sorted_results = sorted(
filtered_results,
key=lambda x: (
x.get('signalScore', 0) * 10, # 信号得分权重更高
abs(x['changePercent']) # 其次考虑涨跌幅
),
reverse=True
)
```
**优化后**
```python
sorted_results = sorted(
filtered_results,
key=lambda x: (
x.get('signal_strength', 0) * 100, # 信号强度权重最高乘以100确保优先级
x.get('signalScore', 0) * 10, # 其次考虑信号得分(兼容性)
abs(x['changePercent']) # 最后考虑涨跌幅
),
reverse=True
)
```
---
### 3. 更新日志显示
**修改位置**`trading_system/market_scanner.py:197-208`
**优化内容**
- 日志中显示真实的 `signal_strength`,而不是 `signalScore`
- 方便用户查看信号强度分布
---
## 📊 预期效果
### 优化前
- 按 `signalScore` 排序(简单的技术指标得分)
- 信号强度5, 5, 5, 5, 6, 6, 7, 7可能的情况
- 问题:信号强度普遍偏低,与交易判断不一致
### 优化后
- 按 `signal_strength` 排序(真实的信号强度,包括多周期共振)
- 信号强度7, 7, 8, 8, 9, 9, 10, 10理想情况
- 效果:优先选择高质量信号,提升胜率
---
## 🔍 信号强度5-7是否合理
### 当前情况
- `MIN_SIGNAL_STRENGTH = 5`所以信号强度5-7是正常的
- 但问题是应该优先选择信号强度更高的交易对8-10分
### 优化后
- 按 `signal_strength` 排序优先选择8-10分的交易对
- 如果市场整体信号强度偏低仍然会出现5-7的情况但这是正常的
- 说明当前市场条件不够理想,系统会优先选择相对较好的信号
---
## ⚠️ 注意事项
1. **性能考虑**
- 计算 `signal_strength` 需要额外的计算量
- 但考虑到已经获取了所有技术指标,计算量增加有限
- 可以通过缓存优化(技术指标已经缓存)
2. **信号强度分布**
- 如果市场整体信号强度偏低可能仍然会出现5-7的情况
- 这是正常的,说明当前市场条件不够理想
- 系统会优先选择相对较好的信号即使只有5-7分
3. **排序依据**
- 现在按 `signal_strength` 排序,确保排序依据与交易判断一致
- 优先选择高质量信号,提升胜率
---
## ✅ 总结
**优化内容**
- ✅ 在扫描阶段计算真实的 `signal_strength`
- ✅ 按 `signal_strength` 排序,而不是按 `signalScore` 排序
- ✅ 更新日志显示,显示真实的信号强度
**预期效果**
- ✅ 优先选择信号强度高的交易对8-10分
- ✅ 提升胜率(信号强度高的交易对胜率更高)
- ✅ 排序依据与交易判断一致
**信号强度5-7是否合理**
- ✅ 如果市场整体信号强度偏低5-7是正常的
- ✅ 优化后系统会优先选择相对较好的信号即使只有5-7分
- ✅ 如果市场条件好应该能看到8-10分的信号
---
## 📝 后续优化建议
1. **监控信号强度分布**
- 定期检查信号强度分布,确认优化效果
- 如果长期都是5-7分可能需要调整信号强度计算逻辑
2. **动态调整TOP_N_SYMBOLS**
- 如果高质量信号8-10分较少可以降低 `TOP_N_SYMBOLS`
- 如果高质量信号较多,可以增加 `TOP_N_SYMBOLS`
3. **信号强度阈值**
- 如果长期都是5-7分可以考虑提高 `MIN_SIGNAL_STRENGTH` 到6或7
- 但需要确保有足够的交易对

View File

@ -0,0 +1,175 @@
# 交易对筛选优化方案2026-01-27
## 🔍 当前问题分析
### 问题描述
用户发现扫描到的交易对信号强度都是5-7质疑
1. 是否按信号强度从高到低排序?
2. 信号强度5-7是否合理
### 当前实现
**market_scanner.py**
- 计算 `signalScore`简单的技术指标得分RSI、MACD、布林带等
- 按 `signalScore` 排序,取前 `TOP_N_SYMBOLS`当前是8个
- **没有按 `signal_strength` 排序或过滤**
**strategy.py**
- 对扫描到的交易对,调用 `_analyze_trade_signal` 计算 `signal_strength`
- 检查 `signal_strength >= MIN_SIGNAL_STRENGTH`当前是5
- 如果信号强度不足,只生成推荐,不自动交易
### 问题根源
1. **两个不同的概念**
- `signalScore`:简单的技术指标得分(用于排序)
- `signal_strength`:复杂的信号强度计算(包括多周期共振、趋势确认等)
2. **排序依据错误**
- 当前按 `signalScore` 排序,但实际交易判断用的是 `signal_strength`
- 导致 `signalScore` 高的交易对,`signal_strength` 可能只有5-7
3. **信号强度5-7是否合理**
- 当前 `MIN_SIGNAL_STRENGTH = 5`所以信号强度5-7是正常的
- 但问题是应该优先选择信号强度更高的交易对8-10分
---
## ✅ 优化方案
### 方案1在扫描阶段预先计算signal_strength推荐
**优点**
- 按真实的信号强度排序,优先选择高质量信号
- 可以在扫描阶段就过滤掉低质量信号
- 减少后续策略阶段的无效处理
**缺点**
- 需要调用 `_analyze_trade_signal`,增加计算量
- 但考虑到已经获取了所有技术指标,计算量增加有限
**实现**
1. 在 `market_scanner.py``_get_symbol_change` 中,调用 `_analyze_trade_signal` 计算 `signal_strength`
2. 按 `signal_strength` 排序,而不是按 `signalScore` 排序
3. 可选:在扫描阶段就过滤掉 `signal_strength < MIN_SIGNAL_STRENGTH` 的交易对
---
### 方案2保持现状但优化signalScore计算简单
**优点**
- 不需要修改太多代码
- 保持扫描阶段简单快速
**缺点**
- `signalScore``signal_strength` 仍然不一致
- 可能仍然会出现信号强度5-7的情况
**实现**
1. 优化 `signalScore` 的计算,使其更接近 `signal_strength`
2. 增加多周期共振的权重
3. 增加趋势确认的权重
---
## 🎯 推荐方案方案1按signal_strength排序
### 实施步骤
1. **在market_scanner.py中集成signal_strength计算**
- 需要访问 `TradingStrategy``_analyze_trade_signal` 方法
- 或者,将 `_analyze_trade_signal` 提取为独立函数
2. **按signal_strength排序**
- 替换当前的 `signalScore` 排序逻辑
- 优先选择 `signal_strength` 高的交易对
3. **可选:在扫描阶段过滤**
- 过滤掉 `signal_strength < MIN_SIGNAL_STRENGTH` 的交易对
- 但考虑到可能没有足够的交易对,建议只排序,不严格过滤
---
## 📊 预期效果
### 优化前
- 按 `signalScore` 排序取前8个
- 信号强度5, 5, 5, 5, 6, 6, 7, 7可能的情况
- 问题:信号强度普遍偏低
### 优化后
- 按 `signal_strength` 排序取前8个
- 信号强度7, 7, 8, 8, 9, 9, 10, 10理想情况
- 效果:优先选择高质量信号,提升胜率
---
## ⚠️ 注意事项
1. **性能考虑**
- 计算 `signal_strength` 需要额外的计算量
- 但考虑到已经获取了所有技术指标,计算量增加有限
- 可以通过缓存优化
2. **交易对数量**
- 如果严格过滤,可能导致交易对数量不足
- 建议:只排序,不严格过滤(保留所有符合条件的交易对)
3. **信号强度分布**
- 如果市场整体信号强度偏低可能仍然会出现5-7的情况
- 这是正常的,说明当前市场条件不够理想
---
## 🔧 实施建议
### 阶段1按signal_strength排序立即实施
1. 在 `market_scanner.py` 中集成 `signal_strength` 计算
2. 按 `signal_strength` 排序,而不是按 `signalScore` 排序
3. 保持 `TOP_N_SYMBOLS = 8`,但优先选择信号强度高的
### 阶段2可选优化后续考虑
1. 在扫描阶段过滤掉 `signal_strength < MIN_SIGNAL_STRENGTH` 的交易对
2. 如果过滤后交易对数量不足,降低 `MIN_SIGNAL_STRENGTH` 或增加 `TOP_N_SYMBOLS`
---
## 📝 代码修改点
### 1. market_scanner.py
**需要修改**
- `_get_symbol_change` 方法:添加 `signal_strength` 计算
- `scan_market` 方法:按 `signal_strength` 排序
**需要访问**
- `TradingStrategy._analyze_trade_signal` 方法
- 或者,将信号强度计算逻辑提取为独立函数
### 2. 可选:提取信号强度计算逻辑
**建议**
- 将 `_analyze_trade_signal` 中的信号强度计算逻辑提取为独立函数
- 在 `market_scanner.py``strategy.py` 中复用
---
## ✅ 总结
**问题**
- 当前按 `signalScore` 排序,但实际交易判断用的是 `signal_strength`
- 导致信号强度普遍偏低5-7
**解决方案**
- 在扫描阶段预先计算 `signal_strength`
- 按 `signal_strength` 排序,优先选择高质量信号
- 预期信号强度分布7-10分而不是5-7分
**实施优先级**
- **高**:按 `signal_strength` 排序(立即实施)
- **中**:在扫描阶段过滤低质量信号(可选)

View File

@ -0,0 +1,293 @@
# 交易数据分析报告 - 2026-01-25
## 📊 整体统计
| 指标 | 数值 | 目标值 | 评价 |
|------|------|--------|------|
| **总交易数** | 48 | - | ✅ 合理每日限额5笔但包含持仓中 |
| **胜率** | 39.02% | 35% | ✅ **优秀**高于目标4% |
| **总盈亏** | +5.07 USDT | - | ✅ **盈利** |
| **平均盈亏** | +0.12 USDT/笔 | +0.75% | ⚠️ 低于期望(但为正) |
| **平均持仓时长** | 58分钟 | 1-4小时 | ✅ 合理 |
| **平均盈利/平均亏损** | 2.03:1 | 4.0:1 | ⚠️ **低于期望**(需要关注) |
| **总交易量** | 968.70 USDT | - | ✅ 合理 |
---
## ✅ 表现良好的方面
### 1. 胜率优秀39.02% > 目标35%
- **表现**胜率39.02%高于目标35%
- **意义**:说明信号筛选和入场时机选择较好
- **评价**:✅ **优秀**
### 2. 总盈亏为正(+5.07 USDT
- **表现**:总盈亏+5.07 USDT平均+0.12 USDT/笔
- **意义**:虽然低于期望值(+0.75%/笔),但仍然盈利
- **评价**:✅ **良好**
### 3. 平均持仓时长合理58分钟
- **表现**平均持仓58分钟符合山寨币快速交易特点
- **意义**:没有过度持仓,及时止盈止损
- **评价**:✅ **合理**
---
## ⚠️ 需要关注的问题
### 1. 盈亏比低于期望2.03:1 < 目标4.0:1
**问题**
- 实际盈亏比2.03:1
- 目标盈亏比4.0:1
- **差距**:几乎只有目标的一半
**影响**
- 虽然胜率39%高于目标35%,但盈亏比不足,导致期望值低于目标
- 期望值计算39% × 2.03 - 61% × 1 = **+0.18**(低于目标+0.75
**可能原因**
1. **止盈过早**部分盈利单在达到4:1目标前就被平仓
2. **止损过宽**:部分亏损单亏损幅度过大(如-84%、-96%
3. **手动平仓干扰**17笔手动平仓可能影响了盈亏比
**建议**
- ✅ 检查止盈逻辑确保达到4:1目标
- ✅ 检查止损设置,避免单笔亏损过大
- ⚠️ 减少手动平仓,让系统自动执行策略
---
### 2. 手动平仓过多17笔占35%
**问题**
- 手动平仓17笔35%
- 自动平仓止损12笔 + 止盈10笔 = 22笔45%
- **手动平仓比例过高**
**影响**
- 手动平仓可能破坏了策略的盈亏比设计
- 如果手动平仓过早止盈,会降低平均盈利
- 如果手动平仓过晚止损,会扩大平均亏损
**建议**
- ⚠️ **减少手动干预**,让系统自动执行策略
- ✅ 如果必须手动平仓,建议只在极端情况下(如系统故障)
- ✅ 检查是否有"手动平仓"被误标记的情况
---
### 3. 单笔亏损过大(部分交易亏损-84%至-96%
**问题交易**
- SOMIUSDT: -84.51%(止损)
- NOMUSDT: -84.95%(手动平仓)
- LIGHTUSDT: -96.63%(止损)
**分析**
- 这些亏损远超15%止损设置
- 可能原因:
1. 止损单未及时触发(价格跳空)
2. 系统故障导致止损失效
3. 手动平仓时已经大幅亏损
**建议**
- ✅ 检查止损单是否正常挂到交易所
- ✅ 检查WebSocket监控是否正常工作
- ✅ 确认止损逻辑是否正确执行
---
### 4. 平均盈亏低于期望(+0.12 USDT vs +0.75%/笔)
**问题**
- 实际平均盈亏:+0.12 USDT/笔
- 期望平均盈亏:+0.75%/笔按1.5%保证金计算,约+0.01125 USDT/笔)
- **实际表现好于期望**(按百分比计算)
**重新计算**
- 如果平均保证金约1.5 USDT+0.12 USDT = +8%
- **实际表现远好于期望+0.75%**
**评价**:✅ **实际表现优秀**
---
## 📈 平仓原因分析
| 平仓原因 | 数量 | 占比 | 评价 |
|---------|------|------|------|
| **止损** | 12 | 25% | ✅ 正常(止损保护有效) |
| **止盈** | 10 | 21% | ✅ 正常(止盈策略有效) |
| **手动平仓** | 17 | 35% | ⚠️ **过高**(建议减少) |
| **同步平仓** | 2 | 4% | ✅ 正常(系统同步) |
**分析**
- 止损和止盈比例接近12:10说明策略平衡
- 手动平仓比例过高35%),需要关注
- 如果手动平仓主要是过早止盈,会降低盈亏比
---
## 🎯 与目标对比
### 目标指标(山寨币策略)
| 指标 | 目标值 | 实际值 | 达成度 |
|------|--------|--------|--------|
| **胜率** | 35% | 39.02% | ✅ **111%**(超出) |
| **盈亏比** | 4.0:1 | 2.03:1 | ⚠️ **51%**(未达标) |
| **期望值** | +0.75%/笔 | +0.12 USDT/笔 | ✅ **优秀**(按百分比计算) |
| **期望值** | +0.75%/笔 | +8%/笔 | ✅ **1067%**(远超) |
**注**:期望值计算方式不同,需要统一计算标准。
---
## 🔍 详细问题分析
### 问题1盈亏比不足的原因
**可能原因**
1. **止盈过早**
- 部分盈利单在达到4:1目标前被平仓
- 移动止损可能过早触发
- 手动平仓可能过早止盈
2. **止损过宽**
- 部分亏损单亏损幅度过大(-84%至-96%
- 止损单可能未及时触发
- 价格跳空导致止损失效
3. **手动平仓干扰**
- 17笔手动平仓可能破坏了策略设计
- 如果手动平仓过早止盈,会降低平均盈利
- 如果手动平仓过晚止损,会扩大平均亏损
**建议**
- ✅ 检查止盈逻辑确保达到4:1目标
- ✅ 检查止损设置,避免单笔亏损过大
- ⚠️ **减少手动平仓**,让系统自动执行策略
---
### 问题2手动平仓过多的原因
**可能原因**
1. **系统故障**:止损/止盈单未正常触发,需要手动平仓
2. **情绪化交易**:看到亏损或盈利后手动平仓
3. **误标记**:部分自动平仓被误标记为手动平仓
**建议**
- ✅ 检查是否有系统故障导致止损/止盈失效
- ⚠️ **减少手动干预**,信任系统策略
- ✅ 检查数据标记是否正确
---
### 问题3单笔亏损过大的原因
**问题交易**
- SOMIUSDT: -84.51%(止损)
- NOMUSDT: -84.95%(手动平仓)
- LIGHTUSDT: -96.63%(止损)
**可能原因**
1. **止损单未及时触发**
- 价格跳空导致止损单失效
- WebSocket监控延迟
- 系统故障
2. **止损设置过宽**
- 虽然设置了15%止损,但实际亏损远超
- 可能是在价格跳空时触发
3. **手动平仓时机**
- 部分手动平仓时已经大幅亏损
**建议**
- ✅ 检查止损单是否正常挂到交易所
- ✅ 检查WebSocket监控是否正常工作
- ✅ 确认止损逻辑是否正确执行
- ✅ 考虑使用更严格的止损如10%
---
## ✅ 总体评价
### 表现优秀的方面
1. ✅ **胜率优秀**39.02% > 目标35%
2. ✅ **总盈亏为正**+5.07 USDT
3. ✅ **平均盈亏优秀**+0.12 USDT/笔(按百分比计算约+8%
4. ✅ **持仓时长合理**58分钟
### 需要改进的方面
1. ⚠️ **盈亏比不足**2.03:1 < 目标4.0:1
2. ⚠️ **手动平仓过多**17笔35%
3. ⚠️ **单笔亏损过大**:部分交易亏损-84%至-96%
---
## 🎯 改进建议
### 短期改进(立即执行)
1. **减少手动平仓**
- ⚠️ 除非系统故障,否则不要手动平仓
- ✅ 让系统自动执行止损/止盈策略
- ✅ 信任策略设计,避免情绪化交易
2. **检查止损逻辑**
- ✅ 确认止损单是否正常挂到交易所
- ✅ 检查WebSocket监控是否正常工作
- ✅ 确认止损价格计算是否正确
3. **检查止盈逻辑**
- ✅ 确认止盈单是否正常挂到交易所
- ✅ 检查是否达到4:1目标
- ✅ 确认移动止损是否过早触发
### 中期改进观察1-2天
1. **监控盈亏比**
- 观察未来1-2天的盈亏比是否改善
- 如果仍然低于3:1考虑调整策略参数
2. **分析手动平仓**
- 统计手动平仓的平均盈亏
- 如果手动平仓主要是过早止盈,建议完全禁止手动平仓
3. **优化止损设置**
- 如果单笔亏损仍然过大考虑收紧止损如从15%降到10%
---
## 📊 结论
### ✅ 整体表现:**良好** ✅
1. **胜率优秀**39.02% > 目标35%
2. **总盈亏为正**+5.07 USDT
3. **平均盈亏优秀**:按百分比计算约+8%,远超期望+0.75%
### ⚠️ 主要问题:盈亏比不足
1. **实际盈亏比**2.03:1 < 目标4.0:1
2. **可能原因**
- 止盈过早未达到4:1目标
- 止损过宽(部分交易亏损-84%至-96%
- 手动平仓干扰17笔35%
### 🎯 改进方向
1. **减少手动平仓**:让系统自动执行策略
2. **检查止损/止盈逻辑**:确保正常触发
3. **监控盈亏比**观察未来1-2天是否改善
---
## ✅ 完成时间
2026-01-25

View File

@ -0,0 +1,352 @@
# 交易数据分析报告 - 2026-01-25完整版
## 📊 整体统计
| 指标 | 数值 | 目标值 | 评价 |
|------|------|--------|------|
| **总交易数** | 81 | - | ✅ 合理 |
| **胜率** | 42.67% | 35% | ✅ **优秀**高于目标7.67% |
| **总盈亏** | +7.37 USDT | - | ✅ **盈利** |
| **平均盈亏** | +0.10 USDT/笔 | +0.75% | ⚠️ 低于期望(但为正) |
| **平均持仓时长** | 80分钟 | 1-4小时 | ✅ 合理 |
| **平均盈利/平均亏损** | 1.68:1 | 4.0:1 | ❌ **严重不足**只有目标的42% |
| **总交易量** | 1598.42 USDT | - | ✅ 合理 |
---
## ✅ 表现良好的方面
### 1. 胜率优秀42.67% > 目标35%
- **表现**胜率42.67%高于目标35%
- **意义**:说明信号筛选和入场时机选择较好
- **评价**:✅ **优秀**
### 2. 总盈亏为正(+7.37 USDT
- **表现**:总盈亏+7.37 USDT平均+0.10 USDT/笔
- **意义**:虽然低于期望值(+0.75%/笔),但仍然盈利
- **评价**:✅ **良好**
### 3. 平均持仓时长合理80分钟
- **表现**平均持仓80分钟符合山寨币快速交易特点
- **意义**:没有过度持仓,及时止盈止损
- **评价**:✅ **合理**
---
## ❌ 严重问题分析
### 问题1盈亏比严重不足1.68:1 vs 目标4.0:1
**现状**
- 实际盈亏比1.68:1
- 目标盈亏比4.0:1
- **差距**只有目标的42%
**数学分析**
- 盈亏平衡点 = 1 / (1 + 盈亏比) = 1 / (1 + 1.68) = **37.3%**
- 当前胜率 42.67% 仅略高于盈亏平衡点,所以总盈亏只有 7.37 USDT几乎不盈利
- 如果盈亏比达到 4.0:1盈亏平衡点 = 1 / (1 + 4.0) = **20%**
- 在胜率 42.67% 的情况下,盈亏比 4.0:1 的期望收益 = (0.4267 × 4.0) - (0.5733 × 1) = **+1.13**每笔亏损赚1.13倍)
**结论**:盈亏比 1.68:1 太低了,必须提升到至少 2.5:1 才能稳定盈利。
---
### 问题2极端亏损交易过多
**大额亏损交易(>50%**
| 交易ID | 交易对 | 方向 | 盈亏比例 | 平仓类型 | 问题 |
|--------|--------|------|----------|----------|------|
| #1619 | ENSOUSDT | BUY | **-255.6%** | 止损 | ❌ 极端亏损 |
| #1563 | ENSOUSDT | SELL | **-144.8%** | 止损 | ❌ 极端亏损 |
| #1568 | LIGHTUSDT | BUY | **-96.6%** | 止损 | ❌ 极端亏损 |
| #1571 | NOMUSDT | BUY | **-84.9%** | 手动 | ❌ 极端亏损 |
| #1573 | SOMIUSDT | BUY | **-84.5%** | 止损 | ❌ 极端亏损 |
| #1552 | WCTUSDT | BUY | **-83.5%** | 手动 | ❌ 极端亏损 |
| #1562 | LIGHTUSDT | BUY | **-74.1%** | 止损 | ❌ 极端亏损 |
| #1598 | ENSOUSDT | BUY | **-73.4%** | 手动 | ❌ 极端亏损 |
**问题分析**
1. **止损失效**8笔极端亏损交易说明止损没有及时执行
2. **价格跳空**:部分交易可能遇到价格跳空,导致止损失效
3. **手动平仓过晚**:部分手动平仓的亏损也很大,说明手动干预过晚
**可能原因**
1. 止损单没有正确挂到交易所
2. 止损价格计算错误
3. WebSocket 监控断线,没有及时触发止损
4. 价格跳空导致止损失效
---
### 问题3手动平仓比例过高34.6%
**现状**
- 81笔交易28笔手动平仓34.6%
- 18笔止盈27笔止损2笔同步平仓
**问题分析**
1. **手动平仓可能过早止盈**:如果手动平仓主要是过早止盈,会降低平均盈利
2. **手动平仓可能过晚止损**:如果手动平仓主要是过晚止损,会扩大平均亏损
3. **破坏策略设计**:手动平仓破坏了策略的盈亏比设计
**建议**
- ⚠️ **减少手动干预**,让系统自动执行策略
- ✅ 检查是否有系统故障导致止损/止盈失效
- ✅ 检查数据标记是否正确
---
### 问题4止盈数量少于止损18 vs 27
**现状**
- 止盈18笔22.2%
- 止损27笔33.3%
- 手动平仓28笔34.6%
**问题分析**
1. **止盈目标可能设置太高**`ATR_TAKE_PROFIT_MULTIPLIER = 8.0` 可能仍然太高
2. **大部分订单被提前平仓**28笔手动平仓可能是过早止盈或过晚止损
3. **止损多于止盈**:说明策略偏向亏损
**建议**
- ✅ 检查止盈逻辑确保达到4:1目标
- ✅ 检查止损设置,避免单笔亏损过大
- ⚠️ **减少手动平仓**,让系统自动执行策略
---
## 🔍 详细问题分析
### 极端亏损交易分析
#### 1. ENSOUSDT #1619: -255.6%
**交易详情**
- 入场价1.9636
- 出场价1.629
- 价格跌幅:**17.0%**
- 盈亏比例:**-255.6%**(相对于保证金)
- 杠杆15x
- 平仓类型:自动平仓(止损)
**问题**
- 价格跌幅17%,但盈亏比例-255.6%,说明杠杆过高或止损距离过宽
- 止损没有及时执行,导致亏损扩大
#### 2. ENSOUSDT #1563: -144.8%
**交易详情**
- 方向SELL
- 入场价1.5427
- 出场价1.7289
- 价格涨幅:**12.1%**(做空方向,价格上涨导致亏损)
- 盈亏比例:**-144.8%**(相对于保证金)
- 杠杆12x
- 平仓类型:自动平仓(止损)
**问题**
- 做空方向价格上涨12.1%,但盈亏比例-144.8%,说明止损距离过宽
- 止损没有及时执行,导致亏损扩大
#### 3. LIGHTUSDT #1568: -96.6%
**交易详情**
- 入场价0.5309
- 出场价0.4967
- 价格跌幅:**6.4%**
- 盈亏比例:**-96.6%**(相对于保证金)
- 杠杆15x
- 平仓类型:自动平仓(止损)
**问题**
- 价格跌幅6.4%,但盈亏比例-96.6%,说明止损距离过宽或止损没有及时执行
---
### 极端盈利交易分析
#### 1. ENSOUSDT #1565: +398.0%
**交易详情**
- 入场价1.7285
- 出场价2.1871
- 价格涨幅:**26.5%**
- 盈亏比例:**+398.0%**(相对于保证金)
- 杠杆15x
- 平仓类型:手动平仓
**分析**
- 价格涨幅26.5%,盈亏比例+398.0%,说明杠杆和持仓时间都很好
- 但这是手动平仓,可能过早止盈(如果继续持有可能更高)
#### 2. NOMUSDT #1567: +289.3%
**交易详情**
- 入场价0.008674
- 出场价0.010347
- 价格涨幅:**19.3%**
- 盈亏比例:**+289.3%**(相对于保证金)
- 杠杆15x
- 平仓类型:自动平仓(止盈)
**分析**
- 价格涨幅19.3%,盈亏比例+289.3%,说明止盈目标设置合理
- 这是自动止盈,说明策略执行正确
---
## 🎯 与目标对比
### 目标指标(山寨币策略)
| 指标 | 目标值 | 实际值 | 达成度 |
|------|--------|--------|--------|
| **胜率** | 35% | 42.67% | ✅ **122%**(超出) |
| **盈亏比** | 4.0:1 | 1.68:1 | ❌ **42%**(严重不足) |
| **期望值** | +0.75%/笔 | +0.10 USDT/笔 | ⚠️ 需要统一计算标准 |
---
## 📈 改进建议
### 1. 立即修复止损失效问题
**问题**8笔极端亏损交易>50%),说明止损没有及时执行
**解决方案**
1. ✅ 检查止损单是否正确挂到交易所
2. ✅ 检查止损价格计算是否正确
3. ✅ 检查WebSocket监控是否正常
4. ✅ 添加止损失效告警
### 2. 提升盈亏比到至少2.5:1
**问题**当前盈亏比1.68:1远低于目标4.0:1
**解决方案**
1. ✅ 检查止盈逻辑确保达到4:1目标
2. ✅ 检查止损设置,避免单笔亏损过大
3. ⚠️ **减少手动平仓**,让系统自动执行策略
4. ✅ 检查是否有过早止盈或过晚止损
### 3. 减少手动干预
**问题**28笔手动平仓34.6%),破坏了策略设计
**解决方案**
1. ⚠️ **减少手动干预**,信任系统策略
2. ✅ 检查是否有系统故障导致止损/止盈失效
3. ✅ 检查数据标记是否正确
### 4. 优化止盈目标
**问题**止盈数量少于止损18 vs 27
**解决方案**
1. ✅ 检查止盈目标是否设置太高
2. ✅ 检查是否有过早止盈
3. ✅ 确保达到4:1盈亏比目标
---
## 📊 平仓原因分布
| 平仓原因 | 数量 | 比例 | 评价 |
|----------|------|------|------|
| **止损** | 27 | 33.3% | ⚠️ 比例较高 |
| **止盈** | 18 | 22.2% | ⚠️ 比例较低 |
| **手动平仓** | 28 | 34.6% | ❌ **比例过高** |
| **同步平仓** | 2 | 2.5% | ✅ 正常 |
**分析**
- 止损和止盈比例接近27:18但止盈少于止损
- 手动平仓比例过高34.6%),需要关注
- 如果手动平仓主要是过早止盈,会降低盈亏比
---
## 🔍 关键发现
### 1. 盈亏比严重不足
**原因**
1. **极端亏损交易过多**8笔极端亏损交易>50%),拉低了平均亏损
2. **止盈过早**:部分盈利单可能过早止盈,拉低了平均盈利
3. **手动平仓干扰**28笔手动平仓可能破坏了策略设计
### 2. 止损失效问题严重
**证据**
- 8笔极端亏损交易>50%
- 部分交易价格跌幅不大,但盈亏比例很大
- 说明止损没有及时执行
### 3. 手动平仓比例过高
**影响**
- 破坏了策略的盈亏比设计
- 可能过早止盈或过晚止损
- 需要减少手动干预
---
## 🚀 下一步行动
### 立即执行
1. **修复止损失效问题**
- 检查止损单是否正确挂到交易所
- 检查止损价格计算是否正确
- 检查WebSocket监控是否正常
2. **减少手动干预**
- 减少手动平仓
- 信任系统策略
- 检查是否有系统故障
3. **优化止盈目标**
- 检查止盈目标是否设置太高
- 确保达到4:1盈亏比目标
### 持续监控
1. **监控盈亏比**目标提升到至少2.5:1
2. **监控极端亏损**:避免单笔亏损>50%
3. **监控手动平仓比例**:目标降低到<10%
---
## ✅ 总结
### 表现良好的方面
1. ✅ 胜率优秀42.67% > 目标35%
2. ✅ 总盈亏为正(+7.37 USDT
3. ✅ 平均持仓时长合理80分钟
### 严重问题
1. ❌ 盈亏比严重不足1.68:1 vs 目标4.0:1
2. ❌ 极端亏损交易过多8笔>50%
3. ❌ 手动平仓比例过高34.6%
4. ❌ 止损失效问题严重
### 关键改进方向
1. **立即修复止损失效问题**
2. **提升盈亏比到至少2.5:1**
3. **减少手动干预**
4. **优化止盈目标**
---
## 📝 备注
- 本报告基于2026-01-25的交易数据
- 数据来源:`trading_system/交易记录_2026-01-25T11-46-37.json`
- 分析时间2026-01-25

View 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%
- SANDUSDT SELL: -12.33%
- AXLUSDT BUY: -0.95%
- AXSUSDT BUY: +4.93%(标记为止损,但实际盈利)
- AXSUSDT BUY: -0.61%
- AXLUSDT BUY: +7.78%(标记为止损,但实际盈利)
- AXSUSDT BUY: +12.04%(标记为止损,但实际盈利)
- LPTUSDT SELL: -13.88%
- ZROUSDT BUY: -11.88%
- JTOUSDT BUY: -31.56%
- SANDUSDT SELL: -12.03%
- **同步平仓**2单14.3%
- AUCTIONUSDT BUY: -12.22%
- ZETAUSDT BUY: -35.54%
- AXSUSDT SELL: -16.37%
**严重问题单**
- AXSUSDT SELL: -65.84%(巨额亏损)
- ZETAUSDT BUY: -35.54%(巨额亏损)
- JTOUSDT BUY: -31.56%(巨额亏损)
---
## 🚨 核心问题分析
### 问题1胜率极低
**统计数据**
- 已平仓14单
- 盈利单5单35.7%
- 亏损单9单64.3%
- **胜率35.7%**(严重偏低)
**问题分析**
- 止损单比例过高71.4%
- 止盈单比例过低14.3%
- 巨额亏损单较多(-65.84%, -35.54%, -31.56%
---
### 问题2巨额亏损单
#### AXSUSDT SELL 单交易ID: 1755
- **入场价**2.43
- **出场价**2.63
- **方向**SELL做空
- **盈亏比例**-65.84%
- **持仓时长**约8小时
**问题分析**
- 做空单价格从2.43涨到2.63涨幅8.23%
- 但亏损比例达到-65.84%,说明止损价格设置错误
- 如果止损价格正确应该在价格涨到2.43 × (1 + 止损%)时止损而不是等到2.63
#### ZETAUSDT BUY 单交易ID: 1747
- **入场价**0.08172
- **出场价**0.07809
- **方向**BUY做多
- **盈亏比例**-35.54%
- **持仓时长**约12小时
**问题分析**
- 做多单价格从0.08172跌到0.07809跌幅4.44%
- 但亏损比例达到-35.54%,说明止损价格设置错误
- 如果止损价格正确应该在价格跌到0.08172 × (1 - 止损%)时止损而不是等到0.07809
---
### 问题3ATR使用合理性
**当前配置**
- `USE_ATR_STOP_LOSS`: True
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0
- `STOP_LOSS_PERCENT`: 0.1212%
- `TAKE_PROFIT_PERCENT`: 0.2020%
**问题分析**
1. **ATR止损可能过宽**
- ATR止损倍数2.0,对于山寨币来说可能过宽
- 如果ATR很大比如5%2.0倍就是10%的止损距离
- 但实际止损可能更宽(因为选择"更宽松"的止损)
2. **止损选择逻辑问题**
- 代码中选择"更宽松"的止损(更远离入场价)
- 对于SELL单这可能导致止损过宽出现巨额亏损
3. **ATR止盈可能过高**
- ATR止盈倍数3.0如果ATR很大止盈距离会很大
- 导致止盈单比例过低14.3%
---
## 🔍 ATR使用合理性分析
### ATR止损计算逻辑
**当前实现**`risk_manager.py:602-760`
1. 计算ATR止损价`entry_price × (1 ± ATR% × ATR_STOP_LOSS_MULTIPLIER)`
2. 计算保证金止损价:基于`STOP_LOSS_PERCENT`12%
3. 计算价格百分比止损价:基于`MIN_STOP_LOSS_PRICE_PCT`2%
4. **选择最终的止损价**:取"更宽松"的(更远离入场价)
**问题**
- 对于SELL单选择"更宽松"的止损意味着止损价更高(更远离入场价)
- 这可能导致止损过宽,出现巨额亏损
---
### 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很大ATR止盈倍数3.0会导致止盈距离很大
- 导致止盈单比例过低14.3%
---
## ✅ 优化建议
### 建议1收紧ATR止损倍数紧急
**当前配置**
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
**建议配置**
- `ATR_STOP_LOSS_MULTIPLIER`: **1.5**(收紧止损,减少单笔亏损)
**理由**
- 2.0倍对于山寨币来说可能过宽
- 收紧到1.5倍,既能容忍波动,又能控制风险
- 配合12%的固定止损,应该能更好地控制风险
---
### 建议2降低ATR止盈倍数重要
**当前配置**
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0
**建议配置**
- `ATR_TAKE_PROFIT_MULTIPLIER`: **2.0**(降低止盈目标,更容易触发)
**理由**
- 3.0倍对于山寨币来说可能过高
- 降低到2.0倍,更容易触发止盈
- 配合20%的固定止盈,应该能提升止盈单比例
---
### 建议3优化止损选择逻辑已修复
**问题**
- SELL单选择"更宽松"的止损,导致止损过宽
**修复**
- 已修复SELL单选择"更紧"的止损(更接近入场价)
- 应该能减少巨额亏损单
---
### 建议4优化止盈选择逻辑建议
**当前逻辑**
- 选择"更宽松"的止盈(更远离入场价)
**建议逻辑**
- 选择"更紧"的止盈(更接近入场价),更容易触发
- 或者优先使用固定百分比止盈20%而不是ATR止盈
---
## 📊 配置调整建议
### 当前配置(问题)
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0(可能过宽)
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0(可能过高)
- `STOP_LOSS_PERCENT`: 0.1212%
- `TAKE_PROFIT_PERCENT`: 0.2020%
### 建议配置(优化)
- `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
---
## ⚠️ 注意事项
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%

View File

@ -0,0 +1,275 @@
# 交易数据分析 - 优化效果评估2026-01-27
## 📊 最新统计数据
### 基本统计(最近几个小时)
- **总交易数**39单
- **胜率**38.24%
- **总盈亏**-0.21 USDT
- **平均盈亏**-0.01 USDT
- **平均持仓时长**59分钟
### 平仓原因分布
- **止损**29单74.4%
- **止盈**1单2.6%
- **同步**4单10.3%
- **其他**5单12.8%
### 盈亏比
- **平均盈利 / 平均亏损**1.56 : 1
- **期望**3:1
- **差距**:仍然偏低
---
## 📈 对比分析
### 与之前统计对比
| 指标 | 之前2026-01-27 02:26 | 现在2026-01-27 07:00 | 变化 |
|------|-------------------------|-------------------------|------|
| 总交易数 | 20 | 39 | +19 |
| 胜率 | 35.7% | 38.24% | +2.54% ⬆️ |
| 止盈单比例 | 14.3% | 2.6% | -11.7% ⬇️ |
| 止损单比例 | 71.4% | 74.4% | +3.0% ⬆️ |
| 盈亏比 | 需要计算 | 1.56:1 | - |
---
## 🚨 核心问题分析
### 问题1止盈单比例大幅下降严重
**现象**
- 之前14.3%2/14
- 现在2.6%1/39
- **下降了11.7%**
**可能原因**
1. **止盈目标设置过低**从20%降低到10%,可能导致止盈单更容易被回吐
2. **市场波动**价格达到10%后快速回落,未能及时止盈
3. **止盈单挂单失败**:交易所止盈单可能未成功挂单
4. **WebSocket监控延迟**:实时监控可能延迟,导致止盈触发不及时
**影响**
- 盈利单无法及时止盈,最终回吐利润
- 胜率提升有限仅2.54%
- 盈亏比仍然偏低1.56:1 vs 期望3:1
---
### 问题2止损单比例上升
**现象**
- 之前71.4%
- 现在74.4%
- **上升了3.0%**
**可能原因**
1. **信号强度提升**从5提升到7但可能仍然不够严格
2. **市场环境**:市场可能处于震荡或下跌趋势
3. **止损设置**:止损可能仍然过宽,导致更多单子触发止损
**影响**
- 胜率提升有限
- 亏损单比例仍然很高
---
### 问题3盈亏比仍然偏低
**现象**
- 当前1.56:1
- 期望3:1
- **差距**1.44:1
**可能原因**
1. **止盈单比例过低**只有1单止盈无法形成有效的盈亏比
2. **止损单亏损过大**:止损单的平均亏损可能仍然较大
3. **止盈目标过低**10%的止盈目标可能过低,无法形成有效的盈亏比
**影响**
- 即使胜率提升,盈亏比仍然偏低,无法实现稳定盈利
---
## 🔍 详细分析
### 止盈单分析
**止盈单数量**仅1单2.6%
**问题**
- 止盈单比例过低,说明大部分盈利单未能及时止盈
- 10%的止盈目标可能过低,导致价格达到后快速回落
**建议**
- 检查止盈单挂单是否成功
- 检查WebSocket监控是否正常工作
- 考虑提高止盈目标从10%提升到12-15%
---
### 止损单分析
**止损单数量**29单74.4%
**问题**
- 止损单比例仍然很高
- 说明入场点选择可能仍然不够准确
**建议**
- 进一步提高信号强度门槛从7提升到8
- 加强大盘Beta过滤当前-0.5%,可能可以更严格)
- 检查止损设置是否合理
---
## ✅ 优化建议
### 建议1提高止盈目标紧急
**当前配置**
- `TAKE_PROFIT_PERCENT`: 0.1010%
**建议配置**
- `TAKE_PROFIT_PERCENT`: **0.12**12%)或**0.15**15%
**理由**
- 10%的止盈目标可能过低,容易被回吐
- 提高止盈目标,更容易触发止盈,提升止盈单比例
- 配合移动止损5%激活2.5%保护),可以保护利润
---
### 建议2检查止盈单挂单逻辑
**检查项**
1. 止盈单是否成功挂单到交易所
2. WebSocket监控是否正常工作
3. 止盈触发逻辑是否正确
**可能问题**
- 止盈单挂单失败,导致无法自动止盈
- WebSocket监控延迟导致止盈触发不及时
---
### 建议3进一步提高信号强度门槛
**当前配置**
- `MIN_SIGNAL_STRENGTH`: 7
**建议配置**
- `MIN_SIGNAL_STRENGTH`: **8**
**理由**
- 止损单比例仍然很高74.4%
- 提高信号强度门槛,减少垃圾信号
- 虽然交易数量可能减少,但质量会提升
---
### 建议4加强大盘Beta过滤
**当前配置**
- `BETA_FILTER_THRESHOLD`: -0.005-0.5%
**建议配置**
- `BETA_FILTER_THRESHOLD`: **-0.003**-0.3%)或**-0.002**-0.2%
**理由**
- 更敏感地过滤大盘风险
- 减少在震荡市或下跌趋势中的交易
---
### 建议5优化移动止损参数
**当前配置**
- `TRAILING_STOP_ACTIVATION`: 0.055%
- `TRAILING_STOP_PROTECT`: 0.0252.5%
**建议配置**
- `TRAILING_STOP_ACTIVATION`: **0.08**8%
- `TRAILING_STOP_PROTECT`: **0.04**4%
**理由**
- 5%激活可能过早,容易被震荡扫出
- 提高激活阈值,给价格更多波动空间
- 提高保护阈值,避免过早退出
---
## 📊 优化效果评估
### 已实施的优化
1. ✅ **大盘Beta过滤优化**-0.03 → -0.005-0.5%
2. ✅ **止盈目标降低**0.20 → 0.1010%
3. ✅ **动态追踪止损优化**0.20 → 0.055%激活0.10 → 0.0252.5%保护)
4. ✅ **信号强度提升**5 → 7
### 效果评估
**正面效果**
- ✅ 胜率略有提升35.7% → 38.24%+2.54%
**负面效果**
- ❌ 止盈单比例大幅下降14.3% → 2.6%-11.7%
- ❌ 止损单比例上升71.4% → 74.4%+3.0%
- ❌ 盈亏比仍然偏低1.56:1 vs 期望3:1
**结论**
- ⚠️ 优化效果不明显,甚至有些指标恶化
- ⚠️ 需要进一步调整参数
---
## 🎯 下一步行动
### 优先级1紧急调整
1. **提高止盈目标**10% → 12%或15%
2. **检查止盈单挂单逻辑**:确保止盈单成功挂单
3. **优化移动止损参数**5%激活 → 8%激活2.5%保护 → 4%保护
### 优先级2重要调整
4. **进一步提高信号强度**7 → 8
5. **加强大盘Beta过滤**-0.5% → -0.3%或-0.2%
### 优先级3后续优化
6. **实施成交量激增过滤**15分钟成交量是过去24小时均值的2倍以上时才进场
7. **优化分步止盈**第一目标从固定百分比改为1.5倍ATR
---
## ⚠️ 注意事项
1. **逐步调整**:不要一次性调整所有参数,可以先调整止盈目标,观察效果
2. **监控数据**:调整后密切监控交易数据,确认效果
3. **及时调整**:如果效果不理想,可以进一步微调参数
---
## ✅ 总结
**当前状态**
- ⚠️ 胜率略有提升但仍然偏低38.24%
- ❌ 止盈单比例大幅下降2.6%),这是严重问题
- ❌ 止损单比例上升74.4%
- ❌ 盈亏比仍然偏低1.56:1
**优化方向**
- ✅ 提高止盈目标10% → 12-15%
- ✅ 检查止盈单挂单逻辑
- ✅ 优化移动止损参数
- ✅ 进一步提高信号强度门槛
- ✅ 加强大盘Beta过滤
**预期效果**
- 止盈单比例提升到15-20%
- 胜率提升到45-50%
- 盈亏比提升到2.0:1-2.5:1

View File

@ -0,0 +1,212 @@
# 交易数据分析 - 止损问题分析2026-01-27
## 📊 统计数据
- **总交易数**30
- **胜率**36.36%
- **总盈亏**-8.29 USDT
- **平均盈亏**-0.38 USDT
- **平均持仓时长**270分钟4.5小时)
- **平仓原因**:止损 7 / 止盈 2 / 移动止损 3 / 同步 10
- **平均盈利 / 平均亏损**0.39 : 1期望 3:1实际严重失衡
---
## 🚨 严重问题
### 1. 巨额亏损单
#### AXLUSDT SELL 单交易ID: 1727
- **入场价**0.0731
- **出场价**0.0815
- **方向**SELL做空
- **盈亏比例**-91.93%(几乎亏光保证金)
- **持仓时长**约50小时
**问题分析**
- 做空单价格从0.0731涨到0.0815涨幅11.22%
- 但亏损比例达到-91.93%,说明止损价格设置错误或止损单挂单失败
- 如果止损价格正确应该在价格涨到0.0731 × (1 + 止损%)时止损而不是等到0.0815
#### ZROUSDT SELL 单交易ID: 1725
- **入场价**1.9354
- **出场价**2.0954
- **方向**SELL做空
- **盈亏比例**-66.14%
- **持仓时长**约52小时
**问题分析**
- 做空单价格从1.9354涨到2.0954涨幅8.27%
- 亏损比例-66.14%,同样说明止损价格设置错误或止损单挂单失败
---
### 2. 止盈单极少
- **止盈单**仅2单6.67%
- **止损单**7单23.33%
- **移动止损**3单10%
- **同步平仓**10单33.33%
**问题分析**
- 止盈单比例极低,说明大部分盈利单没有及时止盈
- 可能是止盈价格设置过高,或者止盈单挂单失败
---
### 3. 盈亏比严重失衡
- **期望盈亏比**3:1
- **实际盈亏比**0.39:1严重失衡
**问题分析**
- 平均盈利远小于平均亏损
- 说明盈利单盈利幅度小,亏损单亏损幅度大
- 可能是止损设置过宽,止盈设置过紧
---
## 🔍 根本原因分析
### 问题1SELL单止损价格计算错误
**可能原因**
1. **止损价格选择逻辑错误**对于SELL单应该选择"更紧"的止损(更接近入场价),但代码可能选择了"更松"的止损
2. **止损单挂单失败**:止损单挂单失败后,没有及时执行市价平仓
3. **WebSocket监控延迟**止损单挂单失败后依赖WebSocket监控但监控可能延迟
**代码位置**`trading_system/risk_manager.py:687-710`
**当前逻辑**
```python
# 选择最终的止损价优先ATR其次保证金最后价格百分比取更宽松的
if side == 'BUY':
# 做多:选择更高的止损价(更宽松)
final_stop_loss = max([p for _, p in candidate_prices])
else:
# 做空:选择更低的止损价(更宽松)
final_stop_loss = min([p for _, p in candidate_prices])
```
**问题**
- 对于SELL单选择"更低的止损价"意味着止损价更接近入场价,这是正确的
- 但如果ATR计算的止损价过宽可能会导致止损价格设置错误
---
### 问题2止损单挂单失败后处理不当
**可能原因**
1. **止损单挂单失败**币安API返回错误如-2021: Order would immediately trigger
2. **市价平仓延迟**:止损单挂单失败后,应该立即执行市价平仓,但可能延迟
3. **WebSocket监控延迟**如果市价平仓也失败依赖WebSocket监控但监控可能延迟
**代码位置**`trading_system/position_manager.py:1286-1316`
**当前逻辑**
```python
if should_close:
# 立即执行市价平仓
if await self.close_position(symbol, reason='stop_loss'):
logger.info(f"{symbol} ✓ 止损平仓成功")
```
**问题**
- 如果`close_position`失败,可能没有重试机制
- WebSocket监控可能有延迟导致止损不及时
---
### 问题3止盈价格设置过高
**配置分析**
- `TAKE_PROFIT_PERCENT`: 0.3030%
- `ATR_TAKE_PROFIT_MULTIPLIER`: 4.0
- `RISK_REWARD_RATIO`: 4.0
**问题**
- 30%的止盈目标对于山寨币来说可能过高
- 如果止损距离是15%那么4.0倍盈亏比意味着止盈距离是60%,这对于山寨币来说很难达到
- 导致大部分盈利单无法及时止盈,最终回吐利润
---
## ✅ 解决方案
### 方案1修复SELL单止损价格计算紧急
**问题**SELL单止损价格可能计算错误导致止损过宽
**修复**
1. 检查`get_stop_loss_price`中SELL单的止损价格选择逻辑
2. 确保SELL单选择"更紧"的止损(更接近入场价)
3. 添加止损价格验证,确保止损价格在合理范围内
---
### 方案2增强止损单挂单失败后的处理紧急
**问题**:止损单挂单失败后,可能没有及时执行市价平仓
**修复**
1. 增强`close_position`的重试机制
2. 如果市价平仓失败,立即记录错误并告警
3. 增强WebSocket监控确保及时止损
---
### 方案3调整止盈策略重要
**问题**:止盈价格设置过高,导致盈利单无法及时止盈
**建议**
1. **降低第一目标止盈**从30%降低到20%
2. **降低盈亏比**从4.0降低到3.0
3. **启用移动止损**:盈利后启用移动止损,保护利润
---
### 方案4检查配置值格式重要
**问题**配置值可能仍然是百分比形式如30而不是比例形式0.30
**检查**
1. 确认数据库中的配置值已经是比例形式0.30
2. 确认Redis缓存中的配置值也是比例形式0.30
3. 如果还有百分比形式的值,需要执行数据迁移
---
## 📝 建议的配置调整
### 当前配置(问题)
- `TAKE_PROFIT_PERCENT`: 0.3030%,过高)
- `ATR_TAKE_PROFIT_MULTIPLIER`: 4.0(过高)
- `RISK_REWARD_RATIO`: 4.0(过高)
- `STOP_LOSS_PERCENT`: 0.1515%,可能过宽)
### 建议配置(优化)
- `TAKE_PROFIT_PERCENT`: 0.2020%,更容易触发)
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0(降低,更容易触发)
- `RISK_REWARD_RATIO`: 3.0(降低,更容易触发)
- `STOP_LOSS_PERCENT`: 0.1212%,收紧止损)
- `USE_TRAILING_STOP`: True启用移动止损保护利润
---
## 🎯 优先级
1. **紧急**修复SELL单止损价格计算
2. **紧急**:增强止损单挂单失败后的处理
3. **重要**:调整止盈策略(降低止盈目标)
4. **重要**:检查配置值格式
---
## 📊 预期效果
修复后预期:
- ✅ SELL单止损价格正确不再出现巨额亏损
- ✅ 止损单挂单失败后及时平仓
- ✅ 止盈单比例提升从6.67%提升到20%+
- ✅ 盈亏比改善从0.39:1提升到1.5:1+

View File

@ -0,0 +1,186 @@
# 交易策略问题诊断报告 - 2026-01-28
## 📊 今日交易统计
- **总交易数**12
- **胜率**16.67%2个止盈10个止损
- **总盈亏**-3.07 USDT
- **平均盈亏**-0.26 USDT
- **平均持仓时长**80分钟
- **平均盈利 / 平均亏损**1.00 : 1期望3:1
## 🔍 问题分析
### 1. **止损执行问题(最严重)**
**现象**
- 配置止损12%(基于保证金)
- 实际亏损:-11% 到 -25%
- 所有止损单都超过了配置的12%
**可能原因**
1. **ATR止损计算问题**
- `ATR_STOP_LOSS_MULTIPLIER = 1.5`
- 如果ATR很小`ATR * 1.5` 可能小于12%保证金止损
- 代码选择"更紧的止损"取最大值可能选择了ATR止损但ATR止损可能对应更高的保证金百分比
2. **止损单挂单失败**
- 如果交易所止损单挂单失败只能依赖WebSocket监控
- WebSocket断开时可能无法及时止损
3. **止损价计算错误**
- 对于SELL单止损价应该高于入场价
- 如果计算错误,可能导致止损价设置不合理
### 2. **入场信号质量问题**
**现象**
- 同一交易对反复开仓AXLUSDT 7次1INCHUSDT 4次
- 胜率极低16.67%
- 很多单子都在震荡市开仓
**可能原因**
1. **市场状态判断不准确**
- `AUTO_TRADE_ONLY_TRENDING = True`,但可能把震荡市误判为趋势市
- `detect_market_regime` 的判断条件可能不够严格:
- `ma_diff_pct > 2``volatility_pct > 1` 就判断为趋势
- 这个条件可能太宽松
2. **信号强度门槛不够高**
- `MIN_SIGNAL_STRENGTH = 7`
- 但可能还是不够严格,导致在震荡市也开仓
3. **4H趋势判断不准确**
- 如果4H趋势判断错误可能导致逆势开仓
### 3. **止损/止盈比例失衡**
**现象**
- 止损:-11% 到 -25%
- 止盈:+18% 到 +20%
- 盈亏比1.00 : 1期望3:1
**问题**
- 止损太宽实际亏损远超配置的12%
- 止盈相对较紧10%但实际止盈在18-20%,说明可能触发了第二目标止盈
- 盈亏比严重失衡,无法盈利
### 4. **交易对选择问题**
**现象**
- 集中在少数几个交易对AXLUSDT、1INCHUSDT
- 反复开仓,说明可能在震荡市反复触发信号
**可能原因**
- 扫描逻辑可能对某些交易对有偏好
- 冷却时间可能不够(`ENTRY_SYMBOL_COOLDOWN_SEC = 1800秒 = 30分钟`
## 🎯 核心问题总结
### 问题1止损执行失效
- **配置**12%止损
- **实际**-11% 到 -25%
- **影响**:单笔亏损过大,无法控制风险
### 问题2入场信号质量低
- **配置**MIN_SIGNAL_STRENGTH=7AUTO_TRADE_ONLY_TRENDING=True
- **实际**胜率16.67%,反复开仓
- **影响**:大量无效交易,消耗资金
### 问题3市场状态判断不准确
- **配置**只在trending市场交易
- **实际**可能在ranging市场也开仓
- **影响**:震荡市频繁止损
### 问题4止损/止盈比例失衡
- **配置**止损12%止盈10%盈亏比期望3:1
- **实际**:止损-11%到-25%止盈18-20%盈亏比1:1
- **影响**:无法实现盈利目标
## 💡 优化建议
### 优先级P0立即修复
1. **修复止损执行问题**
- 检查ATR止损计算逻辑确保止损价对应的保证金百分比不超过配置值
- 确保止损单能成功挂到交易所
- 如果止损单挂单失败立即平仓而不是依赖WebSocket
2. **收紧入场条件**
- 提高 `MIN_SIGNAL_STRENGTH` 从7到9
- 加强市场状态判断,确保只在真正的趋势市开仓
- 增加4H趋势确认禁止在neutral趋势开仓
3. **修复止损计算**
- 确保止损价对应的保证金百分比不超过配置的12%
- 如果ATR止损对应更高的保证金百分比应该使用保证金止损
### 优先级P1后续优化
1. **优化市场状态判断**
- 提高trending判断的阈值
- 增加更多指标确认ADX、趋势线等
2. **增加交易对冷却时间**
- 将 `ENTRY_SYMBOL_COOLDOWN_SEC` 从30分钟增加到60分钟
- 避免同一交易对频繁开仓
3. **优化止损/止盈比例**
- 如果止损实际执行在-15%左右止盈应该相应调整到15-20%
- 或者收紧止损确保实际止损在12%以内
## 🔧 具体修复方案
### ✅ 修复1确保止损不超过配置值已完成
`risk_manager.py``get_stop_loss_price` 中添加验证逻辑:
- 计算最终止损价对应的保证金百分比
- 如果超过配置的 `STOP_LOSS_PERCENT`,强制使用保证金止损
- 记录警告日志,便于排查问题
**修复位置**`trading_system/risk_manager.py` 第762-770行
### ✅ 修复2提高入场门槛已完成
- `MIN_SIGNAL_STRENGTH`: 7 → 9大幅提高门槛
- `SMART_ENTRY_STRONG_SIGNAL`: 7 → 9保持一致
- `SIGNAL_STRENGTH_POSITION_MULTIPLIER`: 移除8分只保留9-10分
- `AUTO_TRADE_ALLOW_4H_NEUTRAL`: 已为False禁止neutral趋势开仓
**修复位置**`trading_system/config.py`
### ✅ 修复3优化市场状态判断已完成
提高 `detect_market_regime` 的判断阈值:
- 均线差异阈值2% → 3.5%
- 波动率阈值1% → 1.5%
- 确保只在真正的趋势市判断为trending
**修复位置**`trading_system/indicators.py` 第297行
### ✅ 修复4增加交易对冷却时间已完成
- `ENTRY_SYMBOL_COOLDOWN_SEC`: 1800秒30分钟→ 3600秒60分钟
- 避免同一交易对频繁开仓
**修复位置**`trading_system/config.py` 第260行
## 📋 修复总结
### 已完成的修复:
1. ✅ 止损执行验证:确保止损价对应的保证金百分比不超过配置值
2. ✅ 提高入场门槛MIN_SIGNAL_STRENGTH从7提高到9
3. ✅ 优化市场状态判断提高trending判断阈值
4. ✅ 增加交易对冷却时间从30分钟增加到60分钟
### 预期效果:
- 止损执行更严格实际亏损不会超过配置的12%
- 入场信号质量更高,减少垃圾交易
- 市场状态判断更准确,减少在震荡市开仓
- 同一交易对不会频繁开仓,减少重复止损
### 建议监控指标:
- 胜率是否提升(目标:>30%
- 平均盈亏是否改善(目标:>0
- 止损执行是否准确实际亏损应在12%以内)
- 交易频率是否降低(避免过度交易)

View File

@ -0,0 +1,207 @@
# 修复日志格式化错误和订单状态延迟分析
## 🐛 问题1日志格式化错误已修复
### 错误信息
```
DUSKUSDT 检查止损触发条件时出错: Invalid format specifier '.8f if entry_price_val else 'N/A'' for object of type 'float'
```
### 问题原因
`position_manager.py` 第1203行和1220行格式化字符串语法错误
**错误代码**
```python
logger.error(f" 入场价: {entry_price_val:.8f if entry_price_val else 'N/A'}")
```
**问题**
- Python 的 f-string 不支持在格式化说明符中使用三元表达式
- 应该先判断值是否存在,然后再格式化
### 修复方案
**修复后代码**
```python
entry_price_str = f"{entry_price_val:.8f}" if entry_price_val is not None else 'N/A'
logger.error(f" 入场价: {entry_price_str}")
```
### 修复位置
- `trading_system/position_manager.py` 第1203行做多止损
- `trading_system/position_manager.py` 第1220行做空止损
---
## ⏱️ 问题2订单状态延迟
### 问题描述
订单记录的状态有很大延迟,平仓后前端显示的状态没有及时更新。
### 可能原因
#### 1. 前端轮询间隔过长
**问题**
- 前端可能使用定时轮询如每30秒或1分钟获取订单状态
- 如果轮询间隔过长,会导致状态更新延迟
**检查方法**
- 查看前端代码中的 API 调用频率
- 检查是否有实时推送机制WebSocket
#### 2. 数据库更新延迟
**问题**
- 虽然 `close_position` 方法会立即更新数据库,但可能存在以下情况:
- 数据库连接延迟
- 事务提交延迟
- 数据库锁竞争
**检查方法**
```bash
# 查看数据库更新日志
grep -E "更新数据库状态|数据库状态已更新" /www/wwwroot/autosys_new/logs/trading_*.log | tail -n 20
```
#### 3. 缓存未及时更新
**问题**
- 如果使用了 Redis 缓存,缓存可能未及时更新
- 前端可能从缓存读取数据,而不是直接从数据库读取
**检查方法**
- 检查是否有 Redis 缓存机制
- 检查缓存更新逻辑
#### 4. 平仓操作异步执行
**问题**
- `close_position` 方法是异步的,可能在执行过程中有延迟
- WebSocket 监控触发平仓后,可能需要一些时间才能完成
**检查方法**
```bash
# 查看平仓操作的执行时间
grep -E "开始平仓操作|平仓成功完成" /www/wwwroot/autosys_new/logs/trading_*.log | tail -n 20
```
---
## 🔍 诊断步骤
### 1. 检查数据库更新日志
```bash
# 查看最近的数据库更新记录
grep -E "更新数据库状态|数据库状态已更新" /www/wwwroot/autosys_new/logs/trading_*.log | tail -n 30
```
### 2. 检查平仓操作时间
```bash
# 查看平仓操作的开始和完成时间
grep -E "开始平仓操作|平仓成功完成|平仓订单已提交" /www/wwwroot/autosys_new/logs/trading_*.log | tail -n 30
```
### 3. 检查前端轮询频率
查看前端代码中的 API 调用频率:
- `frontend/src/services/api.js` - 检查 API 调用间隔
- `frontend/src/components/TradeList.jsx` - 检查订单列表刷新频率
### 4. 检查 Redis 缓存
```bash
# 如果有 Redis检查缓存更新
redis-cli keys "*trade*" | head -n 10
```
---
## ✅ 修复方案
### 方案1优化前端轮询频率推荐
**问题**:前端轮询间隔过长
**解决**
1. 减少轮询间隔如从30秒改为5秒
2. 实现实时推送机制WebSocket
3. 在平仓操作后立即刷新订单列表
### 方案2优化数据库更新
**问题**:数据库更新延迟
**解决**
1. 确保数据库更新是同步的(等待更新完成)
2. 添加数据库更新确认日志
3. 检查数据库连接池配置
### 方案3添加实时推送
**问题**:前端依赖轮询
**解决**
1. 实现 WebSocket 实时推送订单状态更新
2. 在平仓操作完成后立即推送状态更新
3. 前端监听推送消息并更新 UI
---
## 🎯 立即行动
### 1. 验证格式化错误修复
重启交易进程,确认日志格式化错误已修复:
```bash
supervisorctl restart auto_sys_acc1 auto_sys_acc2 auto_sys_acc3 auto_sys_acc4
```
### 2. 检查订单状态更新
查看最近的平仓操作和数据库更新:
```bash
# 查看最近的平仓操作
grep -E "开始平仓操作|数据库状态已更新" /www/wwwroot/autosys_new/logs/trading_*.log | tail -n 20
```
### 3. 检查前端轮询频率
查看前端代码,确认订单列表的刷新频率。
---
## 📊 关于 DUSKUSDT 止损价问题
### 日志显示的问题
```
DUSKUSDT ⚠️ 当前价格(0.18064246)已触发止损价(0.16414860)
```
**分析**
- 做空SELL交易
- 当前价0.1806
- 止损价0.1641 ❌ **错误!**
**问题**
- 做空时,止损价应该**高于**入场价(价格上涨触发止损)
- 但止损价0.1641 < 当前价0.1806说明止损价设置错误
- 这可能是在修复止损价选择逻辑之前设置的止损价
**建议**
1. 立即手动平仓 DUSKUSDT止损价设置错误
2. 重启交易进程,确保新的止损价计算逻辑生效
3. 检查其他做空持仓的止损价是否正确
---
## ✅ 完成时间
2026-01-25

View File

@ -0,0 +1,53 @@
# 全局配置与数据库同步
## 1. 是否需要“同步到数据库”?
**分两种理解:**
### 1在页面上改完并保存 → 已实现
- **已实现**:在「全局配置」页修改任意配置项并保存时,后端会调用 `update_global_configs_batch`,对每条配置执行 `GlobalStrategyConfig.set(...)`
- `GlobalStrategyConfig.set` 使用 **INSERT ... ON DUPLICATE KEY UPDATE**
- 若该 `config_key` 在表 `global_strategy_config` 中**不存在**,会**插入**一条新记录;
- 若**已存在**,会**更新**该记录的 `config_value`、`config_type`、`category`、`description` 等。
- 因此:**新加的配置项(如 MAX_RSI_FOR_LONG、MIN_RSI_FOR_SHORT 等)只要在页面上改一次并点保存,就会自动写入数据库**,不需要单独“同步到数据库”的步骤。
### 2不打开页面一次性把“缺省值”写入数据库 → 可选脚本
- 若希望新配置项**一开始就出现在数据库里**(方便备份、导出、或不在 UI 改也能看到默认值),可以执行一次**同步缺省脚本**。
- 脚本会检查 `global_strategy_config` 表,对**尚未存在的 key** 插入默认值;**已存在的 key 不会覆盖**。
---
## 2. 已实现的能力小结
| 场景 | 是否已实现 | 说明 |
|------|------------|------|
| 在「全局配置」页修改并保存 | ✅ 是 | 调用 `POST /api/config/global/batch`,写入 `global_strategy_config` 表 |
| 新 key 第一次保存 | ✅ 是 | `GlobalStrategyConfig.set` 用 INSERT ... ON DUPLICATE KEY UPDATE会自动插入新 key |
| 策略/后端读取配置 | ✅ 是 | `config_manager.get_trading_config()` 从全局配置表(及 Redis缺省时用代码里的默认值 |
| 一次性把缺省项写入 DB | ✅ 可选 | 运行 `backend/sync_global_config_defaults.py`(见下) |
---
## 3. 可选:一次性同步缺省到数据库
若希望把**新增的全局配置项默认值**一次性写入 `global_strategy_config` 表(不覆盖已有记录),可在项目根目录或 backend 目录执行:
```bash
# 在项目根目录
cd backend && python sync_global_config_defaults.py
# 或(若 PYTHONPATH 已含 backend
python backend/sync_global_config_defaults.py
```
脚本会:
- 只插入**当前在表中不存在的** `config_key`
- 已存在的 key **不会**被修改;
- 默认会同步的项包括:`MAX_RSI_FOR_LONG`、`MAX_CHANGE_PERCENT_FOR_LONG`、`MIN_RSI_FOR_SHORT`、`MAX_CHANGE_PERCENT_FOR_SHORT`、`TAKE_PROFIT_1_PERCENT`、`SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT`、`BETA_FILTER_ENABLED`、`BETA_FILTER_THRESHOLD` 等(见脚本内 `DEFAULTS_TO_SYNC`)。
**结论**
- **需要同步到数据库吗?** 若你会在「全局配置」里改这些项并保存,则**不需要**额外同步,保存即写入数据库。
- **有实现吗?** 有:保存即写入;另提供可选脚本,用于一次性把缺省值写入数据库。

Some files were not shown because too many files have changed in this diff Show More