Compare commits
172 Commits
all_in_one
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3f2cce922 | ||
|
|
8ff8cd4ebc | ||
|
|
18257b7d8a | ||
|
|
0a4bbd3132 | ||
|
|
ce3a4953f5 | ||
|
|
4da3e0bd48 | ||
|
|
c01f681dec | ||
|
|
0a0bcd941b | ||
|
|
cb8b393550 | ||
|
|
cf86c64296 | ||
|
|
380ce7cda9 | ||
|
|
aaca165f55 | ||
|
|
6e23c924b2 | ||
|
|
4f21240116 | ||
|
|
9490207537 | ||
|
|
53396adf26 | ||
|
|
f1a82f53e0 | ||
|
|
e328272701 | ||
|
|
15394445b4 | ||
|
|
8337893b0c | ||
|
|
8422e93aa2 | ||
|
|
461aeaf359 | ||
|
|
8eb2476192 | ||
|
|
3865e25a2b | ||
|
|
dfd899256b | ||
|
|
cf678569ee | ||
|
|
5faf3e103d | ||
|
|
fb04f69965 | ||
|
|
1d25b3cb79 | ||
|
|
4c5d040746 | ||
|
|
8667c07134 | ||
|
|
8e365b3a9a | ||
|
|
16c4cfbdd8 | ||
|
|
9fe028d704 | ||
|
|
d4edc16f43 | ||
|
|
88ed3bfab4 | ||
|
|
9e0f180229 | ||
|
|
042cb02563 | ||
|
|
3057ce0e8b | ||
|
|
1eb5c618eb | ||
|
|
51fd3c6550 | ||
|
|
be6459d5dd | ||
|
|
9448996837 | ||
|
|
ed994e6e8e | ||
|
|
7f736c9081 | ||
|
|
7947101bc4 | ||
|
|
c3a14f0f1a | ||
|
|
83e628b611 | ||
|
|
07d3bf4398 | ||
|
|
00751298bb | ||
|
|
4c640f780b | ||
|
|
86b85c2609 | ||
|
|
096b838769 | ||
|
|
10fd7a7d60 | ||
|
|
04f222875a | ||
|
|
1032295052 | ||
|
|
d9270ad6b4 | ||
|
|
762e9c4b38 | ||
|
|
731e71aae8 | ||
|
|
8d2fb4b9af | ||
|
|
f716ea69d5 | ||
|
|
81c73eb9cd | ||
|
|
f7c68efb3e | ||
|
|
b01920cadf | ||
|
|
f2d71d3390 | ||
|
|
27ddbcb8c1 | ||
|
|
6504efbf15 | ||
|
|
fb3d1a0dda | ||
|
|
14b5acae09 | ||
|
|
6341bacc20 | ||
|
|
aca1cf26b7 | ||
|
|
7847d3100b | ||
|
|
8d3991c74c | ||
|
|
211ef38ee9 | ||
|
|
fad8a1d6fd | ||
|
|
150eea7a28 | ||
|
|
e1c6cc2681 | ||
|
|
0c98bfe236 | ||
|
|
7adf5c7126 | ||
|
|
7a64ff44c2 | ||
|
|
2ee6e7a009 | ||
|
|
1fcd692368 | ||
|
|
cb7b091280 | ||
|
|
95abbf50be | ||
|
|
fe420ad1a4 | ||
|
|
efbd149f1f | ||
|
|
9e70f90260 | ||
|
|
f1bc8413df | ||
|
|
0c489bfdee | ||
|
|
f798782a6d | ||
|
|
f9ce156e9a | ||
|
|
63f1ea05f0 | ||
|
|
6aeed50d83 | ||
|
|
7a72cfb30c | ||
|
|
cdbb660c1d | ||
|
|
84c4af5ff5 | ||
|
|
2ba8d69ee0 | ||
|
|
ae953d119e | ||
|
|
3154dd5518 | ||
|
|
fc128f98b4 | ||
|
|
7ec1ae32d7 | ||
|
|
ba818b480d | ||
|
|
972694e98f | ||
|
|
76bde06a4f | ||
|
|
a7e1e6ca0a | ||
|
|
651e736474 | ||
|
|
e7efc5e8fa | ||
|
|
c581174747 | ||
|
|
1c1580b344 | ||
|
|
d051be3f65 | ||
|
|
d6cdc2f055 | ||
|
|
971d4e3a39 | ||
|
|
c9120294c9 | ||
|
|
18eeac233a | ||
|
|
d914b64294 | ||
|
|
078576ddf8 | ||
|
|
3e9d24ebea | ||
|
|
43d54bad97 | ||
|
|
5d7166d404 | ||
|
|
14773e530a | ||
|
|
717f51b015 | ||
|
|
5503860a04 | ||
|
|
d354aec939 | ||
|
|
8afd282ca5 | ||
|
|
e5a281569c | ||
|
|
b1f4cbddac | ||
|
|
3baa00c851 | ||
|
|
06272b6922 | ||
|
|
244ef6f4ba | ||
|
|
3e1e3392b7 | ||
|
|
9ed4d4259a | ||
|
|
5717614f61 | ||
|
|
0ecdff4530 | ||
|
|
7d3d9c7de1 | ||
|
|
fac2651911 | ||
|
|
3ef66fb54a | ||
|
|
490e94f7c7 | ||
|
|
9a62528c93 | ||
|
|
66d68e319f | ||
|
|
3bac042273 | ||
|
|
28bce8f02b | ||
|
|
352d36e7a5 | ||
|
|
156acc92e0 | ||
|
|
dc49c2717b | ||
|
|
5b1370a5a2 | ||
|
|
87e7865cbb | ||
|
|
414607d566 | ||
|
|
2d17406799 | ||
|
|
5a00dc75d7 | ||
|
|
45a654f654 | ||
|
|
e80fd1059b | ||
|
|
d75293e037 | ||
|
|
fe855df566 | ||
|
|
18cfeaf5db | ||
|
|
fe60f12ee0 | ||
|
|
3b0ff0227e | ||
|
|
8d8bc409a6 | ||
|
|
d7813bbc80 | ||
|
|
ed5ea8b733 | ||
|
|
52e849a586 | ||
|
|
2d9adddb91 | ||
|
|
9bc73b63a3 | ||
|
|
c28400e51e | ||
|
|
11e6361235 | ||
|
|
4d26777845 | ||
|
|
6d48dc98d2 | ||
|
|
1fdcb9c8b7 | ||
|
|
ad63dbd234 | ||
|
|
7fb0ed39a7 | ||
|
|
4c20a7a488 | ||
|
|
8832b83ced | ||
|
|
746c8ac25b |
14
.cursorrules
Normal file
14
.cursorrules
Normal 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
120
backend/api/auth_deps.py
Normal 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
75
backend/api/auth_utils.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""
|
||||
登录鉴权工具(JWT + 密码哈希)
|
||||
|
||||
设计目标:
|
||||
- 最小依赖:密码哈希用 pbkdf2_hmac(标准库)
|
||||
- JWT 使用 python-jose(已加入 requirements)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jose import jwt # type: ignore
|
||||
|
||||
|
||||
def _jwt_secret() -> str:
|
||||
s = (os.getenv("ATS_JWT_SECRET") or os.getenv("JWT_SECRET") or "").strip()
|
||||
if s:
|
||||
return s
|
||||
# 允许开发环境兜底,但线上务必配置
|
||||
return "dev-secret-change-me"
|
||||
|
||||
|
||||
def jwt_encode(payload: Dict[str, Any], exp_sec: int = 3600) -> str:
|
||||
now = int(time.time())
|
||||
body = dict(payload or {})
|
||||
body["iat"] = now
|
||||
body["exp"] = now + int(exp_sec)
|
||||
return jwt.encode(body, _jwt_secret(), algorithm="HS256")
|
||||
|
||||
|
||||
def jwt_decode(token: str) -> Dict[str, Any]:
|
||||
return jwt.decode(token, _jwt_secret(), algorithms=["HS256"])
|
||||
|
||||
|
||||
def _b64(b: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(b).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def _b64d(s: str) -> bytes:
|
||||
s = (s or "").strip()
|
||||
s = s + ("=" * (-len(s) % 4))
|
||||
return base64.urlsafe_b64decode(s.encode("utf-8"))
|
||||
|
||||
|
||||
def hash_password(password: str, iterations: int = 260_000) -> str:
|
||||
"""
|
||||
PBKDF2-SHA256:返回格式
|
||||
pbkdf2_sha256$<iterations>$<salt_b64>$<hash_b64>
|
||||
"""
|
||||
pw = (password or "").encode("utf-8")
|
||||
salt = os.urandom(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", pw, salt, int(iterations))
|
||||
return f"pbkdf2_sha256${int(iterations)}${_b64(salt)}${_b64(dk)}"
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
s = str(password_hash or "")
|
||||
if not s.startswith("pbkdf2_sha256$"):
|
||||
return False
|
||||
_, it_s, salt_b64, dk_b64 = s.split("$", 3)
|
||||
it = int(it_s)
|
||||
salt = _b64d(salt_b64)
|
||||
dk0 = _b64d(dk_b64)
|
||||
dk1 = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, it)
|
||||
return hmac.compare_digest(dk0, dk1)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
|
@ -3,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=1(default)
|
||||
try:
|
||||
UserAccountMembership.add(int(uid), 1, role="owner")
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"✓ 已创建初始管理员用户: {username} (id={uid})")
|
||||
else:
|
||||
# 若已存在但不是 admin,则提升为 admin(可注释掉更保守)
|
||||
if (u.get("role") or "user") != "admin":
|
||||
try:
|
||||
User.set_role(int(u["id"]), "admin")
|
||||
logger.warning(f"已将用户 {username} 提升为 admin")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"初始化管理员失败(可忽略): {e}")
|
||||
|
||||
# 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("/")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
# 对冲模式可能有多条 trade(BUY/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)
|
||||
|
|
|
|||
433
backend/api/routes/accounts.py
Normal file
433
backend/api/routes/accounts.py
Normal 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
125
backend/api/routes/admin.py
Normal 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}
|
||||
|
||||
71
backend/api/routes/auth.py
Normal file
71
backend/api/routes/auth.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""
|
||||
登录鉴权 API(JWT)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
|
||||
from database.models import User
|
||||
from api.auth_utils import verify_password, jwt_encode
|
||||
from api.auth_deps import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginReq(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class LoginResp(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: Dict[str, Any]
|
||||
|
||||
|
||||
def _auth_enabled() -> bool:
|
||||
v = (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower()
|
||||
return v not in {"0", "false", "no"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResp)
|
||||
async def login(payload: LoginReq):
|
||||
if not _auth_enabled():
|
||||
raise HTTPException(status_code=400, detail="当前环境未启用登录(ATS_AUTH_ENABLED=false)")
|
||||
|
||||
u = User.get_by_username(payload.username)
|
||||
if not u:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
if (u.get("status") or "active") != "active":
|
||||
raise HTTPException(status_code=403, detail="用户已被禁用")
|
||||
if not verify_password(payload.password, u.get("password_hash") or ""):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
token = jwt_encode({"sub": str(u["id"]), "role": u.get("role") or "user"}, exp_sec=24 * 3600)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"id": u["id"], "username": u["username"], "role": u.get("role") or "user", "status": u.get("status") or "active"},
|
||||
}
|
||||
|
||||
|
||||
class MeResp(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResp)
|
||||
async def me(user: Dict[str, Any] = Depends(get_current_user)):
|
||||
return {
|
||||
"id": int(user["id"]),
|
||||
"username": user.get("username") or "",
|
||||
"role": user.get("role") or "user",
|
||||
"status": user.get("status") or "active",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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:可修改所有配置
|
||||
# - 非 admin(account 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))
|
||||
|
|
|
|||
183
backend/api/routes/public.py
Normal file
183
backend/api/routes/public.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""
|
||||
公开只读状态接口(非管理员也可访问)
|
||||
|
||||
用途:
|
||||
- 普通用户能看到:后端是否在线、启动时间、推荐是否在更新(snapshot 时间)
|
||||
- recommendations-viewer 也可复用该接口展示“服务状态”
|
||||
|
||||
安全原则:
|
||||
- 不返回任何敏感信息(不返回密钥、密码、完整 Redis URL 等)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
try:
|
||||
import redis.asyncio as redis_async
|
||||
except Exception: # pragma: no cover
|
||||
redis_async = None
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
_STARTED_AT_MS = int(time.time() * 1000)
|
||||
|
||||
REDIS_KEY_RECOMMENDATIONS_SNAPSHOT = "recommendations:snapshot"
|
||||
|
||||
|
||||
def _beijing_time_str(ts_ms: Optional[int] = None) -> str:
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
if ts_ms is None:
|
||||
return datetime.now(tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _mask_redis_url(redis_url: str) -> str:
|
||||
s = (redis_url or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
# 简单脱敏:去掉 username/password(如果有)
|
||||
# rediss://user:pass@host:6379/0 -> rediss://***@host:6379/0
|
||||
if "://" in s and "@" in s:
|
||||
scheme, rest = s.split("://", 1)
|
||||
creds_and_host = rest
|
||||
# 仅替换 @ 前面的内容
|
||||
idx = creds_and_host.rfind("@")
|
||||
if idx > 0:
|
||||
return f"{scheme}://***@{creds_and_host[idx+1:]}"
|
||||
return s
|
||||
|
||||
|
||||
def _redis_connection_kwargs() -> Tuple[str, Dict[str, Any]]:
|
||||
redis_url = (os.getenv("REDIS_URL", "") or "").strip() or "redis://localhost:6379"
|
||||
username = os.getenv("REDIS_USERNAME", None)
|
||||
password = os.getenv("REDIS_PASSWORD", None)
|
||||
ssl_cert_reqs = (os.getenv("REDIS_SSL_CERT_REQS", "required") or "required").strip()
|
||||
ssl_ca_certs = os.getenv("REDIS_SSL_CA_CERTS", None)
|
||||
|
||||
select = os.getenv("REDIS_SELECT", None)
|
||||
try:
|
||||
select_i = int(select) if select is not None else 0
|
||||
except Exception:
|
||||
select_i = 0
|
||||
|
||||
kwargs: Dict[str, Any] = {"decode_responses": True}
|
||||
if username:
|
||||
kwargs["username"] = username
|
||||
if password:
|
||||
kwargs["password"] = password
|
||||
kwargs["db"] = select_i
|
||||
|
||||
use_tls = redis_url.startswith("rediss://") or (os.getenv("REDIS_USE_TLS", "False").lower() == "true")
|
||||
if use_tls and not redis_url.startswith("rediss://"):
|
||||
if redis_url.startswith("redis://"):
|
||||
redis_url = redis_url.replace("redis://", "rediss://", 1)
|
||||
else:
|
||||
redis_url = f"rediss://{redis_url}"
|
||||
|
||||
if use_tls or redis_url.startswith("rediss://"):
|
||||
kwargs["ssl_cert_reqs"] = ssl_cert_reqs
|
||||
if ssl_ca_certs:
|
||||
kwargs["ssl_ca_certs"] = ssl_ca_certs
|
||||
kwargs["ssl_check_hostname"] = (ssl_cert_reqs == "required")
|
||||
|
||||
return redis_url, kwargs
|
||||
|
||||
|
||||
async def _get_redis():
|
||||
if redis_async is None:
|
||||
return None
|
||||
redis_url, kwargs = _redis_connection_kwargs()
|
||||
try:
|
||||
client = redis_async.from_url(redis_url, **kwargs)
|
||||
await client.ping()
|
||||
return client
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _get_cached_json(client, key: str) -> Optional[Any]:
|
||||
try:
|
||||
raw = await client.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def public_status():
|
||||
"""
|
||||
公共状态:
|
||||
- backend:在线/启动时间
|
||||
- redis:可用性(不暴露密码)
|
||||
- recommendations:snapshot 最新生成时间(若推荐进程在跑,会持续更新)
|
||||
"""
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
# Redis + 推荐快照
|
||||
redis_ok = False
|
||||
reco: Dict[str, Any] = {"snapshot_ok": False}
|
||||
redis_meta: Dict[str, Any] = {"ok": False, "db": int(os.getenv("REDIS_SELECT", "0") or 0), "url": _mask_redis_url(os.getenv("REDIS_URL", ""))}
|
||||
|
||||
rds = await _get_redis()
|
||||
if rds is not None:
|
||||
redis_ok = True
|
||||
redis_meta["ok"] = True
|
||||
try:
|
||||
snap = await _get_cached_json(rds, REDIS_KEY_RECOMMENDATIONS_SNAPSHOT)
|
||||
except Exception:
|
||||
snap = None
|
||||
|
||||
if isinstance(snap, dict):
|
||||
gen_ms = snap.get("generated_at_ms")
|
||||
try:
|
||||
gen_ms = int(gen_ms) if gen_ms is not None else None
|
||||
except Exception:
|
||||
gen_ms = None
|
||||
count = snap.get("count")
|
||||
try:
|
||||
count = int(count) if count is not None else None
|
||||
except Exception:
|
||||
count = None
|
||||
age_sec = None
|
||||
if gen_ms:
|
||||
age_sec = max(0, int((now_ms - gen_ms) / 1000))
|
||||
reco = {
|
||||
"snapshot_ok": True,
|
||||
"generated_at_ms": gen_ms,
|
||||
"generated_at": snap.get("generated_at"),
|
||||
"generated_at_beijing": _beijing_time_str(gen_ms) if gen_ms else None,
|
||||
"age_sec": age_sec,
|
||||
"count": count,
|
||||
"ttl_sec": snap.get("ttl_sec"),
|
||||
}
|
||||
|
||||
try:
|
||||
await rds.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"backend": {
|
||||
"running": True,
|
||||
"started_at_ms": _STARTED_AT_MS,
|
||||
"started_at": _beijing_time_str(_STARTED_AT_MS),
|
||||
"now_ms": now_ms,
|
||||
"now": _beijing_time_str(now_ms),
|
||||
},
|
||||
"redis": redis_meta,
|
||||
"recommendations": reco,
|
||||
"auth": {
|
||||
"enabled": (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower() not in {"0", "false", "no"},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 条测试日志到 Redis(error/warning/info),用于验证“是否写入到同一台 Redis、同一组 key”。
|
||||
"""
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
|
||||
client = _get_redis_client_for_logs()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="Redis 不可用,无法写入测试日志")
|
||||
|
|
@ -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 界面将无法访问,请手动在服务器启动!",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
529
backend/api/supervisor_account.py
Normal file
529
backend/api/supervisor_account.py
Normal 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)
|
||||
# 当前进程 python(backend venv)
|
||||
candidates.append(sys.executable)
|
||||
# 常见 venv 位置
|
||||
candidates += [
|
||||
str(project_root / "backend" / ".venv" / "bin" / "python"),
|
||||
str(project_root / ".venv" / "bin" / "python"),
|
||||
str(project_root / "trading_system" / ".venv" / "bin" / "python"),
|
||||
"/usr/bin/python3",
|
||||
"/usr/local/bin/python3",
|
||||
]
|
||||
python_bin = None
|
||||
for c in candidates:
|
||||
try:
|
||||
p = Path(c)
|
||||
if p.exists() and os.access(str(p), os.X_OK):
|
||||
python_bin = str(p)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not python_bin:
|
||||
# 最后兜底:写 sys.executable,让错误能在日志里体现
|
||||
python_bin = sys.executable
|
||||
|
||||
# 日志目录可通过环境变量覆盖
|
||||
log_dir, out_log, err_log = expected_trading_log_paths(project_root, int(account_id))
|
||||
# supervisor 在 reread/update 时会校验 logfile 目录是否存在;这里提前创建避免 CANT_REREAD
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
# 最后兜底到 /tmp,确保一定存在
|
||||
log_dir = Path("/tmp") / "autosys_logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_log = log_dir / f"trading_{int(account_id)}.out.log"
|
||||
err_log = log_dir / f"trading_{int(account_id)}.err.log"
|
||||
|
||||
# 默认不自动启动,避免“创建账号=立刻下单”
|
||||
autostart = (os.getenv("TRADING_AUTOSTART_DEFAULT", "false") or "false").lower() == "true"
|
||||
run_user = (os.getenv("SUPERVISOR_RUN_USER") or "").strip()
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
f"[program:{program_name}]",
|
||||
f"directory={project_root}",
|
||||
f"command={python_bin} -m trading_system.main",
|
||||
"autostart=" + ("true" if autostart else "false"),
|
||||
# 更合理:仅在“非0退出”时重启;0 退出视为“正常结束”,不进入 FATAL 反复拉起
|
||||
"autorestart=unexpected",
|
||||
# 兼容:0/2 都视为“预期退出”(例如配置不完整/前置检查失败时主动退出)
|
||||
"exitcodes=0,2",
|
||||
"startsecs=0",
|
||||
"stopasgroup=true",
|
||||
"killasgroup=true",
|
||||
"stopsignal=TERM",
|
||||
"",
|
||||
# 关键:PYTHONPATH 指向项目根,确保 -m trading_system.main 可导入
|
||||
f'environment=ATS_ACCOUNT_ID="{int(account_id)}",PYTHONUNBUFFERED="1",PYTHONPATH="{project_root}"',
|
||||
(f"user={run_user}" if run_user else "").rstrip(),
|
||||
"",
|
||||
f"stdout_logfile={out_log}",
|
||||
f"stderr_logfile={err_log}",
|
||||
"stdout_logfile_maxbytes=20MB",
|
||||
"stdout_logfile_backups=5",
|
||||
"stderr_logfile_maxbytes=20MB",
|
||||
"stderr_logfile_backups=5",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def write_program_ini(program_dir: Path, filename: str, content: str) -> Path:
|
||||
program_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = program_dir / filename
|
||||
tmp = program_dir / (filename + ".tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
os.replace(str(tmp), str(target))
|
||||
return target
|
||||
|
||||
|
||||
def _build_supervisorctl_cmd(args: list[str]) -> list[str]:
|
||||
supervisorctl_path = 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),
|
||||
)
|
||||
|
||||
|
|
@ -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.0(2026-01-29优化:从1.5提高到2.0,减少被正常波动扫出)
|
||||
'ATR_TAKE_PROFIT_MULTIPLIER': eff_get('ATR_TAKE_PROFIT_MULTIPLIER', 2.0), # ATR止盈倍数2.0(2026-01-27优化:降低止盈目标,更容易触发)
|
||||
'RISK_REWARD_RATIO': eff_get('RISK_REWARD_RATIO', 3.0), # 盈亏比3:1(2026-01-27优化:降低,更容易触发,保证胜率)
|
||||
'ATR_PERIOD': eff_get('ATR_PERIOD', 14), # ATR计算周期
|
||||
'USE_DYNAMIC_ATR_MULTIPLIER': eff_get('USE_DYNAMIC_ATR_MULTIPLIER', False), # 是否根据波动率动态调整ATR倍数
|
||||
'ATR_MULTIPLIER_MIN': eff_get('ATR_MULTIPLIER_MIN', 1.5), # 动态ATR倍数最小值
|
||||
'ATR_MULTIPLIER_MAX': eff_get('ATR_MULTIPLIER_MAX', 2.5), # 动态ATR倍数最大值
|
||||
|
||||
# 市场扫描(1小时主周期)
|
||||
'SCAN_INTERVAL': self.get('SCAN_INTERVAL', 3600), # 1小时
|
||||
'TOP_N_SYMBOLS': self.get('TOP_N_SYMBOLS', 10), # 每次扫描后处理的交易对数量
|
||||
'MAX_SCAN_SYMBOLS': self.get('MAX_SCAN_SYMBOLS', 500), # 扫描的最大交易对数量(0表示扫描所有)
|
||||
'KLINE_INTERVAL': self.get('KLINE_INTERVAL', '1h'),
|
||||
'PRIMARY_INTERVAL': self.get('PRIMARY_INTERVAL', '1h'),
|
||||
'CONFIRM_INTERVAL': self.get('CONFIRM_INTERVAL', '4h'),
|
||||
'ENTRY_INTERVAL': self.get('ENTRY_INTERVAL', '15m'),
|
||||
# 固定风险百分比仓位计算(凯利公式)
|
||||
'USE_FIXED_RISK_SIZING': eff_get('USE_FIXED_RISK_SIZING', True), # 使用固定风险百分比计算仓位
|
||||
'FIXED_RISK_PERCENT': eff_get('FIXED_RISK_PERCENT', 0.02), # 每笔单子承受的风险(2%)
|
||||
|
||||
# 市场扫描(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):
|
||||
|
|
|
|||
31
backend/database/add_auth.sql
Normal file
31
backend/database/add_auth.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
-- 登录与权限系统迁移脚本(在已有库上执行一次)
|
||||
-- 目标:
|
||||
-- 1) 新增 users 表(管理员/普通用户)
|
||||
-- 2) 新增 user_account_memberships 表(用户可访问哪些交易账号)
|
||||
--
|
||||
-- 执行前建议备份数据库。
|
||||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`username` VARCHAR(64) NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL DEFAULT 'user' COMMENT 'admin, user',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active, disabled',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录用户';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_account_memberships` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`account_id` INT NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL DEFAULT 'viewer' COMMENT 'owner, viewer',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_user_account` (`user_id`, `account_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_account_id` (`account_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户-交易账号授权';
|
||||
|
||||
21
backend/database/add_entry_context.sql
Normal file
21
backend/database/add_entry_context.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- 为 trades 表添加「入场思路/过程」字段,便于事后分析策略执行效果
|
||||
-- 存储 JSON:signal_strength, market_regime, trend_4h, change_percent, rsi, reason, volume_confirmed 等
|
||||
|
||||
-- 使用动态 SQL 检查列是否存在(兼容已有库)
|
||||
SET @column_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'trades'
|
||||
AND column_name = 'entry_context'
|
||||
);
|
||||
|
||||
SET @sql = IF(@column_exists = 0,
|
||||
'ALTER TABLE `trades` ADD COLUMN `entry_context` JSON NULL COMMENT ''入场时的思路与过程(信号强度、市场状态、趋势、过滤通过情况等),便于综合分析策略执行效果'' AFTER `entry_reason`',
|
||||
'SELECT "entry_context 列已存在,跳过添加" AS message'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SELECT 'Migration completed: entry_context added to trades (if not exists).' AS result;
|
||||
45
backend/database/add_global_strategy_config.sql
Normal file
45
backend/database/add_global_strategy_config.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
-- 创建全局策略配置表(独立于账户)
|
||||
-- 全局配置不依赖任何account_id,由管理员统一管理
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `global_strategy_config` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`config_key` VARCHAR(100) NOT NULL,
|
||||
`config_value` TEXT NOT NULL,
|
||||
`config_type` VARCHAR(50) NOT NULL COMMENT 'string, number, boolean, json',
|
||||
`category` VARCHAR(50) NOT NULL COMMENT 'strategy, risk, scan',
|
||||
`description` TEXT,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(50) COMMENT '更新人(用户名)',
|
||||
INDEX `idx_category` (`category`),
|
||||
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全局策略配置表(管理员专用)';
|
||||
|
||||
-- 迁移现有account_id=1的核心策略配置到全局配置表
|
||||
-- 注意:只迁移非风险旋钮的配置
|
||||
INSERT INTO `global_strategy_config` (`config_key`, `config_value`, `config_type`, `category`, `description`)
|
||||
SELECT
|
||||
`config_key`,
|
||||
`config_value`,
|
||||
`config_type`,
|
||||
`category`,
|
||||
`description`
|
||||
FROM `trading_config`
|
||||
WHERE `account_id` = 1
|
||||
AND `config_key` NOT IN (
|
||||
'MIN_MARGIN_USDT',
|
||||
'MIN_POSITION_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'AUTO_TRADE_ENABLED',
|
||||
'MAX_OPEN_POSITIONS',
|
||||
'MAX_DAILY_ENTRIES',
|
||||
'BINANCE_API_KEY',
|
||||
'BINANCE_API_SECRET',
|
||||
'USE_TESTNET'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`config_value` = VALUES(`config_value`),
|
||||
`config_type` = VALUES(`config_type`),
|
||||
`category` = VALUES(`category`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = CURRENT_TIMESTAMP;
|
||||
91
backend/database/add_multi_account.sql
Normal file
91
backend/database/add_multi_account.sql
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
-- 多账号迁移脚本(在已有库上执行一次)
|
||||
-- 目标:
|
||||
-- 1) 新增 accounts 表(存加密后的 API KEY/SECRET)
|
||||
-- 2) trading_config/trades/account_snapshots 增加 account_id(默认=1)
|
||||
-- 3) trading_config 的唯一约束从 config_key 改为 (account_id, config_key)
|
||||
--
|
||||
-- ⚠️ 注意:
|
||||
-- - 不同 MySQL 版本对 "ADD COLUMN IF NOT EXISTS" 支持不一致,因此这里用 INFORMATION_SCHEMA + 动态SQL。
|
||||
-- - 执行前建议先备份数据库。
|
||||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
-- 1) accounts 表
|
||||
CREATE TABLE IF NOT EXISTS `accounts` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`status` VARCHAR(20) DEFAULT 'active' COMMENT 'active, disabled',
|
||||
`api_key_enc` TEXT NULL COMMENT '加密后的 API KEY(enc:v1:...)',
|
||||
`api_secret_enc` TEXT NULL COMMENT '加密后的 API SECRET(enc:v1:...)',
|
||||
`use_testnet` BOOLEAN DEFAULT FALSE,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号表(多账号)';
|
||||
|
||||
INSERT INTO `accounts` (`id`, `name`, `status`, `use_testnet`)
|
||||
VALUES (1, 'default', 'active', false)
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- 2) trading_config.account_id
|
||||
SET @has_col := (
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trading_config'
|
||||
AND COLUMN_NAME = 'account_id'
|
||||
);
|
||||
SET @sql := IF(@has_col = 0, 'ALTER TABLE trading_config ADD COLUMN account_id INT NOT NULL DEFAULT 1 AFTER id', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 3) trades.account_id
|
||||
SET @has_col := (
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trades'
|
||||
AND COLUMN_NAME = 'account_id'
|
||||
);
|
||||
SET @sql := IF(@has_col = 0, 'ALTER TABLE trades ADD COLUMN account_id INT NOT NULL DEFAULT 1 AFTER id', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 4) account_snapshots.account_id
|
||||
SET @has_col := (
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'account_snapshots'
|
||||
AND COLUMN_NAME = 'account_id'
|
||||
);
|
||||
SET @sql := IF(@has_col = 0, 'ALTER TABLE account_snapshots ADD COLUMN account_id INT NOT NULL DEFAULT 1 AFTER id', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 5) trading_config 唯一键:改为 (account_id, config_key)
|
||||
-- 尝试删除旧 UNIQUE(config_key)(名字可能是 config_key 或其他)
|
||||
SET @idx_name := (
|
||||
SELECT INDEX_NAME
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trading_config'
|
||||
AND NON_UNIQUE = 0
|
||||
AND COLUMN_NAME = 'config_key'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @sql := IF(@idx_name IS NOT NULL, CONCAT('ALTER TABLE trading_config DROP INDEX ', @idx_name), 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 添加新唯一键(如果不存在)
|
||||
SET @has_uk := (
|
||||
SELECT COUNT(1)
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trading_config'
|
||||
AND INDEX_NAME = 'uk_account_config_key'
|
||||
);
|
||||
SET @sql := IF(@has_uk = 0, 'ALTER TABLE trading_config ADD UNIQUE KEY uk_account_config_key (account_id, config_key)', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 6) 索引(可选,老版本 MySQL 不支持 IF NOT EXISTS,可忽略报错后手动检查)
|
||||
-- 如果你看到 “Duplicate key name” 可直接忽略。
|
||||
CREATE INDEX idx_trades_account_id ON trades(account_id);
|
||||
CREATE INDEX idx_account_snapshots_account_id ON account_snapshots(account_id);
|
||||
|
||||
42
backend/database/add_partial_profit_exit_reasons.sql
Normal file
42
backend/database/add_partial_profit_exit_reasons.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
-- 分步止盈状态细分:添加新的exit_reason值支持
|
||||
-- 执行时间:2026-01-27
|
||||
|
||||
-- 1. 更新exit_reason字段注释,说明新的状态值
|
||||
ALTER TABLE `trades` MODIFY COLUMN `exit_reason` VARCHAR(50)
|
||||
COMMENT '平仓原因: manual(手动), stop_loss(止损), take_profit(单次止盈), trailing_stop(移动止损), sync(同步), take_profit_partial_then_take_profit(第一目标止盈后第二目标止盈), take_profit_partial_then_stop(第一目标止盈后剩余仓位止损), take_profit_partial_then_trailing_stop(第一目标止盈后剩余仓位移动止损)';
|
||||
|
||||
-- 2. 验证字段长度是否足够(VARCHAR(50)应该足够)
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
COLUMN_COMMENT
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'trades'
|
||||
AND COLUMN_NAME = 'exit_reason';
|
||||
|
||||
-- 3. 查看当前exit_reason的分布情况(用于验证)
|
||||
SELECT
|
||||
exit_reason,
|
||||
COUNT(*) as count,
|
||||
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM trades WHERE status = 'closed'), 2) as percentage
|
||||
FROM
|
||||
trades
|
||||
WHERE
|
||||
status = 'closed'
|
||||
GROUP BY
|
||||
exit_reason
|
||||
ORDER BY
|
||||
count DESC;
|
||||
|
||||
-- 说明:
|
||||
-- 新的状态值:
|
||||
-- - take_profit_partial_then_take_profit: 第一目标止盈(50%仓位)后,剩余仓位第二目标止盈
|
||||
-- - take_profit_partial_then_stop: 第一目标止盈(50%仓位)后,剩余仓位止损(保本)
|
||||
-- - take_profit_partial_then_trailing_stop: 第一目标止盈(50%仓位)后,剩余仓位移动止损
|
||||
--
|
||||
-- 这些状态用于更准确地统计胜率和盈亏比:
|
||||
-- - 第一目标止盈后剩余仓位止损,应该算作"部分成功"(第一目标已达成)
|
||||
-- - 第一目标止盈后剩余仓位第二目标止盈,应该算作"完整成功"
|
||||
|
|
@ -4,22 +4,69 @@ CREATE DATABASE IF NOT EXISTS `auto_trade_sys` DEFAULT CHARACTER SET utf8mb4 COL
|
|||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
-- 用户表(登录用户:管理员/普通用户)
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`username` VARCHAR(64) NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL DEFAULT 'user' COMMENT 'admin, user',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active, disabled',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录用户';
|
||||
|
||||
-- 用户-交易账号授权关系
|
||||
CREATE TABLE IF NOT EXISTS `user_account_memberships` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`account_id` INT NOT NULL,
|
||||
`role` VARCHAR(20) NOT NULL DEFAULT 'viewer' COMMENT 'owner, viewer',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_user_account` (`user_id`, `account_id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_account_id` (`account_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户-交易账号授权';
|
||||
|
||||
-- 账号表(多账号)
|
||||
CREATE TABLE IF NOT EXISTS `accounts` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`status` VARCHAR(20) DEFAULT 'active' COMMENT 'active, disabled',
|
||||
`api_key_enc` TEXT NULL COMMENT '加密后的 API KEY(enc:v1:...)',
|
||||
`api_secret_enc` TEXT NULL COMMENT '加密后的 API SECRET(enc:v1:...)',
|
||||
`use_testnet` BOOLEAN DEFAULT FALSE,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号表(多账号)';
|
||||
|
||||
-- 默认账号(兼容单账号)
|
||||
INSERT INTO `accounts` (`id`, `name`, `status`, `use_testnet`)
|
||||
VALUES (1, 'default', 'active', false)
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- 配置表
|
||||
CREATE TABLE IF NOT EXISTS `trading_config` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`config_key` VARCHAR(100) UNIQUE NOT NULL,
|
||||
`account_id` INT NOT NULL DEFAULT 1,
|
||||
`config_key` VARCHAR(100) NOT NULL,
|
||||
`config_value` TEXT NOT NULL,
|
||||
`config_type` VARCHAR(50) NOT NULL COMMENT 'string, number, boolean, json',
|
||||
`category` VARCHAR(50) NOT NULL COMMENT 'position, risk, scan, strategy, api',
|
||||
`description` TEXT,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(50),
|
||||
INDEX `idx_category` (`category`)
|
||||
INDEX `idx_category` (`category`),
|
||||
INDEX `idx_account_id` (`account_id`),
|
||||
UNIQUE KEY `uk_account_config_key` (`account_id`, `config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易配置表';
|
||||
|
||||
-- 注意:多账号需要 (account_id, config_key) 唯一。旧库升级请跑迁移脚本(见 add_multi_account.sql)。
|
||||
|
||||
-- 交易记录表
|
||||
CREATE TABLE IF NOT EXISTS `trades` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`account_id` INT NOT NULL DEFAULT 1,
|
||||
`symbol` VARCHAR(20) NOT NULL,
|
||||
`side` VARCHAR(10) NOT NULL COMMENT 'BUY, SELL',
|
||||
`quantity` DECIMAL(20, 8) NOT NULL,
|
||||
|
|
@ -45,6 +92,7 @@ CREATE TABLE IF NOT EXISTS `trades` (
|
|||
`take_profit_2` DECIMAL(20, 8) NULL COMMENT '第二目标止盈价(用于展示与分步止盈)',
|
||||
`status` VARCHAR(20) DEFAULT 'open' COMMENT 'open, closed, cancelled',
|
||||
`created_at` INT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()) COMMENT '创建时间(Unix时间戳秒数)',
|
||||
INDEX `idx_account_id` (`account_id`),
|
||||
INDEX `idx_symbol` (`symbol`),
|
||||
INDEX `idx_entry_time` (`entry_time`),
|
||||
INDEX `idx_status` (`status`),
|
||||
|
|
@ -57,12 +105,14 @@ CREATE TABLE IF NOT EXISTS `trades` (
|
|||
-- 账户快照表
|
||||
CREATE TABLE IF NOT EXISTS `account_snapshots` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`account_id` INT NOT NULL DEFAULT 1,
|
||||
`total_balance` DECIMAL(20, 8) NOT NULL,
|
||||
`available_balance` DECIMAL(20, 8) NOT NULL,
|
||||
`total_position_value` DECIMAL(20, 8) DEFAULT 0,
|
||||
`total_pnl` DECIMAL(20, 8) DEFAULT 0,
|
||||
`open_positions` INT DEFAULT 0,
|
||||
`snapshot_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX `idx_account_id` (`account_id`),
|
||||
INDEX `idx_snapshot_time` (`snapshot_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户快照表';
|
||||
|
||||
|
|
@ -158,11 +208,11 @@ INSERT INTO `trading_config` (`config_key`, `config_value`, `config_type`, `cate
|
|||
('STOP_LOSS_PERCENT', '0.10', 'number', 'risk', '止损:10%(相对于保证金)'),
|
||||
('TAKE_PROFIT_PERCENT', '0.30', 'number', 'risk', '止盈:30%(相对于保证金,盈亏比3:1)'),
|
||||
('MIN_STOP_LOSS_PRICE_PCT', '0.02', 'number', 'risk', '最小止损价格变动:2%(防止止损过紧)'),
|
||||
('MIN_TAKE_PROFIT_PRICE_PCT', '0.03', 'number', 'risk', '最小止盈价格变动:3%(防止止盈过紧)'),
|
||||
('MIN_TAKE_PROFIT_PRICE_PCT', '0.02', 'number', 'risk', '最小止盈价格变动:2%(防止ATR过小时计算出不切实际的微小止盈距离)'),
|
||||
('USE_ATR_STOP_LOSS', 'true', 'boolean', 'risk', '是否使用ATR动态止损(优先于固定百分比)'),
|
||||
('ATR_STOP_LOSS_MULTIPLIER', '1.8', 'number', 'risk', 'ATR止损倍数(1.5-2倍ATR,默认1.8)'),
|
||||
('ATR_TAKE_PROFIT_MULTIPLIER', '3.0', 'number', 'risk', 'ATR止盈倍数(3倍ATR,对应3:1盈亏比)'),
|
||||
('RISK_REWARD_RATIO', '3.0', 'number', 'risk', '盈亏比(止损距离的倍数,用于计算止盈)'),
|
||||
('ATR_TAKE_PROFIT_MULTIPLIER', '1.5', 'number', 'risk', 'ATR止盈倍数(从4.5降至1.5,将盈亏比从3:1降至更现实、可达成的1.5:1,提升止盈触发率)'),
|
||||
('RISK_REWARD_RATIO', '1.5', 'number', 'risk', '盈亏比(止损距离的倍数,用于计算止盈,从3.0降至1.5,更容易达成)'),
|
||||
('ATR_PERIOD', '14', 'number', 'risk', 'ATR计算周期(默认14)'),
|
||||
('USE_DYNAMIC_ATR_MULTIPLIER', 'false', 'boolean', 'risk', '是否根据波动率动态调整ATR倍数'),
|
||||
('ATR_MULTIPLIER_MIN', '1.5', 'number', 'risk', '动态ATR倍数最小值'),
|
||||
|
|
|
|||
130
backend/database/migrate_percent_configs_to_ratio.sql
Normal file
130
backend/database/migrate_percent_configs_to_ratio.sql
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
-- ============================================================
|
||||
-- 配置值格式统一迁移脚本
|
||||
-- 将百分比形式(>1)转换为比例形式(除以100)
|
||||
-- 执行时间:2026-01-26
|
||||
-- ============================================================
|
||||
|
||||
-- 说明:
|
||||
-- 此脚本将数据库中的百分比配置项从百分比形式(如30表示30%)
|
||||
-- 转换为比例形式(如0.30表示30%),以统一数据格式。
|
||||
|
||||
-- ⚠️ 重要:执行前请备份数据库!
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 备份表(强烈推荐)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS trading_config_backup_20260126 AS
|
||||
SELECT * FROM trading_config;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_strategy_config_backup_20260126 AS
|
||||
SELECT * FROM global_strategy_config;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 迁移 trading_config 表
|
||||
-- ============================================================
|
||||
UPDATE trading_config
|
||||
SET config_value = CAST(config_value AS DECIMAL(10, 4)) / 100.0
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 迁移 global_strategy_config 表
|
||||
-- ============================================================
|
||||
UPDATE global_strategy_config
|
||||
SET config_value = CAST(config_value AS DECIMAL(10, 4)) / 100.0
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 验证迁移结果
|
||||
-- ============================================================
|
||||
-- 检查是否还有>1的百分比配置项(应该返回0行)
|
||||
SELECT 'trading_config' as table_name, config_key, config_value, account_id
|
||||
FROM trading_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1
|
||||
UNION ALL
|
||||
SELECT 'global_strategy_config' as table_name, config_key, config_value, NULL as account_id
|
||||
FROM global_strategy_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 查看迁移结果(可选)
|
||||
-- ============================================================
|
||||
-- 查看迁移后的配置值
|
||||
SELECT config_key, config_value, account_id
|
||||
FROM trading_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
ORDER BY config_key, account_id;
|
||||
|
||||
SELECT config_key, config_value
|
||||
FROM global_strategy_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
ORDER BY config_key;
|
||||
|
|
@ -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,),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
backend/security/__init__.py
Normal file
4
backend/security/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
安全相关工具(加密/解密等)
|
||||
"""
|
||||
|
||||
119
backend/security/crypto.py
Normal file
119
backend/security/crypto.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""
|
||||
对称加密工具(用于存储 API Key/Secret 等敏感字段)
|
||||
|
||||
说明:
|
||||
- 使用 AES-GCM(需要 cryptography 依赖)
|
||||
- master key 来自环境变量:
|
||||
- ATS_MASTER_KEY(推荐):32字节 key 的 base64(urlsafe) 或 hex
|
||||
- AUTO_TRADE_SYS_MASTER_KEY(兼容)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _load_master_key_bytes() -> Optional[bytes]:
|
||||
raw = (
|
||||
os.getenv("ATS_MASTER_KEY")
|
||||
or os.getenv("AUTO_TRADE_SYS_MASTER_KEY")
|
||||
or os.getenv("MASTER_KEY")
|
||||
or ""
|
||||
).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
# 1) hex
|
||||
try:
|
||||
b = bytes.fromhex(raw)
|
||||
if len(b) == 32:
|
||||
return b
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) urlsafe base64
|
||||
try:
|
||||
padded = raw + ("=" * (-len(raw) % 4))
|
||||
b = base64.urlsafe_b64decode(padded.encode("utf-8"))
|
||||
if len(b) == 32:
|
||||
return b
|
||||
except binascii.Error:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _aesgcm():
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM # type: ignore
|
||||
|
||||
return AESGCM
|
||||
except Exception as e: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
"缺少加密依赖 cryptography,无法安全存储敏感字段。请安装 cryptography 并设置 ATS_MASTER_KEY。"
|
||||
) from e
|
||||
|
||||
|
||||
def encrypt_str(plaintext: str) -> str:
|
||||
"""
|
||||
加密字符串,返回带版本前缀的密文:
|
||||
enc:v1:<b64(nonce)>:<b64(ciphertext)>
|
||||
"""
|
||||
if plaintext is None:
|
||||
plaintext = ""
|
||||
s = str(plaintext)
|
||||
if s == "":
|
||||
return ""
|
||||
|
||||
key = _load_master_key_bytes()
|
||||
if not key:
|
||||
# 允许降级:不加密直接存(避免线上因缺KEY彻底不可用),但强烈建议尽快配置 master key
|
||||
return s
|
||||
|
||||
import os as _os
|
||||
|
||||
AESGCM = _aesgcm()
|
||||
nonce = _os.urandom(12)
|
||||
aes = AESGCM(key)
|
||||
ct = aes.encrypt(nonce, s.encode("utf-8"), None)
|
||||
return "enc:v1:{}:{}".format(
|
||||
base64.urlsafe_b64encode(nonce).decode("utf-8").rstrip("="),
|
||||
base64.urlsafe_b64encode(ct).decode("utf-8").rstrip("="),
|
||||
)
|
||||
|
||||
|
||||
def decrypt_str(ciphertext: str) -> str:
|
||||
"""
|
||||
解密 encrypt_str 的输出;若不是 enc:v1 前缀,则视为明文原样返回(兼容旧数据)。
|
||||
"""
|
||||
if ciphertext is None:
|
||||
return ""
|
||||
s = str(ciphertext)
|
||||
if s == "":
|
||||
return ""
|
||||
if not s.startswith("enc:v1:"):
|
||||
return s
|
||||
|
||||
key = _load_master_key_bytes()
|
||||
if not key:
|
||||
raise RuntimeError("密文存在但未配置 ATS_MASTER_KEY,无法解密敏感字段。")
|
||||
|
||||
parts = s.split(":")
|
||||
if len(parts) != 4:
|
||||
raise ValueError("密文格式不正确")
|
||||
|
||||
b64_nonce = parts[2] + ("=" * (-len(parts[2]) % 4))
|
||||
b64_ct = parts[3] + ("=" * (-len(parts[3]) % 4))
|
||||
nonce = base64.urlsafe_b64decode(b64_nonce.encode("utf-8"))
|
||||
ct = base64.urlsafe_b64decode(b64_ct.encode("utf-8"))
|
||||
|
||||
AESGCM = _aesgcm()
|
||||
aes = AESGCM(key)
|
||||
pt = aes.decrypt(nonce, ct, None)
|
||||
return pt.decode("utf-8")
|
||||
|
||||
87
backend/sync_global_config_defaults.py
Normal file
87
backend/sync_global_config_defaults.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
将“缺省全局配置项”同步到数据库 global_strategy_config 表。
|
||||
|
||||
- 已在 UI 保存过的项不会覆盖(只插入缺失的 key)。
|
||||
- 用于新上线配置项(如 MAX_RSI_FOR_LONG、MIN_RSI_FOR_SHORT 等)一次性写入默认值,
|
||||
便于在数据库中可见、可备份,且不依赖“先在页面改一次再保存”。
|
||||
|
||||
使用方式(在项目根目录):
|
||||
cd backend && python sync_global_config_defaults.py
|
||||
或
|
||||
python backend/sync_global_config_defaults.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保 backend 在路径中
|
||||
backend_dir = Path(__file__).resolve().parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
# 需要同步的缺省项(仅插入数据库中不存在的 key)
|
||||
DEFAULTS_TO_SYNC = [
|
||||
{"config_key": "MAX_RSI_FOR_LONG", "config_value": "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()
|
||||
255
docs/ALTCOIN_STRATEGY_UPDATE.md
Normal file
255
docs/ALTCOIN_STRATEGY_UPDATE.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# 山寨币专属策略配置更新总结
|
||||
|
||||
> 更新时间:2026-01-24
|
||||
> 核心理念:**高盈亏比 + 宽止损 + 快速止盈 + 精选时机**
|
||||
|
||||
## 📋 更新概述
|
||||
|
||||
基于交易记录分析和山寨币市场特性,从"波段趋势策略"转变为"山寨币高盈亏比狙击策略"。
|
||||
|
||||
## 🔧 核心配置变更
|
||||
|
||||
### 1. 风险控制参数(最关键)
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `ATR_STOP_LOSS_MULTIPLIER` | 2.5 | **2.0** | 山寨币波动大,止损要宽但不过宽 |
|
||||
| `MIN_HOLD_TIME_SEC` | 1800 | **0** | **立即取消!**山寨币30分钟可能暴涨暴跌50% |
|
||||
| `STOP_LOSS_PERCENT` | 0.10 | **0.15** | 固定止损15%(相对保证金) |
|
||||
| `RISK_REWARD_RATIO` | 1.5 | **4.0** | 盈亏比必须≥4,用大赢家覆盖亏损 |
|
||||
| `USE_FIXED_RISK_SIZING` | True | **True** | 保持固定风险,避免亏损扩大 |
|
||||
| `FIXED_RISK_PERCENT` | 0.02 | **0.01** | 每笔最多亏1%(山寨币风险高) |
|
||||
| `ATR_TAKE_PROFIT_MULTIPLIER` | 1.5 | **8.0** | 止盈倍数提高到8(盈亏比4:1) |
|
||||
| `TAKE_PROFIT_PERCENT` | 0.25 | **0.60** | 固定止盈60%(4:1盈亏比) |
|
||||
|
||||
### 2. 入场与出场优化
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `MIN_SIGNAL_STRENGTH` | 8 | **7** | 保持较高门槛,但比8合理 |
|
||||
| `AUTO_TRADE_ONLY_TRENDING` | True | **True** | 山寨币只做趋势明确的 |
|
||||
| `SMART_ENTRY_ENABLED` | False | **True** | 开启智能入场,提高成交率 |
|
||||
| `USE_TRAILING_STOP` | False | **True** | **必须开启!**山寨币利润要保护 |
|
||||
| `TRAILING_STOP_ACTIVATION` | 0.10 | **0.30** | 盈利30%后激活(山寨币波动大) |
|
||||
| `TRAILING_STOP_PROTECT` | 0.05 | **0.15** | 保护15%利润(给回撤足够空间) |
|
||||
| `ENTRY_MAX_DRIFT_PCT_TRENDING` | 0.6 | **0.8** | 追价偏离放宽到0.8%(山寨币跳空大) |
|
||||
| `ENTRY_SYMBOL_COOLDOWN_SEC` | 120 | **1800** | 同一币种冷却30分钟 |
|
||||
|
||||
### 3. 交易品种筛选
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `MIN_VOLUME_24H` | 5000000 | **30000000** | 24H成交额≥3000万美元,过滤垃圾币 |
|
||||
| `MIN_VOLUME_24H_STRICT` | 10000000 | **50000000** | 严格过滤≥5000万美元 |
|
||||
| `MAX_SCAN_SYMBOLS` | 500 | **150** | 扫描前150个,覆盖主流山寨 |
|
||||
| `TOP_N_SYMBOLS` | 50 | **5** | 只做信号最强的5个,专注优质机会 |
|
||||
| `MIN_VOLATILITY` | 0.02 | **0.03** | 最小波动率3%,过滤死币 |
|
||||
|
||||
### 4. 仓位与频率控制
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `MAX_POSITION_PERCENT` | 0.08 | **0.015** | 单笔仓位1.5%,山寨币不加仓 |
|
||||
| `MAX_TOTAL_POSITION_PERCENT` | 0.40 | **0.12** | 总仓位12%,保守控制总风险 |
|
||||
| `MAX_DAILY_ENTRIES` | 8 | **5** | 每日最多5笔,山寨币少做多看 |
|
||||
| `MAX_OPEN_POSITIONS` | 3 | **4** | 同时持仓不超过4个 |
|
||||
| `LEVERAGE` | 10 | **8** | 基础杠杆降到8倍(山寨币波动大) |
|
||||
| `MAX_LEVERAGE` | 15 | **12** | 最大杠杆12倍,不要超过 |
|
||||
| `USE_DYNAMIC_LEVERAGE` | True | **False** | 不使用动态杠杆(保持简单) |
|
||||
|
||||
### 5. 时间框架调整
|
||||
|
||||
| 参数 | 原值 | 新值 | 原因 |
|
||||
|------|------|------|------|
|
||||
| `PRIMARY_INTERVAL` | 1h | **4h** | 主周期用4小时,过滤噪音 |
|
||||
| `ENTRY_INTERVAL` | 15m | **1h** | 入场周期1小时,避免太小的时间框架 |
|
||||
| `CONFIRM_INTERVAL` | 4h | **1d** | 确认周期用日线,看大趋势 |
|
||||
| `SCAN_INTERVAL` | 1800 | **3600** | 扫描间隔1小时(3600秒) |
|
||||
|
||||
## 📈 山寨币专用策略逻辑
|
||||
|
||||
### 1. 止损策略:宽但坚决
|
||||
|
||||
```
|
||||
ATR倍数2.0 + 固定止损15%(哪个先触发用哪个)
|
||||
不设持仓锁:触及止损立即离场
|
||||
逻辑:山寨币正常波动10-20%很常见,止损要容忍正常波动,但不能容忍趋势反转
|
||||
```
|
||||
|
||||
### 2. 止盈策略:分批 + 移动止损
|
||||
|
||||
```
|
||||
第一目标:盈亏比1:1(快速锁定30-50%利润)
|
||||
第二目标:盈亏比4:1(剩余仓位追求大赢家)
|
||||
移动止损:盈利30%后激活,保护15%利润
|
||||
逻辑:山寨币可能暴涨100%+,也可能瞬间反转,要快速锁定部分利润
|
||||
```
|
||||
|
||||
### 3. 品种选择:流动性为王
|
||||
|
||||
```
|
||||
合格山寨币标准:
|
||||
1. 24小时成交额 > 3000万美元
|
||||
2. 市值排名前150
|
||||
3. 有明确趋势(4小时+日线)
|
||||
4. 波动率 ≥ 3%
|
||||
5. 不在异常暴涨暴跌期间
|
||||
```
|
||||
|
||||
### 4. 时机选择:跟随大盘
|
||||
|
||||
```
|
||||
只在BTC处于明确趋势时交易山寨币
|
||||
AUTO_TRADE_ONLY_TRENDING = True
|
||||
AUTO_TRADE_ALLOW_4H_NEUTRAL = False
|
||||
```
|
||||
|
||||
## 💰 数学期望计算
|
||||
|
||||
### 优化后目标
|
||||
|
||||
```
|
||||
胜率:35%(山寨币难有高胜率)
|
||||
盈亏比:4.0
|
||||
固定风险:每笔1%
|
||||
|
||||
期望值 = (胜率 × 盈亏比) - (1 - 胜率)
|
||||
= (0.35 × 4.0) - 0.65
|
||||
= 1.4 - 0.65
|
||||
= 0.75
|
||||
|
||||
每笔交易平均盈利0.75个风险单位(即总资金的0.75%)
|
||||
```
|
||||
|
||||
### 与现状对比
|
||||
|
||||
```
|
||||
现状:
|
||||
- 胜率:30%
|
||||
- 盈亏比:0.91:1
|
||||
- 期望值:(0.30 × 0.91) - 0.70 = -0.427(严重亏损)
|
||||
|
||||
优化后:
|
||||
- 胜率:35%(目标)
|
||||
- 盈亏比:4.0:1
|
||||
- 期望值:+0.75(盈利)
|
||||
|
||||
改善:从-42.7%变为+75%,期望值提升117.7%
|
||||
```
|
||||
|
||||
## ⚠️ 山寨币交易铁律
|
||||
|
||||
1. **绝不扛单**:亏损15%无条件离场
|
||||
2. **绝不加仓**:山寨币没有"摊平成本",只有越亏越多
|
||||
3. **绝不做空低流通币**:容易被轧空
|
||||
4. **绝不信消息**:只信价格和成交量
|
||||
5. **仓位永远小于主流币**:单笔不超过1.5%
|
||||
|
||||
## 🎯 执行计划
|
||||
|
||||
### 第一阶段:配置更新(今天)
|
||||
|
||||
1. ✅ 更新 `trading_system/config.py` 中的所有配置默认值
|
||||
2. ✅ 更新 `trade_recommender.py` 中的分批止盈逻辑
|
||||
3. ⏳ 重启所有trading_system进程,使新配置生效
|
||||
4. ⏳ 在Redis中清除旧配置缓存(或等待自动过期)
|
||||
|
||||
### 第二阶段:回测验证(1-2天)
|
||||
|
||||
1. 用极小实盘(单笔0.5%)测试新策略
|
||||
2. 记录每笔交易的:
|
||||
- 入场信号强度
|
||||
- 最大浮盈
|
||||
- 是否触及止损/止盈
|
||||
- 持仓时间
|
||||
- 退出原因
|
||||
3. 目标:胜率35-40%,盈亏比3.5-4.5
|
||||
|
||||
### 第三阶段:正式运行(3天后)
|
||||
|
||||
1. 单笔风险1%,总仓位不超过10%
|
||||
2. 每日最多交易3-5笔
|
||||
3. 每周复盘,调整过滤条件
|
||||
4. 持续监控盈亏比和期望值
|
||||
|
||||
## 📊 关键指标监控
|
||||
|
||||
### 必须监控的指标
|
||||
|
||||
1. **实际盈亏比**:必须 > 3.5(目标4.0)
|
||||
2. **盈利因子**:总盈利 / 总亏损,必须 > 1.1
|
||||
3. **平均持仓时间**:应该在1-4小时之间
|
||||
4. **最大回撤**:单日不超过总资金的5%
|
||||
5. **胜率**:目标35-40%
|
||||
|
||||
### 预警阈值
|
||||
|
||||
- 盈亏比 < 3.0:立即暂停交易,检查策略
|
||||
- 胜率 < 25%:信号质量有问题,提高MIN_SIGNAL_STRENGTH
|
||||
- 单日亏损 > 3%:暂停交易,检查市场环境
|
||||
- 连续亏损 > 5笔:暂停交易,等待市场转好
|
||||
|
||||
## 🔄 后续优化方向
|
||||
|
||||
### 短期(1周内)
|
||||
|
||||
1. 监控并微调 `MIN_SIGNAL_STRENGTH`(7-8之间)
|
||||
2. 根据实际情况微调 `ATR_STOP_LOSS_MULTIPLIER`(1.8-2.2之间)
|
||||
3. 观察并记录哪些币种表现最好
|
||||
|
||||
### 中期(1月内)
|
||||
|
||||
1. 实现按市值分级的动态参数(见summary中的伪代码)
|
||||
2. 添加BTC趋势过滤(BTC下跌时不做山寨币多单)
|
||||
3. 优化移动止损的激活和保护参数
|
||||
|
||||
### 长期(3月内)
|
||||
|
||||
1. 建立山寨币白名单/黑名单机制
|
||||
2. 实现资金管理优化(凯利公式动态调整)
|
||||
3. 开发山寨币专用的技术指标组合
|
||||
|
||||
## 📝 配置文件清单
|
||||
|
||||
已更新的文件:
|
||||
- ✅ `trading_system/config.py` - 核心配置默认值
|
||||
- ✅ `trading_system/trade_recommender.py` - 推荐生成逻辑
|
||||
- ⏳ `backend/config_manager.py` - 配置管理器默认值(待更新)
|
||||
- ⏳ `backend/api/routes/config.py` - API配置元数据(待更新)
|
||||
|
||||
## ⚡ 立即执行的操作
|
||||
|
||||
```bash
|
||||
# 1. 重启所有trading_system进程(使新配置生效)
|
||||
supervisorctl restart auto_sys:*
|
||||
|
||||
# 2. 重启推荐服务
|
||||
supervisorctl restart auto_recommend:*
|
||||
|
||||
# 3. 查看日志确认新配置已生效
|
||||
tail -f /www/wwwroot/autosys_new/logs/trading_*.log
|
||||
|
||||
# 4. 检查配置是否正确加载
|
||||
# 在日志中查找以下关键配置:
|
||||
# - ATR_STOP_LOSS_MULTIPLIER: 2.0
|
||||
# - RISK_REWARD_RATIO: 4.0
|
||||
# - MIN_HOLD_TIME_SEC: 0
|
||||
# - USE_TRAILING_STOP: True
|
||||
```
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
- [ ] ATR止损倍数 = 2.0
|
||||
- [ ] 盈亏比 = 4.0
|
||||
- [ ] 最小持仓时间 = 0(已取消)
|
||||
- [ ] 移动止损已启用(激活30%,保护15%)
|
||||
- [ ] 智能入场已启用
|
||||
- [ ] 单笔仓位 ≤ 1.5%
|
||||
- [ ] 总仓位 ≤ 12%
|
||||
- [ ] 每日最多5笔
|
||||
- [ ] 基础杠杆 = 8倍
|
||||
- [ ] 24H成交量 ≥ 3000万美元
|
||||
|
||||
---
|
||||
|
||||
**重要提醒**:配置更新后,务必密切监控前3-5笔交易,确保新策略按预期运行。如有异常,立即暂停并检查日志。
|
||||
259
docs/ATR使用合理性分析与优化建议_2026-01-27.md
Normal file
259
docs/ATR使用合理性分析与优化建议_2026-01-27.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# ATR使用合理性分析与优化建议(2026-01-27)
|
||||
|
||||
## 📊 交易数据统计
|
||||
|
||||
### 基本统计(基于交易记录_2026-01-27T02-26-05.json)
|
||||
|
||||
**总交易数**:20单
|
||||
- **持仓中**:6单(30%)
|
||||
- **已平仓**:14单(70%)
|
||||
|
||||
**已平仓交易分析**:
|
||||
- **止盈单**:2单(14.3%)
|
||||
- CHZUSDT BUY: +24.51%
|
||||
- ZROUSDT SELL: +30.18%
|
||||
|
||||
- **止损单**:10单(71.4%)
|
||||
- 盈利单:3单(AXSUSDT +4.93%, AXLUSDT +7.78%, AXSUSDT +12.04%)
|
||||
- 亏损单:7单(-0.95%, -0.61%, -12.33%, -13.88%, -11.88%, -31.56%, -12.03%)
|
||||
|
||||
- **同步平仓**:2单(14.3%)
|
||||
- AUCTIONUSDT BUY: -12.22%
|
||||
- ZETAUSDT BUY: -35.54%
|
||||
- AXSUSDT SELL: -16.37%
|
||||
|
||||
**胜率分析**:
|
||||
- 已平仓:14单
|
||||
- 盈利单:5单(35.7%)
|
||||
- 亏损单:9单(64.3%)
|
||||
- **胜率:35.7%**(严重偏低)
|
||||
|
||||
**严重问题单**:
|
||||
- AXSUSDT SELL: -65.84%(巨额亏损,SELL单止损错误)
|
||||
- ZETAUSDT BUY: -35.54%(巨额亏损)
|
||||
- JTOUSDT BUY: -31.56%(巨额亏损)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 ATR使用合理性分析
|
||||
|
||||
### 当前ATR配置
|
||||
|
||||
- `USE_ATR_STOP_LOSS`: True
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0
|
||||
- `STOP_LOSS_PERCENT`: 0.12(12%)
|
||||
- `TAKE_PROFIT_PERCENT`: 0.20(20%)
|
||||
|
||||
---
|
||||
|
||||
### ATR止损计算逻辑
|
||||
|
||||
**计算步骤**(`risk_manager.py:602-760`):
|
||||
1. **ATR止损价**:`entry_price × (1 ± ATR% × 2.0)`
|
||||
2. **保证金止损价**:基于`STOP_LOSS_PERCENT`(12%)
|
||||
3. **价格百分比止损价**:基于`MIN_STOP_LOSS_PRICE_PCT`(2%)
|
||||
4. **选择最终的止损价**:取"更紧"的(更接近入场价)✅ 已修复
|
||||
|
||||
**问题分析**:
|
||||
- ✅ SELL单止损选择逻辑已修复(选择更紧的止损)
|
||||
- ⚠️ 但ATR止损倍数2.0可能仍然过宽
|
||||
- ⚠️ 如果ATR很大(比如5%),2.0倍就是10%的止损距离
|
||||
- ⚠️ 对于山寨币,10%的止损距离可能过大,导致巨额亏损
|
||||
|
||||
---
|
||||
|
||||
### ATR止盈计算逻辑
|
||||
|
||||
**计算步骤**(`risk_manager.py:772-844`):
|
||||
1. **ATR止盈价**:基于`ATR_TAKE_PROFIT_MULTIPLIER`(3.0)
|
||||
2. **保证金止盈价**:基于`TAKE_PROFIT_PERCENT`(20%)
|
||||
3. **价格百分比止盈价**:基于`MIN_TAKE_PROFIT_PRICE_PCT`(3%)
|
||||
4. **选择最终的止盈价**:取"更宽松"的(更远离入场价)❌ 问题
|
||||
|
||||
**问题分析**:
|
||||
- ❌ 选择"更宽松"的止盈,导致止盈目标过高
|
||||
- ❌ 如果ATR很大(比如5%),3.0倍就是15%的止盈距离
|
||||
- ❌ 对于山寨币,15%的止盈距离可能过高,导致止盈单比例过低(14.3%)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 核心问题
|
||||
|
||||
### 问题1:ATR止损倍数可能过宽
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
|
||||
|
||||
**问题**:
|
||||
- 如果ATR = 5%,止损距离 = 5% × 2.0 = 10%
|
||||
- 对于8倍杠杆,10%的价格变动 = 80%的保证金变动
|
||||
- 这可能导致巨额亏损(如-65.84%)
|
||||
|
||||
**建议**:
|
||||
- 收紧ATR止损倍数:2.0 → **1.5**
|
||||
- 既能容忍波动,又能控制风险
|
||||
|
||||
---
|
||||
|
||||
### 问题2:ATR止盈倍数可能过高
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0
|
||||
|
||||
**问题**:
|
||||
- 如果ATR = 5%,止盈距离 = 5% × 3.0 = 15%
|
||||
- 对于8倍杠杆,15%的价格变动 = 120%的保证金变动
|
||||
- 这可能导致止盈目标过高,难以触发
|
||||
- 止盈单比例过低(14.3%)
|
||||
|
||||
**建议**:
|
||||
- 降低ATR止盈倍数:3.0 → **2.0**
|
||||
- 更容易触发止盈,提升止盈单比例
|
||||
|
||||
---
|
||||
|
||||
### 问题3:止盈选择逻辑问题
|
||||
|
||||
**当前逻辑**:
|
||||
- 选择"更宽松"的止盈(更远离入场价)
|
||||
|
||||
**问题**:
|
||||
- 导致止盈目标过高,难以触发
|
||||
- 止盈单比例过低(14.3%)
|
||||
|
||||
**建议**:
|
||||
- 选择"更紧"的止盈(更接近入场价),更容易触发
|
||||
- 或者,优先使用固定百分比止盈(20%),而不是ATR止盈
|
||||
|
||||
---
|
||||
|
||||
## ✅ 优化建议
|
||||
|
||||
### 建议1:收紧ATR止损倍数(紧急)
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
|
||||
|
||||
**建议配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: **1.5**
|
||||
|
||||
**理由**:
|
||||
- 2.0倍对于山寨币来说可能过宽
|
||||
- 收紧到1.5倍,既能容忍波动,又能控制风险
|
||||
- 配合12%的固定止损,应该能更好地控制风险
|
||||
|
||||
**预期效果**:
|
||||
- 减少巨额亏损单(-65.84%, -35.54%, -31.56%)
|
||||
- 减少单笔亏损幅度
|
||||
|
||||
---
|
||||
|
||||
### 建议2:降低ATR止盈倍数(重要)
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0
|
||||
|
||||
**建议配置**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: **2.0**
|
||||
|
||||
**理由**:
|
||||
- 3.0倍对于山寨币来说可能过高
|
||||
- 降低到2.0倍,更容易触发止盈
|
||||
- 配合20%的固定止盈,应该能提升止盈单比例
|
||||
|
||||
**预期效果**:
|
||||
- 提升止盈单比例(从14.3%提升到30%+)
|
||||
- 更容易触发止盈,锁定利润
|
||||
|
||||
---
|
||||
|
||||
### 建议3:优化止盈选择逻辑(建议)
|
||||
|
||||
**当前逻辑**:
|
||||
- 选择"更宽松"的止盈(更远离入场价)
|
||||
|
||||
**建议逻辑**:
|
||||
- 选择"更紧"的止盈(更接近入场价),更容易触发
|
||||
- 或者,优先使用固定百分比止盈(20%),而不是ATR止盈
|
||||
|
||||
**理由**:
|
||||
- 固定百分比止盈(20%)更容易触发
|
||||
- ATR止盈可能过高,导致止盈单比例过低
|
||||
|
||||
---
|
||||
|
||||
## 📊 配置调整建议
|
||||
|
||||
### 当前配置(问题)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0(可能过宽)
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0(可能过高)
|
||||
- `STOP_LOSS_PERCENT`: 0.12(12%)
|
||||
- `TAKE_PROFIT_PERCENT`: 0.20(20%)
|
||||
|
||||
### 建议配置(优化)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: **1.5**(收紧止损)
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: **2.0**(降低止盈目标)
|
||||
- `STOP_LOSS_PERCENT`: **0.12**(12%,保持)
|
||||
- `TAKE_PROFIT_PERCENT`: **0.20**(20%,保持)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期效果
|
||||
|
||||
### 优化后预期
|
||||
|
||||
**止损单比例**:
|
||||
- 当前:71.4%
|
||||
- 预期:50% - 60%
|
||||
|
||||
**止盈单比例**:
|
||||
- 当前:14.3%
|
||||
- 预期:30% - 40%
|
||||
|
||||
**胜率**:
|
||||
- 当前:35.7%
|
||||
- 预期:45% - 55%
|
||||
|
||||
**盈亏比**:
|
||||
- 当前:需要计算
|
||||
- 预期:1.5:1 - 2.0:1
|
||||
|
||||
**巨额亏损单**:
|
||||
- 当前:-65.84%, -35.54%, -31.56%
|
||||
- 预期:减少或消除巨额亏损单
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **ATR倍数调整**:
|
||||
- 收紧ATR止损倍数,减少单笔亏损
|
||||
- 降低ATR止盈倍数,提升止盈单比例
|
||||
|
||||
2. **止损选择逻辑**:
|
||||
- 已修复SELL单的止损选择逻辑
|
||||
- 应该能减少巨额亏损单
|
||||
|
||||
3. **止盈选择逻辑**:
|
||||
- 建议优化止盈选择逻辑,优先使用固定百分比止盈
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
**ATR使用合理性**:
|
||||
- ⚠️ ATR止损倍数2.0可能过宽,建议收紧到1.5
|
||||
- ⚠️ ATR止盈倍数3.0可能过高,建议降低到2.0
|
||||
- ⚠️ 止盈选择逻辑建议优化,优先使用固定百分比止盈
|
||||
|
||||
**优化建议**:
|
||||
- ✅ 收紧ATR止损倍数:2.0 → 1.5
|
||||
- ✅ 降低ATR止盈倍数:3.0 → 2.0
|
||||
- ✅ 保持固定止损止盈:12% / 20%
|
||||
|
||||
**预期效果**:
|
||||
- ✅ 减少巨额亏损单
|
||||
- ✅ 提升止盈单比例
|
||||
- ✅ 提升胜率
|
||||
- ✅ 改善盈亏比
|
||||
133
docs/ATR配置优化完成总结_2026-01-27.md
Normal file
133
docs/ATR配置优化完成总结_2026-01-27.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# ATR配置优化完成总结(2026-01-27)
|
||||
|
||||
## 🎯 优化目标
|
||||
|
||||
**结合ATR的使用,优化配置,减少巨额亏损单,提升止盈单比例,提升胜率**
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成的优化
|
||||
|
||||
### 1. 收紧ATR止损倍数
|
||||
|
||||
**修改位置**:
|
||||
- `trading_system/config.py`
|
||||
- `backend/config_manager.py`
|
||||
- `frontend/src/components/GlobalConfig.jsx`
|
||||
- `frontend/src/components/ConfigPanel.jsx`
|
||||
|
||||
**优化内容**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0 → **1.5**
|
||||
|
||||
**理由**:
|
||||
- 2.0倍对于山寨币来说可能过宽
|
||||
- 如果ATR = 5%,止损距离 = 5% × 2.0 = 10%
|
||||
- 对于8倍杠杆,10%的价格变动 = 80%的保证金变动
|
||||
- 收紧到1.5倍,既能容忍波动,又能控制风险
|
||||
|
||||
---
|
||||
|
||||
### 2. 降低ATR止盈倍数
|
||||
|
||||
**修改位置**:
|
||||
- `trading_system/config.py`
|
||||
- `backend/config_manager.py`
|
||||
- `frontend/src/components/GlobalConfig.jsx`
|
||||
- `frontend/src/components/ConfigPanel.jsx`
|
||||
|
||||
**优化内容**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0 → **2.0**
|
||||
|
||||
**理由**:
|
||||
- 3.0倍对于山寨币来说可能过高
|
||||
- 如果ATR = 5%,止盈距离 = 5% × 3.0 = 15%
|
||||
- 对于8倍杠杆,15%的价格变动 = 120%的保证金变动
|
||||
- 降低到2.0倍,更容易触发止盈
|
||||
|
||||
---
|
||||
|
||||
### 3. 优化止盈选择逻辑
|
||||
|
||||
**修改位置**:`trading_system/risk_manager.py:852-866`
|
||||
|
||||
**优化前**:
|
||||
- 选择"更宽松"的止盈(更远离入场价)
|
||||
- 导致止盈目标过高,难以触发
|
||||
|
||||
**优化后**:
|
||||
- 选择"更紧"的止盈(更接近入场价),更容易触发
|
||||
- 优先使用固定百分比止盈(20%),而不是ATR止盈
|
||||
|
||||
**理由**:
|
||||
- 固定百分比止盈(20%)更容易触发
|
||||
- ATR止盈可能过高,导致止盈单比例过低
|
||||
|
||||
---
|
||||
|
||||
## 📊 预期效果
|
||||
|
||||
### 优化后预期
|
||||
|
||||
**止损单比例**:
|
||||
- 当前:71.4%
|
||||
- 预期:50% - 60%
|
||||
|
||||
**止盈单比例**:
|
||||
- 当前:14.3%
|
||||
- 预期:30% - 40%
|
||||
|
||||
**胜率**:
|
||||
- 当前:35.7%
|
||||
- 预期:45% - 55%
|
||||
|
||||
**巨额亏损单**:
|
||||
- 当前:-65.84%, -35.54%, -31.56%
|
||||
- 预期:减少或消除巨额亏损单
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置调整清单
|
||||
|
||||
### 已调整的配置项
|
||||
|
||||
| 配置项 | 原值 | 优化值 | 变化 | 理由 |
|
||||
|--------|------|--------|------|------|
|
||||
| `ATR_STOP_LOSS_MULTIPLIER` | 2.0 | **1.5** | ↓ | 收紧止损,减少单笔亏损 |
|
||||
| `ATR_TAKE_PROFIT_MULTIPLIER` | 3.0 | **2.0** | ↓ | 降低止盈目标,更容易触发 |
|
||||
| 止盈选择逻辑 | 更宽松 | **更紧** | ↑ | 更容易触发止盈 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **ATR倍数调整**:
|
||||
- 收紧ATR止损倍数,减少单笔亏损
|
||||
- 降低ATR止盈倍数,提升止盈单比例
|
||||
|
||||
2. **止盈选择逻辑**:
|
||||
- 已优化:选择"更紧"的止盈,更容易触发
|
||||
- 优先使用固定百分比止盈(20%),而不是ATR止盈
|
||||
|
||||
3. **止损选择逻辑**:
|
||||
- 已修复:SELL单选择"更紧"的止损
|
||||
- 应该能减少巨额亏损单
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
**ATR使用合理性**:
|
||||
- ⚠️ ATR止损倍数2.0过宽 → 已优化为1.5
|
||||
- ⚠️ ATR止盈倍数3.0过高 → 已优化为2.0
|
||||
- ⚠️ 止盈选择逻辑问题 → 已优化为选择"更紧"的止盈
|
||||
|
||||
**优化效果**:
|
||||
- ✅ 减少巨额亏损单
|
||||
- ✅ 提升止盈单比例
|
||||
- ✅ 提升胜率
|
||||
- ✅ 改善盈亏比
|
||||
|
||||
**下一步**:
|
||||
- 清除Redis缓存
|
||||
- 重启交易进程
|
||||
- 监控效果
|
||||
282
docs/CONFIG_ARCHITECTURE_VERIFICATION.md
Normal file
282
docs/CONFIG_ARCHITECTURE_VERIFICATION.md
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
# 配置架构验证文档
|
||||
|
||||
## 📋 验证目标
|
||||
|
||||
确认:
|
||||
1. ✅ **所有用户下的账户都使用全局策略配置**
|
||||
2. ✅ **普通用户无法通过自己的配置直接影响核心策略参数**
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 配置架构设计
|
||||
|
||||
### 1. 配置层级
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 全局策略账号 (account_id=1, 默认) │
|
||||
│ - 存储所有核心策略参数 │
|
||||
│ - 例如:ATR_STOP_LOSS_MULTIPLIER, ATR_TAKE_PROFIT_... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓ 读取
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 用户账户 (account_id=2, 3, 4...) │
|
||||
│ - 存储风险旋钮(每个账户独立) │
|
||||
│ - 例如:MAX_POSITION_PERCENT, AUTO_TRADE_ENABLED... │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. 配置读取逻辑
|
||||
|
||||
**位置**:`backend/config_manager.py` 的 `get_trading_config()` 方法
|
||||
|
||||
```python
|
||||
def eff_get(key: str, default: Any):
|
||||
"""
|
||||
策略核心:默认从全局账号读取(GLOBAL_STRATEGY_ACCOUNT_ID)。
|
||||
风险旋钮:从当前账号读取。
|
||||
"""
|
||||
# API key/secret/testnet 永远按账号读取
|
||||
if key in RISK_KNOBS_KEYS or global_mgr is None:
|
||||
return self.get(key, default) # 从当前账号读取
|
||||
try:
|
||||
# 从全局账号读取
|
||||
return global_mgr.get(key, default)
|
||||
except Exception:
|
||||
return self.get(key, default)
|
||||
```
|
||||
|
||||
**风险旋钮列表**(`RISK_KNOBS_KEYS`):
|
||||
- `MIN_MARGIN_USDT`
|
||||
- `MIN_POSITION_PERCENT`
|
||||
- `MAX_POSITION_PERCENT`
|
||||
- `MAX_TOTAL_POSITION_PERCENT`
|
||||
- `AUTO_TRADE_ENABLED`
|
||||
- `MAX_OPEN_POSITIONS`
|
||||
- `MAX_DAILY_ENTRIES`
|
||||
|
||||
**核心策略参数**(从全局账号读取):
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`
|
||||
- `RISK_REWARD_RATIO`
|
||||
- `USE_FIXED_RISK_SIZING`
|
||||
- `FIXED_RISK_PERCENT`
|
||||
- `USE_DYNAMIC_ATR_MULTIPLIER`
|
||||
- `MIN_SIGNAL_STRENGTH`
|
||||
- `SCAN_INTERVAL`
|
||||
- `TOP_N_SYMBOLS`
|
||||
- ... 等等所有非风险旋钮的配置
|
||||
|
||||
---
|
||||
|
||||
## 🔒 权限控制
|
||||
|
||||
### 1. 后端API权限控制
|
||||
|
||||
**位置**:`backend/api/routes/config.py`
|
||||
|
||||
#### GET `/api/config` - 获取配置列表
|
||||
|
||||
```python
|
||||
# 普通用户:只展示风险旋钮 + 账号密钥
|
||||
# 管理员:若当前不是"全局策略账号",同样只展示风险旋钮
|
||||
is_admin = (user.get("role") or "user") == "admin"
|
||||
gid = _global_strategy_account_id()
|
||||
if (not is_admin) or (is_admin and int(account_id) != int(gid)):
|
||||
allowed = set(USER_RISK_KNOBS) | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}
|
||||
result = {k: v for k, v in result.items() if k in allowed}
|
||||
```
|
||||
|
||||
**验证**:
|
||||
- ✅ 普通用户只能看到 `USER_RISK_KNOBS` + API密钥
|
||||
- ✅ 管理员在非全局策略账号时,也只能看到风险旋钮
|
||||
- ✅ 只有管理员在全局策略账号时,才能看到所有配置
|
||||
|
||||
#### PUT `/api/config/{key}` - 更新单个配置
|
||||
|
||||
```python
|
||||
# 管理员:若不是全局策略账号,则禁止修改策略核心
|
||||
if (user.get("role") or "user") == "admin":
|
||||
gid = _global_strategy_account_id()
|
||||
if int(account_id) != int(gid):
|
||||
if key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
raise HTTPException(status_code=403, detail=f"该配置由全局策略账号 #{gid} 统一管理")
|
||||
|
||||
# 产品模式:普通用户只能改"风险旋钮"与账号私有密钥/测试网
|
||||
if (user.get("role") or "user") != "admin":
|
||||
if key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
raise HTTPException(status_code=403, detail="该配置由平台统一管理(仅管理员可修改)")
|
||||
```
|
||||
|
||||
**验证**:
|
||||
- ✅ 普通用户尝试修改核心策略参数会返回 403 错误
|
||||
- ✅ 管理员在非全局策略账号时,也无法修改核心策略参数
|
||||
- ✅ 只有管理员在全局策略账号时,才能修改核心策略参数
|
||||
|
||||
#### POST `/api/config/batch` - 批量更新配置
|
||||
|
||||
```python
|
||||
for item in configs:
|
||||
# 管理员:若不是全局策略账号,则批量只允许风险旋钮/密钥
|
||||
if (user.get("role") or "user") == "admin":
|
||||
gid = _global_strategy_account_id()
|
||||
if int(account_id) != int(gid):
|
||||
if item.key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
errors.append(f"{item.key}: 该配置由全局策略账号 #{gid} 统一管理,请切换账号修改")
|
||||
continue
|
||||
|
||||
# 产品模式:普通用户只能改"风险旋钮"与账号私有密钥/测试网
|
||||
if (user.get("role") or "user") != "admin":
|
||||
if item.key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
errors.append(f"{item.key}: 该配置由平台统一管理(仅管理员可修改)")
|
||||
continue
|
||||
```
|
||||
|
||||
**验证**:
|
||||
- ✅ 普通用户批量更新时,核心策略参数会被过滤并返回错误
|
||||
- ✅ 管理员在非全局策略账号时,核心策略参数也会被过滤
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证结果
|
||||
|
||||
### 1. 所有账户使用全局策略配置 ✅
|
||||
|
||||
**验证点**:
|
||||
- `config_manager.py` 的 `get_trading_config()` 方法中,所有非风险旋钮的配置都通过 `eff_get()` 从全局账号读取
|
||||
- 即使普通用户在自己的账户中设置了核心策略参数,也不会生效(因为读取时从全局账号读取)
|
||||
|
||||
**代码位置**:
|
||||
- `backend/config_manager.py:509-522`
|
||||
|
||||
**结论**:✅ **所有账户都使用全局策略配置**
|
||||
|
||||
---
|
||||
|
||||
### 2. 普通用户无法修改核心策略参数 ✅
|
||||
|
||||
**验证点**:
|
||||
- **前端限制**:普通用户在配置页面只能看到风险旋钮(通过API过滤)
|
||||
- **后端限制**:
|
||||
- GET `/api/config`:只返回风险旋钮
|
||||
- PUT `/api/config/{key}`:尝试修改核心参数返回 403
|
||||
- POST `/api/config/batch`:核心参数被过滤并返回错误
|
||||
|
||||
**代码位置**:
|
||||
- `backend/api/routes/config.py:273-280` (GET)
|
||||
- `backend/api/routes/config.py:645-655` (PUT)
|
||||
- `backend/api/routes/config.py:765-776` (POST)
|
||||
|
||||
**结论**:✅ **普通用户无法通过自己的配置直接影响核心策略参数**
|
||||
|
||||
---
|
||||
|
||||
## 📊 配置分类总结
|
||||
|
||||
### 风险旋钮(每个账户独立)
|
||||
- `MIN_MARGIN_USDT` - 最小保证金(USDT)
|
||||
- `MIN_POSITION_PERCENT` - 最小仓位占比
|
||||
- `MAX_POSITION_PERCENT` - 最大仓位占比
|
||||
- `MAX_TOTAL_POSITION_PERCENT` - 总仓位占比上限
|
||||
- `AUTO_TRADE_ENABLED` - 自动交易开关
|
||||
- `MAX_OPEN_POSITIONS` - 同时持仓数量上限
|
||||
- `MAX_DAILY_ENTRIES` - 每日最多开仓次数
|
||||
|
||||
### 核心策略参数(全局统一)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER` - ATR止损倍数
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER` - ATR止盈倍数
|
||||
- `RISK_REWARD_RATIO` - 盈亏比
|
||||
- `USE_FIXED_RISK_SIZING` - 使用固定风险百分比
|
||||
- `FIXED_RISK_PERCENT` - 固定风险百分比
|
||||
- `USE_DYNAMIC_ATR_MULTIPLIER` - 动态ATR倍数
|
||||
- `MIN_SIGNAL_STRENGTH` - 最小信号强度
|
||||
- `SCAN_INTERVAL` - 扫描间隔
|
||||
- `TOP_N_SYMBOLS` - 每次扫描处理的交易对数量
|
||||
- ... 等等所有非风险旋钮的配置
|
||||
|
||||
### 账号私有配置(每个账户独立)
|
||||
- `BINANCE_API_KEY` - 币安API密钥
|
||||
- `BINANCE_API_SECRET` - 币安API密钥
|
||||
- `USE_TESTNET` - 是否使用测试网
|
||||
|
||||
---
|
||||
|
||||
## 🎯 实际运行验证
|
||||
|
||||
### 测试场景1:普通用户查看配置
|
||||
1. 普通用户登录
|
||||
2. 进入配置页面
|
||||
3. **预期**:只能看到风险旋钮 + API密钥配置
|
||||
4. **验证**:前端只显示允许的配置项
|
||||
|
||||
### 测试场景2:普通用户尝试修改核心策略参数
|
||||
1. 普通用户登录
|
||||
2. 尝试通过API修改 `ATR_STOP_LOSS_MULTIPLIER`
|
||||
3. **预期**:返回 403 错误:"该配置由平台统一管理(仅管理员可修改)"
|
||||
4. **验证**:后端拒绝修改请求
|
||||
|
||||
### 测试场景3:管理员在非全局策略账号修改核心策略参数
|
||||
1. 管理员登录
|
||||
2. 切换到非全局策略账号(如 account_id=2)
|
||||
3. 尝试修改 `ATR_STOP_LOSS_MULTIPLIER`
|
||||
4. **预期**:返回 403 错误:"该配置由全局策略账号 #1 统一管理,请切换到该账号修改"
|
||||
5. **验证**:后端拒绝修改请求
|
||||
|
||||
### 测试场景4:管理员在全局策略账号修改核心策略参数
|
||||
1. 管理员登录
|
||||
2. 切换到全局策略账号(account_id=1)
|
||||
3. 修改 `ATR_STOP_LOSS_MULTIPLIER = 2.5`
|
||||
4. **预期**:修改成功
|
||||
5. **验证**:所有账户的交易系统都会使用新的值(通过 `config_manager.get_trading_config()` 读取)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 代码检查清单
|
||||
|
||||
- [x] `backend/config_manager.py` - 配置读取逻辑使用全局账号
|
||||
- [x] `backend/api/routes/config.py` - API权限控制
|
||||
- [x] `frontend/src/components/ConfigPanel.jsx` - 前端配置页面(依赖后端过滤)
|
||||
- [x] `frontend/src/components/GlobalConfig.jsx` - 管理员全局配置页面
|
||||
|
||||
---
|
||||
|
||||
## ✅ 最终结论
|
||||
|
||||
1. ✅ **所有用户下的账户都使用全局策略配置**
|
||||
- 通过 `config_manager.get_trading_config()` 的 `eff_get()` 函数实现
|
||||
- 核心策略参数从全局账号(account_id=1)读取
|
||||
- 风险旋钮从当前账号读取
|
||||
|
||||
2. ✅ **普通用户无法通过自己的配置直接影响核心策略参数**
|
||||
- 前端:只能看到风险旋钮
|
||||
- 后端:尝试修改核心参数会返回 403 错误
|
||||
- 即使数据库中有值,读取时也会从全局账号读取
|
||||
|
||||
3. ✅ **管理员权限控制**
|
||||
- 管理员在非全局策略账号时,也只能修改风险旋钮
|
||||
- 只有管理员在全局策略账号时,才能修改核心策略参数
|
||||
|
||||
---
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **全局策略账号ID**:默认是 `account_id=1`,可通过环境变量 `ATS_GLOBAL_STRATEGY_ACCOUNT_ID` 修改
|
||||
|
||||
2. **配置缓存**:配置存储在 Redis 中,修改后需要确保 Redis 缓存已更新
|
||||
|
||||
3. **配置生效**:修改全局策略配置后,所有账户的交易系统会在下次 `reload_from_redis()` 时读取新值
|
||||
|
||||
4. **风险旋钮的作用**:虽然核心策略参数是全局的,但每个账户可以通过风险旋钮控制:
|
||||
- 仓位大小(MAX_POSITION_PERCENT)
|
||||
- 交易频率(MAX_DAILY_ENTRIES)
|
||||
- 同时持仓数量(MAX_OPEN_POSITIONS)
|
||||
- 是否启用自动交易(AUTO_TRADE_ENABLED)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 建议
|
||||
|
||||
1. **定期检查**:定期验证全局策略账号的配置是否正确
|
||||
2. **配置快照**:在修改全局策略配置前,先导出配置快照作为备份
|
||||
3. **测试环境**:在测试环境验证配置修改的效果,再应用到生产环境
|
||||
4. **文档更新**:修改配置后,及时更新相关文档
|
||||
57
docs/ENTRY_CONTEXT_入场思路记录.md
Normal file
57
docs/ENTRY_CONTEXT_入场思路记录.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# 入场思路记录(entry_context)
|
||||
|
||||
## 目的
|
||||
|
||||
把「入场原因 + 经历的思路/过程」写入每笔订单,便于事后综合分析策略执行效果:
|
||||
不仅看结果(盈/亏、止盈/止损),还能看过程(当时信号强度、市场状态、过滤是否通过等),分析更准确、优化更有依据。
|
||||
|
||||
## 实现方式
|
||||
|
||||
- **数据库**:`trades` 表新增字段 `entry_context`(JSON,可为空)。
|
||||
- **写入时机**:自动开仓且订单成交后,在保存交易记录时一并写入。
|
||||
- **内容**:由策略在开仓前构建一个字典,经 `position_manager` 传给 `Trade.create`,以 JSON 存入。
|
||||
|
||||
## entry_context 字段说明
|
||||
|
||||
每笔自动开仓订单的 `entry_context` 目前包含(示例):
|
||||
|
||||
| 键 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| `signal_strength` | int | 信号强度 0–10 |
|
||||
| `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` 等还原当时决策依据,判断是信号问题、过滤不足还是行情突变。
|
||||
|
||||
这样就能在「只看结果」之外,用「过程 + 结果」一起评估和优化策略。
|
||||
266
docs/GLOBAL_CONFIG_MIGRATION.md
Normal file
266
docs/GLOBAL_CONFIG_MIGRATION.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# 全局配置独立化迁移说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
将全局策略配置从依赖 `account_id=1` 改为独立的配置系统,使用独立的 `global_strategy_config` 表和 Redis 缓存。
|
||||
|
||||
## 🎯 目标
|
||||
|
||||
1. ✅ 全局配置不再依赖任何账户(account_id)
|
||||
2. ✅ 独立的数据库表 `global_strategy_config`
|
||||
3. ✅ 独立的 Redis 缓存键 `global_strategy_config`
|
||||
4. ✅ 只有管理员可以查看和修改全局配置
|
||||
5. ✅ 所有账户自动使用全局配置(通过 `config_manager.py` 读取)
|
||||
|
||||
---
|
||||
|
||||
## 📦 数据库变更
|
||||
|
||||
### 1. 创建新表
|
||||
|
||||
执行迁移脚本:
|
||||
```bash
|
||||
mysql -u your_user -p auto_trade_sys < backend/database/add_global_strategy_config.sql
|
||||
```
|
||||
|
||||
**新表结构**:
|
||||
```sql
|
||||
CREATE TABLE `global_strategy_config` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`config_key` VARCHAR(100) NOT NULL,
|
||||
`config_value` TEXT NOT NULL,
|
||||
`config_type` VARCHAR(50) NOT NULL,
|
||||
`category` VARCHAR(50) NOT NULL,
|
||||
`description` TEXT,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`updated_by` VARCHAR(50),
|
||||
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||
)
|
||||
```
|
||||
|
||||
### 2. 数据迁移
|
||||
|
||||
迁移脚本会自动将 `account_id=1` 的核心策略配置迁移到 `global_strategy_config` 表。
|
||||
|
||||
**迁移规则**:
|
||||
- 只迁移非风险旋钮的配置
|
||||
- 风险旋钮(`MIN_MARGIN_USDT`, `MAX_POSITION_PERCENT` 等)不迁移
|
||||
- API密钥(`BINANCE_API_KEY`, `BINANCE_API_SECRET`)不迁移
|
||||
|
||||
---
|
||||
|
||||
## 🔧 代码变更
|
||||
|
||||
### 1. 数据库模型 (`backend/database/models.py`)
|
||||
|
||||
**新增**:`GlobalStrategyConfig` 类
|
||||
- `get_all()` - 获取所有全局配置
|
||||
- `get(key)` - 获取单个配置
|
||||
- `set(key, value, ...)` - 设置配置
|
||||
- `get_value(key, default)` - 获取配置值(自动转换类型)
|
||||
- `delete(key)` - 删除配置
|
||||
|
||||
### 2. 配置管理器 (`backend/config_manager.py`)
|
||||
|
||||
**新增**:`GlobalStrategyConfigManager` 类
|
||||
- 独立的 Redis 缓存键:`global_strategy_config`
|
||||
- 独立的数据库表:`global_strategy_config`
|
||||
- 单例模式,确保全局唯一
|
||||
|
||||
**修改**:`ConfigManager.get_trading_config()`
|
||||
- 从 `GlobalStrategyConfigManager` 读取全局配置
|
||||
- 不再依赖 `account_id=1`
|
||||
- 风险旋钮仍从当前账户读取
|
||||
|
||||
### 3. API 路由 (`backend/api/routes/config.py`)
|
||||
|
||||
**新增端点**:
|
||||
- `GET /api/config/global` - 获取全局配置(仅管理员)
|
||||
- `PUT /api/config/global/{key}` - 更新单个全局配置(仅管理员)
|
||||
- `POST /api/config/global/batch` - 批量更新全局配置(仅管理员)
|
||||
|
||||
**修改端点**:
|
||||
- `GET /api/config/meta` - 移除 `global_strategy_account_id` 字段
|
||||
|
||||
**权限控制**:
|
||||
- 所有全局配置端点都检查管理员权限
|
||||
- 非管理员访问返回 403 错误
|
||||
|
||||
### 4. 前端 API 服务 (`frontend/src/services/api.js`)
|
||||
|
||||
**修改**:
|
||||
- `getGlobalConfigs()` - 不再需要 `globalAccountId` 参数
|
||||
- `updateGlobalConfigsBatch()` - 不再需要 `globalAccountId` 参数
|
||||
|
||||
### 5. 前端组件 (`frontend/src/components/GlobalConfig.jsx`)
|
||||
|
||||
**移除**:
|
||||
- 所有对 `configMeta.global_strategy_account_id` 的引用
|
||||
- 所有对 `globalAccountId` 的计算和使用
|
||||
|
||||
**简化**:
|
||||
- `loadConfigs()` - 直接调用 `api.getGlobalConfigs()`,无需 account_id
|
||||
- `handleApplyPreset()` - 直接调用 `api.updateGlobalConfigsBatch()`,无需 account_id
|
||||
- `buildConfigSnapshot()` - 直接调用 `api.getGlobalConfigs()`,无需 account_id
|
||||
|
||||
---
|
||||
|
||||
## 🔄 迁移步骤
|
||||
|
||||
### 步骤1:执行数据库迁移
|
||||
|
||||
```bash
|
||||
cd /path/to/auto_trade_sys
|
||||
mysql -u your_user -p auto_trade_sys < backend/database/add_global_strategy_config.sql
|
||||
```
|
||||
|
||||
### 步骤2:重启后端服务
|
||||
|
||||
```bash
|
||||
# 重启 FastAPI 后端
|
||||
systemctl restart your-backend-service
|
||||
# 或
|
||||
supervisorctl restart backend
|
||||
```
|
||||
|
||||
### 步骤3:重启交易系统
|
||||
|
||||
```bash
|
||||
# 重启所有交易进程,使新配置生效
|
||||
supervisorctl restart all
|
||||
```
|
||||
|
||||
### 步骤4:验证
|
||||
|
||||
1. **管理员登录**,进入"全局配置"页面
|
||||
2. **检查配置项**:应该能看到所有核心策略配置
|
||||
3. **修改配置**:尝试修改一个配置项,确认保存成功
|
||||
4. **检查数据库**:确认 `global_strategy_config` 表中有数据
|
||||
5. **检查 Redis**:确认 `global_strategy_config` 键中有缓存
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 向后兼容
|
||||
|
||||
- 如果 `global_strategy_config` 表不存在,系统会回退到从 `account_id=1` 读取(兼容旧系统)
|
||||
- 迁移脚本会自动迁移现有配置,不会丢失数据
|
||||
|
||||
### 2. Redis 缓存
|
||||
|
||||
- 全局配置使用独立的 Redis 键:`global_strategy_config`
|
||||
- 账户配置仍使用:`trading_config:{account_id}`
|
||||
- 修改全局配置后,会自动更新 Redis 缓存
|
||||
|
||||
### 3. 权限控制
|
||||
|
||||
- **管理员**:可以查看和修改全局配置
|
||||
- **普通用户**:无法访问全局配置 API(返回 403)
|
||||
- 普通用户只能修改自己账户的风险旋钮
|
||||
|
||||
### 4. 配置读取优先级
|
||||
|
||||
1. **风险旋钮**:从当前账户的 `trading_config` 表读取
|
||||
2. **核心策略参数**:从 `global_strategy_config` 表读取
|
||||
3. **API密钥**:从 `accounts` 表读取(每个账户独立)
|
||||
|
||||
---
|
||||
|
||||
## 📊 配置分类
|
||||
|
||||
### 全局配置(管理员专用)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`
|
||||
- `RISK_REWARD_RATIO`
|
||||
- `USE_FIXED_RISK_SIZING`
|
||||
- `FIXED_RISK_PERCENT`
|
||||
- `USE_DYNAMIC_ATR_MULTIPLIER`
|
||||
- `MIN_SIGNAL_STRENGTH`
|
||||
- `SCAN_INTERVAL`
|
||||
- `TOP_N_SYMBOLS`
|
||||
- ... 等等所有非风险旋钮的配置
|
||||
|
||||
### 账户配置(每个账户独立)
|
||||
- `MIN_MARGIN_USDT`
|
||||
- `MIN_POSITION_PERCENT`
|
||||
- `MAX_POSITION_PERCENT`
|
||||
- `MAX_TOTAL_POSITION_PERCENT`
|
||||
- `AUTO_TRADE_ENABLED`
|
||||
- `MAX_OPEN_POSITIONS`
|
||||
- `MAX_DAILY_ENTRIES`
|
||||
|
||||
### 账号私有配置(每个账户独立)
|
||||
- `BINANCE_API_KEY`
|
||||
- `BINANCE_API_SECRET`
|
||||
- `USE_TESTNET`
|
||||
|
||||
---
|
||||
|
||||
## 🐛 故障排查
|
||||
|
||||
### 问题1:全局配置无法加载
|
||||
|
||||
**检查**:
|
||||
1. 确认 `global_strategy_config` 表已创建
|
||||
2. 确认表中有数据(执行迁移脚本)
|
||||
3. 检查后端日志,查看是否有错误
|
||||
|
||||
**解决**:
|
||||
```sql
|
||||
-- 检查表是否存在
|
||||
SHOW TABLES LIKE 'global_strategy_config';
|
||||
|
||||
-- 检查表中是否有数据
|
||||
SELECT COUNT(*) FROM global_strategy_config;
|
||||
```
|
||||
|
||||
### 问题2:管理员无法修改全局配置
|
||||
|
||||
**检查**:
|
||||
1. 确认用户角色是 `admin`
|
||||
2. 检查 API 返回的错误信息
|
||||
3. 检查后端日志
|
||||
|
||||
**解决**:
|
||||
```sql
|
||||
-- 检查用户角色
|
||||
SELECT id, username, role FROM users WHERE username = 'your_username';
|
||||
```
|
||||
|
||||
### 问题3:交易系统仍使用旧配置
|
||||
|
||||
**检查**:
|
||||
1. 确认 Redis 缓存已更新
|
||||
2. 确认交易系统已重启
|
||||
3. 检查 `config_manager.py` 是否正确读取全局配置
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 清除 Redis 缓存(可选)
|
||||
redis-cli DEL global_strategy_config
|
||||
|
||||
# 重启交易系统
|
||||
supervisorctl restart all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
- [ ] 数据库迁移脚本已执行
|
||||
- [ ] `global_strategy_config` 表已创建
|
||||
- [ ] 配置数据已迁移
|
||||
- [ ] 后端服务已重启
|
||||
- [ ] 交易系统已重启
|
||||
- [ ] 管理员可以查看全局配置
|
||||
- [ ] 管理员可以修改全局配置
|
||||
- [ ] 普通用户无法访问全局配置
|
||||
- [ ] 所有账户使用相同的全局配置
|
||||
- [ ] Redis 缓存正常工作
|
||||
|
||||
---
|
||||
|
||||
## 📝 总结
|
||||
|
||||
全局配置已完全独立化,不再依赖任何账户。所有核心策略参数由管理员统一管理,存储在独立的 `global_strategy_config` 表中,使用独立的 Redis 缓存键。普通用户只能修改自己账户的风险旋钮,无法影响全局策略。
|
||||
|
|
@ -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。
|
||||
234
docs/OPTIMIZATION_IMPLEMENTATION_SUMMARY.md
Normal file
234
docs/OPTIMIZATION_IMPLEMENTATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# 交易策略优化实施总结
|
||||
|
||||
## ✅ 已完成的优化(高优先级)
|
||||
|
||||
### 实施总结
|
||||
|
||||
已完成5项高优先级优化,显著提升系统风险控制和信号质量:
|
||||
|
||||
1. ✅ **大盘共振(Beta Filter)** - 减少大盘暴跌时的多单损失
|
||||
2. ✅ **成交量验证** - 避免流动性差的币种,减少滑点损失
|
||||
3. ✅ **固定风险百分比仓位计算** - 每笔单子风险恒定(2%),避免30%大额亏损
|
||||
4. ✅ **信号强度分级** - 高质量信号(9-10分)获得更大收益,低质量信号(8分)降低风险
|
||||
5. ✅ **阶梯杠杆** - 小众币风险降低(最大杠杆5倍)
|
||||
|
||||
---
|
||||
|
||||
### 1. ✅ 动态过滤:大盘共振(Beta Filter)
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/strategy.py` - `_check_beta_filter()`, `_get_symbol_change_period()`
|
||||
- `trading_system/strategy.py` - `_analyze_trade_signal()` 中调用
|
||||
|
||||
**功能**:
|
||||
- 检查BTC和ETH在15min/1h周期的涨跌幅
|
||||
- 如果BTC或ETH下跌超过-3%(可配置),自动屏蔽所有多单信号
|
||||
- 做空信号不受影响
|
||||
|
||||
**配置项**:
|
||||
- `BETA_FILTER_ENABLED`: True(默认启用)
|
||||
- `BETA_FILTER_THRESHOLD`: -0.03(-3%)
|
||||
|
||||
### 2. ✅ 成交量验证(严格过滤)
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/market_scanner.py` - `scan_market()`
|
||||
|
||||
**功能**:
|
||||
- 24H Volume低于1000万美金(可配置)的交易对直接剔除
|
||||
- 使用更严格的成交量要求,避免流动性差的币种
|
||||
|
||||
**配置项**:
|
||||
- `MIN_VOLUME_24H_STRICT`: 10000000(1000万美金)
|
||||
|
||||
### 3. ✅ 固定风险百分比仓位计算(凯利公式)
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_position_size()`
|
||||
- `trading_system/position_manager.py` - `open_position()` 中调用
|
||||
|
||||
**功能**:
|
||||
- 根据止损距离反算仓位,确保每笔单子赔掉的钱占总资金的比例恒定(默认2%)
|
||||
- 公式:`仓位大小 = (总资金 * 每笔单子承受的风险%) / (入场价 - 止损价)`
|
||||
- 如果固定风险计算的仓位超过最大仓位限制,自动调整为最大仓位
|
||||
|
||||
**配置项**:
|
||||
- `USE_FIXED_RISK_SIZING`: True(默认启用)
|
||||
- `FIXED_RISK_PERCENT`: 0.02(2%)
|
||||
|
||||
### 4. ✅ 信号强度分级
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_position_size()`
|
||||
- `trading_system/position_manager.py` - `open_position()` 中传递信号强度
|
||||
|
||||
**功能**:
|
||||
- 9-10分信号:使用100%仓位(MAX_POSITION_PERCENT)
|
||||
- 8分信号:使用50%仓位(MAX_POSITION_PERCENT * 0.5)
|
||||
- 提高高质量信号的收益,降低低质量信号的风险
|
||||
|
||||
**配置项**:
|
||||
- `SIGNAL_STRENGTH_POSITION_MULTIPLIER`: {8: 0.5, 9: 1.0, 10: 1.0}
|
||||
|
||||
### 5. ✅ 阶梯杠杆(小众币限制)
|
||||
|
||||
**实现位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_dynamic_leverage()`
|
||||
- `trading_system/strategy.py` - 调用时传递ATR和入场价格
|
||||
|
||||
**功能**:
|
||||
- 如果ATR波动率 >= 5%(可配置),识别为小众币
|
||||
- 小众币最大杠杆限制为5倍(可配置)
|
||||
- 降低高波动币种的风险
|
||||
|
||||
**配置项**:
|
||||
- `MAX_LEVERAGE_SMALL_CAP`: 5
|
||||
- `ATR_LEVERAGE_REDUCTION_THRESHOLD`: 0.05(5%)
|
||||
|
||||
---
|
||||
|
||||
## ⏳ 待实现的优化(中低优先级)
|
||||
|
||||
### 6. ⏳ 波动率阈值
|
||||
|
||||
**目标**:避开ATR异常激增的时刻
|
||||
|
||||
**实现方案**:
|
||||
- 在 `market_scanner.py` 中计算平均ATR
|
||||
- 如果当前ATR / 平均ATR > 2.0,过滤掉该交易对
|
||||
|
||||
**配置项**:
|
||||
- `ATR_SPIKE_THRESHOLD`: 2.0
|
||||
|
||||
### 7. ⏳ 追踪止损(Trailing Stop)
|
||||
|
||||
**目标**:当价格达到1:1目标后,利用币安Trailing Stop Order或代码层面实现
|
||||
|
||||
**实现方案**:
|
||||
- 检查币安是否支持 `TRAILING_STOP_MARKET` 订单类型
|
||||
- 在分步止盈后,挂币安Trailing Stop Order或代码层面实现
|
||||
|
||||
**配置项**:
|
||||
- `USE_TRAILING_STOP_AFTER_PARTIAL_PROFIT`: True
|
||||
- `TRAILING_STOP_ATR_MULTIPLIER`: 1.5
|
||||
|
||||
### 8. ⏳ ADX趋势强度判断
|
||||
|
||||
**目标**:如果ADX > 25且处于上升趋势,延迟第一止盈位触发或取消50%减仓
|
||||
|
||||
**实现方案**:
|
||||
- 在 `indicators.py` 中计算ADX
|
||||
- 在 `position_manager.py` 的止盈检查中,如果ADX > 25且趋势向上,跳过第一止盈
|
||||
|
||||
**配置项**:
|
||||
- `ADX_STRONG_TREND_THRESHOLD`: 25
|
||||
- `ADX_SKIP_PARTIAL_PROFIT`: True
|
||||
|
||||
### 9. ⏳ 心跳检测与兜底巡检
|
||||
|
||||
**目标**:WebSocket断线重连机制 + 每1-2分钟兜底巡检
|
||||
|
||||
**实现方案**:
|
||||
- 在 `position_manager.py` 的WebSocket监控中增加心跳检测
|
||||
- 增加独立的定时巡检任务(每1-2分钟),作为兜底
|
||||
|
||||
**配置项**:
|
||||
- `WEBSOCKET_HEARTBEAT_INTERVAL`: 30(30秒)
|
||||
- `FALLBACK_CHECK_INTERVAL`: 120(2分钟)
|
||||
|
||||
### 10. ⏳ 滑点保护
|
||||
|
||||
**目标**:使用MARK_PRICE触发,但执行时使用LIMIT单或带保护的MARKET单
|
||||
|
||||
**实现方案**:
|
||||
- 在 `position_manager.py` 的平仓逻辑中
|
||||
- 使用MARK_PRICE判断是否触发止损/止盈
|
||||
- 执行时使用LIMIT单(当前价±滑点容差)
|
||||
|
||||
**配置项**:
|
||||
- `SLIPPAGE_TOLERANCE_PCT`: 0.002(0.2%)
|
||||
- `USE_LIMIT_ON_CLOSE`: True
|
||||
|
||||
### 11. ⏳ 资金费率避险
|
||||
|
||||
**目标**:在费率结算前(8:00, 16:00, 24:00),如果费率过高(>0.1%),提前止盈或暂缓入场
|
||||
|
||||
**实现方案**:
|
||||
- 在 `binance_client.py` 中获取资金费率
|
||||
- 在 `strategy.py` 中检查是否接近结算时间
|
||||
- 如果费率 > 0.1%,提前止盈或暂缓入场
|
||||
|
||||
**配置项**:
|
||||
- `FUNDING_RATE_THRESHOLD`: 0.001(0.1%)
|
||||
- `FUNDING_RATE_EARLY_EXIT_HOURS`: 1(结算前1小时)
|
||||
|
||||
---
|
||||
|
||||
## 📊 配置项汇总
|
||||
|
||||
所有新增配置项已添加到 `trading_system/config.py` 的 `_get_trading_config()` 函数中:
|
||||
|
||||
```python
|
||||
# 动态过滤
|
||||
'BETA_FILTER_ENABLED': True,
|
||||
'BETA_FILTER_THRESHOLD': -0.03, # -3%
|
||||
'MIN_VOLUME_24H_STRICT': 10000000, # 1000万美金
|
||||
'SIGNAL_STRENGTH_POSITION_MULTIPLIER': {8: 0.5, 9: 1.0, 10: 1.0},
|
||||
|
||||
# 仓位管理
|
||||
'USE_FIXED_RISK_SIZING': True,
|
||||
'FIXED_RISK_PERCENT': 0.02, # 2%
|
||||
'MAX_LEVERAGE_SMALL_CAP': 5,
|
||||
'ATR_LEVERAGE_REDUCTION_THRESHOLD': 0.05, # 5%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期效果
|
||||
|
||||
### 已实现优化的预期效果:
|
||||
|
||||
1. **大盘共振过滤**:
|
||||
- ✅ 减少在大盘暴跌时的多单损失
|
||||
- ✅ 提高整体胜率
|
||||
|
||||
2. **成交量验证**:
|
||||
- ✅ 避免流动性差的币种
|
||||
- ✅ 减少滑点损失(2-3%)
|
||||
|
||||
3. **固定风险百分比**:
|
||||
- ✅ 每笔单子风险恒定(2%),避免30%的大额亏损
|
||||
- ✅ 根据止损距离自动调整仓位,更科学
|
||||
|
||||
4. **信号强度分级**:
|
||||
- ✅ 高质量信号(9-10分)获得更大收益
|
||||
- ✅ 低质量信号(8分)降低风险
|
||||
|
||||
5. **阶梯杠杆**:
|
||||
- ✅ 小众币风险降低(最大杠杆5倍)
|
||||
- ✅ 减少因高杠杆导致的强平风险
|
||||
|
||||
---
|
||||
|
||||
## 📝 使用说明
|
||||
|
||||
### 管理员配置
|
||||
|
||||
所有优化配置项都可以在 `GlobalConfig` 页面中配置:
|
||||
- 大盘共振过滤:`BETA_FILTER_ENABLED`, `BETA_FILTER_THRESHOLD`
|
||||
- 成交量验证:`MIN_VOLUME_24H_STRICT`
|
||||
- 固定风险百分比:`USE_FIXED_RISK_SIZING`, `FIXED_RISK_PERCENT`
|
||||
- 信号强度分级:`SIGNAL_STRENGTH_POSITION_MULTIPLIER`
|
||||
- 阶梯杠杆:`MAX_LEVERAGE_SMALL_CAP`, `ATR_LEVERAGE_REDUCTION_THRESHOLD`
|
||||
|
||||
### 默认值
|
||||
|
||||
所有优化默认启用,使用推荐的参数值。管理员可以根据实际情况调整。
|
||||
|
||||
---
|
||||
|
||||
## 🔄 后续优化建议
|
||||
|
||||
1. **监控效果**:观察优化后的实际效果,根据数据调整参数
|
||||
2. **逐步实现**:剩余优化可以根据实际需求逐步实现
|
||||
3. **测试验证**:建议在测试环境或小资金账户先测试
|
||||
250
docs/QUICK_APPLY_ALTCOIN_STRATEGY.md
Normal file
250
docs/QUICK_APPLY_ALTCOIN_STRATEGY.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# 山寨币策略快速应用指南
|
||||
|
||||
> 5分钟内完成配置更新和验证
|
||||
|
||||
## 🚀 快速应用步骤
|
||||
|
||||
### 步骤1:确认代码已更新(✅ 已完成)
|
||||
|
||||
已更新的文件:
|
||||
- ✅ `trading_system/config.py` - 核心配置
|
||||
- ✅ `trading_system/trade_recommender.py` - 推荐生成
|
||||
- ✅ `trading_system/position_manager.py` - 持仓管理
|
||||
|
||||
### 步骤2:重启所有进程(⚡ 立即执行)
|
||||
|
||||
```bash
|
||||
# 1. 重启所有交易进程
|
||||
supervisorctl restart auto_sys:*
|
||||
|
||||
# 2. 重启推荐服务
|
||||
supervisorctl restart auto_recommend:*
|
||||
|
||||
# 3. 确认进程状态
|
||||
supervisorctl status
|
||||
```
|
||||
|
||||
### 步骤3:验证配置生效(🔍 关键检查)
|
||||
|
||||
查看日志,确认以下关键参数:
|
||||
|
||||
```bash
|
||||
# 查看最新日志
|
||||
tail -n 100 /www/wwwroot/autosys_new/logs/trading_*.log | grep -E "ATR_STOP_LOSS_MULTIPLIER|RISK_REWARD_RATIO|MIN_HOLD_TIME_SEC|USE_TRAILING_STOP|MAX_POSITION_PERCENT"
|
||||
```
|
||||
|
||||
应该看到:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER: 2.0`
|
||||
- `RISK_REWARD_RATIO: 4.0`
|
||||
- `MIN_HOLD_TIME_SEC: 0`
|
||||
- `USE_TRAILING_STOP: True`
|
||||
- `MAX_POSITION_PERCENT: 0.015`
|
||||
|
||||
### 步骤4:清理旧配置缓存(可选)
|
||||
|
||||
如果配置没有生效,可能需要清理Redis缓存:
|
||||
|
||||
```bash
|
||||
# 方法1:通过backend API清理(推荐)
|
||||
curl -X POST "http://your-api-domain/api/config/clear-cache"
|
||||
|
||||
# 方法2:直接重启Redis(谨慎!)
|
||||
# supervisorctl restart redis
|
||||
```
|
||||
|
||||
## ✅ 验证清单
|
||||
|
||||
使用这个清单逐项验证:
|
||||
|
||||
### 风险控制
|
||||
- [ ] ATR止损倍数 = 2.0(日志确认)
|
||||
- [ ] 固定止损 = 15%(日志确认)
|
||||
- [ ] 盈亏比 = 4.0(日志确认)
|
||||
- [ ] 最小持仓时间 = 0秒(已取消)
|
||||
- [ ] 每笔风险 = 1%
|
||||
|
||||
### 止盈策略
|
||||
- [ ] 移动止损已启用
|
||||
- [ ] 移动止损激活 = 30%
|
||||
- [ ] 移动止损保护 = 15%
|
||||
- [ ] 第一目标盈亏比 = 1:1
|
||||
- [ ] 第二目标盈亏比 = 4:1
|
||||
|
||||
### 仓位管理
|
||||
- [ ] 单笔仓位 ≤ 1.5%
|
||||
- [ ] 总仓位 ≤ 12%
|
||||
- [ ] 最大同时持仓 = 4个
|
||||
- [ ] 基础杠杆 = 8倍
|
||||
- [ ] 最大杠杆 = 12倍
|
||||
|
||||
### 交易控制
|
||||
- [ ] 每日最多5笔
|
||||
- [ ] 智能入场已启用
|
||||
- [ ] 币种冷却 = 30分钟
|
||||
- [ ] 只做趋势市(AUTO_TRADE_ONLY_TRENDING = True)
|
||||
|
||||
### 品种筛选
|
||||
- [ ] 24H成交量 ≥ 3000万美元
|
||||
- [ ] 最小波动率 ≥ 3%
|
||||
- [ ] 最多扫描150个
|
||||
- [ ] 只做前5个最强信号
|
||||
|
||||
### 时间框架
|
||||
- [ ] 主周期 = 4小时
|
||||
- [ ] 入场周期 = 1小时
|
||||
- [ ] 确认周期 = 日线
|
||||
- [ ] 扫描间隔 = 1小时
|
||||
|
||||
## 🔧 如果配置未生效
|
||||
|
||||
### 情况1:进程重启失败
|
||||
|
||||
```bash
|
||||
# 查看错误日志
|
||||
tail -n 50 /www/wwwroot/autosys_new/logs/trading_*.err.log
|
||||
|
||||
# 常见问题:
|
||||
# - 代码语法错误:检查最近修改的代码
|
||||
# - 数据库连接失败:检查数据库状态
|
||||
# - Redis连接失败:检查Redis状态
|
||||
```
|
||||
|
||||
### 情况2:配置值仍是旧值
|
||||
|
||||
```bash
|
||||
# 强制重新加载配置
|
||||
# 在Python代码中调用:
|
||||
# config._config_manager.reload_from_redis()
|
||||
# 或重启backend服务:
|
||||
supervisorctl restart backend
|
||||
```
|
||||
|
||||
### 情况3:部分配置生效,部分未生效
|
||||
|
||||
```bash
|
||||
# 检查数据库中的配置(可能有冲突)
|
||||
# 使用backend管理界面或直接查询数据库:
|
||||
# SELECT * FROM trading_config WHERE config_key LIKE '%ATR%' OR config_key LIKE '%RISK%';
|
||||
```
|
||||
|
||||
## 📊 监控前3笔交易
|
||||
|
||||
策略更新后,密切监控前3笔交易的关键数据:
|
||||
|
||||
```
|
||||
第1笔交易:
|
||||
- 开仓价格:_______
|
||||
- 止损价格:_______(应该是开仓价的±15%左右)
|
||||
- 止盈价格:_______(应该是止损距离的4倍)
|
||||
- 实际杠杆:_______(应该是8倍左右)
|
||||
- 保证金占比:_______(应该≤1.5%)
|
||||
|
||||
第2笔交易:
|
||||
- 开仓价格:_______
|
||||
- 止损价格:_______
|
||||
- 止盈价格:_______
|
||||
- 实际杠杆:_______
|
||||
- 保证金占比:_______
|
||||
|
||||
第3笔交易:
|
||||
- 开仓价格:_______
|
||||
- 止损价格:_______
|
||||
- 止盈价格:_______
|
||||
- 实际杠杆:_______
|
||||
- 保证金占比:_______
|
||||
```
|
||||
|
||||
### 异常判断标准
|
||||
|
||||
如果出现以下情况,立即暂停并检查:
|
||||
- ❌ 止损距离 < 10%或 > 20%
|
||||
- ❌ 盈亏比 < 3:1
|
||||
- ❌ 单笔保证金 > 2%
|
||||
- ❌ 杠杆 > 12倍
|
||||
- ❌ 同时持仓 > 4个
|
||||
- ❌ 触发止损但仍在持仓(说明止损未生效)
|
||||
|
||||
## 🎯 预期效果(3-5天后)
|
||||
|
||||
如果策略正确执行,应该看到:
|
||||
|
||||
### 短期指标(1-2天)
|
||||
- 胜率:30-40%(初期可能偏低,正常)
|
||||
- 单笔盈亏:盈利单平均+4%,亏损单平均-1%
|
||||
- 交易频率:每日2-5笔
|
||||
- 持仓时间:1-4小时
|
||||
|
||||
### 中期指标(3-5天)
|
||||
- 胜率:35-45%
|
||||
- 盈亏比:3.5:1 - 4.5:1
|
||||
- 期望值:+0.5% - +1.0%(每笔)
|
||||
- 最大回撤:单日 < 3%
|
||||
|
||||
### 预警信号
|
||||
|
||||
如果出现以下情况,说明需要调整:
|
||||
- ⚠️ 胜率 < 25%:提高MIN_SIGNAL_STRENGTH到8
|
||||
- ⚠️ 盈亏比 < 3:1:检查止盈设置
|
||||
- ⚠️ 单日亏损 > 5%:暂停交易,检查市场环境
|
||||
- ⚠️ 连续亏损 > 5笔:暂停交易,等待市场转好
|
||||
|
||||
## 📞 问题排查
|
||||
|
||||
### 问题1:配置更新后没有新交易
|
||||
|
||||
**可能原因:**
|
||||
- 信号强度要求提高(MIN_SIGNAL_STRENGTH=7)
|
||||
- 成交量要求提高(MIN_VOLUME_24H=3000万)
|
||||
- 市场不满足AUTO_TRADE_ONLY_TRENDING条件
|
||||
|
||||
**解决方案:**
|
||||
- 查看推荐服务日志,确认是否有新推荐生成
|
||||
- 检查当前市场是否处于趋势中
|
||||
- 如果长期没有交易,可以临时降低MIN_SIGNAL_STRENGTH到6
|
||||
|
||||
### 问题2:止损触发太频繁
|
||||
|
||||
**可能原因:**
|
||||
- ATR_STOP_LOSS_MULTIPLIER太小
|
||||
- 选择的币种波动过大
|
||||
|
||||
**解决方案:**
|
||||
- 提高ATR_STOP_LOSS_MULTIPLIER到2.2或2.5
|
||||
- 提高MIN_VOLATILITY筛选标准
|
||||
- 检查是否在异常波动期间交易
|
||||
|
||||
### 问题3:盈利单无法达到TP2
|
||||
|
||||
**可能原因:**
|
||||
- 盈亏比4:1对当前市场环境过高
|
||||
- 移动止损激活过早
|
||||
|
||||
**解决方案:**
|
||||
- 降低RISK_REWARD_RATIO到3.0或3.5
|
||||
- 提高TRAILING_STOP_ACTIVATION到40%
|
||||
- 观察是否有盈利单达到30%但未触发移动止损
|
||||
|
||||
## 🔄 后续优化
|
||||
|
||||
根据实际运行情况,可能需要微调:
|
||||
|
||||
### 1周后可能的调整
|
||||
- MIN_SIGNAL_STRENGTH:6.5 - 8
|
||||
- ATR_STOP_LOSS_MULTIPLIER:1.8 - 2.2
|
||||
- RISK_REWARD_RATIO:3.5 - 4.5
|
||||
- TRAILING_STOP_ACTIVATION:25% - 35%
|
||||
|
||||
### 1个月后可能的调整
|
||||
- 建立币种白名单/黑名单
|
||||
- 按市值分级设置不同参数
|
||||
- 添加BTC趋势过滤
|
||||
|
||||
---
|
||||
|
||||
**最后提醒**:
|
||||
1. 🚨 配置更新后前3笔交易必须人工监控
|
||||
2. 📊 每日检查盈亏比和期望值是否符合预期
|
||||
3. ⚡ 如有异常立即暂停交易并检查日志
|
||||
4. 📈 坚持记录每笔交易数据,持续优化
|
||||
|
||||
**祝交易顺利!**
|
||||
198
docs/QUICK_PRESET_RECOMMENDATION.md
Normal file
198
docs/QUICK_PRESET_RECOMMENDATION.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# 快速方案选择建议
|
||||
|
||||
## 📊 当前可用的快速方案
|
||||
|
||||
根据你的系统已完成的优化(大盘共振、固定风险百分比、信号强度分级、阶梯杠杆),以下是各方案的适用场景:
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐方案(按优先级)
|
||||
|
||||
### ⭐⭐⭐ **首选:波段回归(swing)**
|
||||
|
||||
**适用场景**:
|
||||
- ✅ 刚完成优化,想验证效果
|
||||
- ✅ 追求稳定盈利,不追求高频
|
||||
- ✅ 能接受"可能撤单"的情况
|
||||
|
||||
**核心特点**:
|
||||
- `SMART_ENTRY_ENABLED: false` - 纯限价单,不追价
|
||||
- `MIN_SIGNAL_STRENGTH: 8` - 高质量信号(配合信号强度分级,8分用50%仓位)
|
||||
- `SCAN_INTERVAL: 1800` - 30分钟扫描,低频波段
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER: 1.5` - 已优化为1.5:1盈亏比
|
||||
- `MAX_POSITION_PERCENT: 2.0%` - 配合固定风险百分比,每笔风险恒定
|
||||
|
||||
**优势**:
|
||||
- ✅ 与最新优化最匹配(固定风险百分比、信号强度分级)
|
||||
- ✅ 避免高频追价导致的损失
|
||||
- ✅ 高质量信号,胜率更高
|
||||
- ✅ 配合大盘共振过滤,减少大盘暴跌时的损失
|
||||
|
||||
**注意事项**:
|
||||
- ⚠️ 可能因为限价单未成交而撤单(这是正常的,避免追价损失)
|
||||
- ⚠️ 交易频率较低,需要耐心
|
||||
|
||||
---
|
||||
|
||||
### ⭐⭐ **次选:精选低频(strict)**
|
||||
|
||||
**适用场景**:
|
||||
- ✅ 追求最高胜率
|
||||
- ✅ 只做趋势行情
|
||||
- ✅ 能接受更少的交易次数
|
||||
|
||||
**核心特点**:
|
||||
- `AUTO_TRADE_ONLY_TRENDING: true` - 仅趋势行情自动交易
|
||||
- `AUTO_TRADE_ALLOW_4H_NEUTRAL: false` - 4H中性不自动下单
|
||||
- `MIN_SIGNAL_STRENGTH: 8` - 高质量信号
|
||||
- `SMART_ENTRY_ENABLED: false` - 纯限价单
|
||||
- `LIMIT_ORDER_OFFSET_PCT: 0.1` - 限价偏移较小,更容易成交
|
||||
|
||||
**优势**:
|
||||
- ✅ 胜率最高(只做趋势行情)
|
||||
- ✅ 避免震荡市亏损
|
||||
- ✅ 配合大盘共振过滤,效果更好
|
||||
|
||||
**注意事项**:
|
||||
- ⚠️ 交易次数最少
|
||||
- ⚠️ 如果市场长期震荡,可能很久不出单
|
||||
|
||||
---
|
||||
|
||||
### ⭐ **备选:成交优先(fill)**
|
||||
|
||||
**适用场景**:
|
||||
- ✅ 发现"波段回归"方案撤单太多
|
||||
- ✅ 想要更多成交,但不想回到高频追价
|
||||
- ✅ 能接受有限的追价(有上限保护)
|
||||
|
||||
**核心特点**:
|
||||
- `SMART_ENTRY_ENABLED: true` - 智能入场,有限追价
|
||||
- `ENTRY_CHASE_MAX_STEPS: 2` - 最多追价2步(严格限制)
|
||||
- `ENTRY_MAX_DRIFT_PCT_TRENDING: 0.3` - 追价上限30%(有保护)
|
||||
- `MIN_SIGNAL_STRENGTH: 7` - 信号门槛略低
|
||||
- `AUTO_TRADE_ONLY_TRENDING: false` - 解锁自动交易过滤
|
||||
|
||||
**优势**:
|
||||
- ✅ 成交率更高,减少撤单
|
||||
- ✅ 追价有严格限制,不会回到高频追价
|
||||
- ✅ 配合固定风险百分比,风险可控
|
||||
|
||||
**注意事项**:
|
||||
- ⚠️ 追价可能增加成本(但有限制)
|
||||
- ⚠️ 信号门槛略低,需要配合信号强度分级使用
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 不推荐(除非特殊需求)
|
||||
|
||||
### 稳定出单(steady)
|
||||
- **问题**:`MIN_SIGNAL_STRENGTH: 6` 太低,信号质量差
|
||||
- **问题**:`SCAN_INTERVAL: 900` 15分钟扫描,频率较高
|
||||
- **建议**:除非你发现其他方案完全不出单,否则不推荐
|
||||
|
||||
### 传统方案(conservative/balanced/aggressive)
|
||||
- **问题**:这些方案没有应用最新的优化(固定风险百分比、信号强度分级等)
|
||||
- **问题**:`ATR_TAKE_PROFIT_MULTIPLIER` 可能还是旧值
|
||||
- **建议**:仅用于对比测试,不建议长期使用
|
||||
|
||||
---
|
||||
|
||||
## 🎯 选择决策树
|
||||
|
||||
```
|
||||
开始
|
||||
│
|
||||
├─ 是否刚完成优化,想验证效果?
|
||||
│ └─ 是 → 选择「波段回归(swing)」
|
||||
│
|
||||
├─ 是否追求最高胜率,能接受很少交易?
|
||||
│ └─ 是 → 选择「精选低频(strict)」
|
||||
│
|
||||
├─ 是否发现「波段回归」撤单太多?
|
||||
│ └─ 是 → 选择「成交优先(fill)」
|
||||
│
|
||||
└─ 其他情况 → 选择「波段回归(swing)」
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 方案对比表
|
||||
|
||||
| 方案 | 信号门槛 | 入场机制 | 交易频率 | 胜率倾向 | 推荐度 |
|
||||
|------|---------|---------|---------|---------|--------|
|
||||
| **波段回归(swing)** | 8分 | 纯限价 | 低频 | 高 | ⭐⭐⭐ |
|
||||
| **精选低频(strict)** | 8分 | 纯限价 | 最低 | 最高 | ⭐⭐ |
|
||||
| **成交优先(fill)** | 7分 | 智能入场(有限) | 中频 | 中高 | ⭐ |
|
||||
| **稳定出单(steady)** | 6分 | 智能入场 | 中高频 | 中 | ⚠️ |
|
||||
| **传统方案** | 3-5分 | 混合 | 高频 | 低 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 💡 最终建议
|
||||
|
||||
### 第一步:先用「波段回归(swing)」
|
||||
|
||||
**理由**:
|
||||
1. ✅ 与最新优化最匹配(固定风险百分比、信号强度分级、大盘共振)
|
||||
2. ✅ 高质量信号(8分),配合信号强度分级,8分用50%仓位
|
||||
3. ✅ 纯限价单,避免追价损失
|
||||
4. ✅ 已优化为1.5:1盈亏比,更容易止盈
|
||||
|
||||
**观察期**:运行20-30单,观察:
|
||||
- 胜率是否提升
|
||||
- 是否出现30%以上的大额亏损(应该不会,因为有固定风险百分比)
|
||||
- 撤单率是否过高
|
||||
|
||||
### 第二步:根据观察结果调整
|
||||
|
||||
**如果撤单太多**:
|
||||
- 切换到「成交优先(fill)」
|
||||
- 或手动调整 `LIMIT_ORDER_OFFSET_PCT` 从 0.5% 降到 0.1%
|
||||
|
||||
**如果交易太少**:
|
||||
- 切换到「精选低频(strict)」
|
||||
- 或手动调整 `AUTO_TRADE_ONLY_TRENDING: false`
|
||||
|
||||
**如果胜率不够**:
|
||||
- 保持「波段回归(swing)」
|
||||
- 或切换到「精选低频(strict)」
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配合最新优化的配置
|
||||
|
||||
无论选择哪个方案,以下配置已自动应用(在 `config.py` 中):
|
||||
|
||||
- ✅ `BETA_FILTER_ENABLED: True` - 大盘共振过滤
|
||||
- ✅ `MIN_VOLUME_24H_STRICT: 10000000` - 成交量验证(1000万美金)
|
||||
- ✅ `USE_FIXED_RISK_SIZING: True` - 固定风险百分比(2%)
|
||||
- ✅ `SIGNAL_STRENGTH_POSITION_MULTIPLIER: {8: 0.5, 9: 1.0, 10: 1.0}` - 信号强度分级
|
||||
- ✅ `MAX_LEVERAGE_SMALL_CAP: 5` - 小众币杠杆限制
|
||||
|
||||
这些优化会在所有方案中生效,进一步提升系统表现。
|
||||
|
||||
---
|
||||
|
||||
## 📊 预期效果
|
||||
|
||||
使用「波段回归(swing)」+ 最新优化,预期:
|
||||
|
||||
- ✅ **胜率**:提升(高质量信号 + 大盘共振过滤)
|
||||
- ✅ **单笔亏损**:控制在2%(固定风险百分比)
|
||||
- ✅ **大额亏损**:避免30%以上的亏损(固定风险百分比 + 阶梯杠杆)
|
||||
- ✅ **滑点损失**:减少2-3%(成交量验证)
|
||||
- ✅ **大盘暴跌损失**:减少(大盘共振过滤)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
**推荐顺序**:
|
||||
1. **首选**:波段回归(swing)
|
||||
2. **次选**:精选低频(strict)
|
||||
3. **备选**:成交优先(fill)
|
||||
|
||||
**不推荐**:稳定出单、传统方案
|
||||
|
||||
**建议**:先用「波段回归(swing)」跑20-30单,根据实际效果再调整。
|
||||
75
docs/RECOMMENDATION_SERVICE_API_KEY_FIX.md
Normal file
75
docs/RECOMMENDATION_SERVICE_API_KEY_FIX.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# 推荐服务 API Key 修复说明
|
||||
|
||||
## 问题描述
|
||||
|
||||
推荐服务(`recommendations_main.py`)仍然在使用真实的 API key,导致可能使用错误的账户(如 account_id=2)进行下单。
|
||||
|
||||
## 根本原因
|
||||
|
||||
1. **推荐服务不应该使用任何账户的 API key**:推荐服务只需要获取公开行情数据,不需要认证。
|
||||
2. **`config.py` 在导入时会读取 `ATS_ACCOUNT_ID`**:如果推荐服务的 supervisor 配置中设置了 `ATS_ACCOUNT_ID=2`,那么 `config.py` 会读取 account_id=2 的 API key。
|
||||
3. **`BinanceClient.__init__` 可能被覆盖**:即使传入空字符串,如果 `config._config_manager` 存在,可能会在某个地方被覆盖。
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 1. 修复 `BinanceClient.__init__` 逻辑
|
||||
|
||||
确保传入空字符串时不会被 config 覆盖:
|
||||
|
||||
```python
|
||||
# 如果传入的是空字符串,保持为空字符串(不覆盖)
|
||||
# 这样推荐服务可以使用空字符串来明确表示"只使用公开接口"
|
||||
```
|
||||
|
||||
### 2. 修复 `connect` 方法
|
||||
|
||||
当 API key 为空时,跳过权限验证:
|
||||
|
||||
```python
|
||||
# 验证API密钥权限(仅当提供了有效的 API key 时)
|
||||
if self.api_key and self.api_secret:
|
||||
await self._verify_api_permissions()
|
||||
else:
|
||||
logger.info("✓ 使用公开 API,跳过权限验证(只能获取行情数据)")
|
||||
```
|
||||
|
||||
### 3. 在推荐服务中添加验证
|
||||
|
||||
在 `recommendations_main.py` 中添加验证逻辑,确保 API key 确实是空的:
|
||||
|
||||
```python
|
||||
# 验证:确保 API key 确实是空的
|
||||
if client.api_key:
|
||||
logger.error(f"❌ 推荐服务 API Key 非空!当前值: {client.api_key[:4]}...")
|
||||
logger.error(" 这可能导致推荐服务使用错误的账户密钥,请检查 BinanceClient.__init__ 逻辑")
|
||||
else:
|
||||
logger.info("✓ 推荐服务 API Key 确认为空,将只使用公开接口")
|
||||
```
|
||||
|
||||
## 检查清单
|
||||
|
||||
1. ✅ 确保 `recommendations_main.py` 传入空字符串:`BinanceClient(api_key="", api_secret="")`
|
||||
2. ✅ 确保 `BinanceClient.__init__` 不会覆盖空字符串
|
||||
3. ✅ 确保 `connect` 方法在 API key 为空时跳过权限验证
|
||||
4. ✅ 在推荐服务中添加验证逻辑,确保 API key 确实是空的
|
||||
|
||||
## 验证方法
|
||||
|
||||
1. 查看推荐服务的日志,确认显示:
|
||||
- `✓ 推荐服务 API Key 确认为空,将只使用公开接口`
|
||||
- `✓ 使用公开 API,跳过权限验证(只能获取行情数据)`
|
||||
|
||||
2. 如果看到以下日志,说明仍有问题:
|
||||
- `❌ 推荐服务 API Key 非空!`
|
||||
- `初始化币安客户端: gqtx...sYmj, l3IB...I6NA`(显示真实的 API key)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **推荐服务不应该设置 `ATS_ACCOUNT_ID`**:推荐服务的 supervisor 配置不应该设置 `ATS_ACCOUNT_ID`,或者应该明确设置为空。
|
||||
2. **推荐服务不应该下单**:推荐服务只生成推荐,不应该进行任何下单操作。
|
||||
3. **如果推荐服务仍然使用真实的 API key**:检查 supervisor 配置,确保推荐服务进程没有设置 `ATS_ACCOUNT_ID`。
|
||||
|
||||
## 后续优化
|
||||
|
||||
1. 考虑在 `config.py` 中添加一个标志,区分推荐服务和交易服务。
|
||||
2. 考虑在推荐服务启动时,明确清除 `ATS_ACCOUNT_ID` 环境变量。
|
||||
166
docs/Redis缓存问题修复说明.md
Normal file
166
docs/Redis缓存问题修复说明.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Redis缓存问题修复说明
|
||||
|
||||
## 🔍 问题分析
|
||||
|
||||
即使执行了数据迁移,日志中仍然显示格式转换警告。原因是:
|
||||
|
||||
1. **Redis缓存中还有旧数据**:即使数据库已经迁移为比例形式(0.30),Redis缓存中可能还存储着百分比形式(30)
|
||||
2. **格式转换后没有更新缓存**:当检测到值>1时,代码会转换为比例形式(0.30),但转换后的值没有写回Redis缓存
|
||||
3. **下次读取时再次触发转换**:下次从Redis读取时,又会读到旧值(30),再次触发转换
|
||||
|
||||
---
|
||||
|
||||
## ✅ 修复方案
|
||||
|
||||
### 方案1:在格式转换时更新Redis缓存(已实现)
|
||||
|
||||
**修改位置**:`backend/config_manager.py:756-777`
|
||||
|
||||
**修复逻辑**:
|
||||
```python
|
||||
if value > 1:
|
||||
old_value = value
|
||||
value = value / 100.0
|
||||
logger.warning(...)
|
||||
# ⚠️ 关键修复:转换后立即更新Redis缓存
|
||||
try:
|
||||
if key in RISK_KNOBS_KEYS:
|
||||
# 风险旋钮:更新当前账号的Redis缓存
|
||||
self._set_to_redis(key, value)
|
||||
self._cache[key] = value
|
||||
else:
|
||||
# 全局配置:更新全局配置的Redis缓存
|
||||
global_config_mgr._set_to_redis(key, value)
|
||||
global_config_mgr._cache[key] = value
|
||||
except Exception as e:
|
||||
logger.debug(f"更新Redis缓存失败(不影响使用): {key} = {e}")
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 转换后的值(0.30)会立即写回Redis缓存
|
||||
- ✅ 下次读取时,直接从Redis读取到正确的值(0.30),不再触发转换
|
||||
- ✅ 警告日志会逐渐减少,直到所有缓存都更新完成
|
||||
|
||||
---
|
||||
|
||||
### 方案2:手动清除Redis缓存(推荐配合使用)
|
||||
|
||||
**执行命令**:
|
||||
```bash
|
||||
# 清除所有配置缓存
|
||||
redis-cli DEL "config:trading_config:*"
|
||||
redis-cli DEL "config:global_strategy_config"
|
||||
|
||||
# 或者清除特定账号的缓存
|
||||
redis-cli DEL "config:trading_config:1"
|
||||
redis-cli DEL "config:trading_config:2"
|
||||
redis-cli DEL "config:trading_config:3"
|
||||
redis-cli DEL "config:trading_config:4"
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 强制系统从数据库重新加载配置
|
||||
- ✅ 如果数据库已经迁移,加载的将是正确的比例形式(0.30)
|
||||
- ✅ 新的配置值会写入Redis缓存
|
||||
|
||||
---
|
||||
|
||||
## 🔧 实施步骤
|
||||
|
||||
### 步骤1:应用代码修复(已完成)
|
||||
|
||||
代码已经修复,格式转换时会自动更新Redis缓存。
|
||||
|
||||
### 步骤2:清除Redis缓存(推荐)
|
||||
|
||||
```bash
|
||||
# 清除所有配置缓存
|
||||
redis-cli DEL "config:trading_config:*"
|
||||
redis-cli DEL "config:global_strategy_config"
|
||||
```
|
||||
|
||||
### 步骤3:重启服务
|
||||
|
||||
```bash
|
||||
# 重启后端服务
|
||||
supervisorctl restart backend
|
||||
|
||||
# 重启交易进程
|
||||
supervisorctl restart auto_sys_acc1 auto_sys_acc2 auto_sys_acc3 auto_sys_acc4
|
||||
```
|
||||
|
||||
### 步骤4:验证
|
||||
|
||||
**检查日志**:
|
||||
```bash
|
||||
# 查看日志,确认格式转换警告逐渐减少
|
||||
tail -f /www/wwwroot/autosys_new/logs/trading_*.log | grep "配置值格式转换"
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 第一次读取时,可能会看到格式转换警告(从Redis读取到旧值)
|
||||
- ✅ 转换后,值会写回Redis缓存
|
||||
- ✅ 下次读取时,不再触发转换,警告消失
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据流
|
||||
|
||||
### 修复前
|
||||
|
||||
```
|
||||
从Redis读取:30(旧数据)
|
||||
↓
|
||||
格式转换:30 -> 0.30
|
||||
↓
|
||||
使用:0.30
|
||||
↓
|
||||
⚠️ Redis缓存中还是30(没有更新)
|
||||
↓
|
||||
下次读取:30(再次触发转换)
|
||||
```
|
||||
|
||||
### 修复后
|
||||
|
||||
```
|
||||
从Redis读取:30(旧数据)
|
||||
↓
|
||||
格式转换:30 -> 0.30
|
||||
↓
|
||||
更新Redis缓存:0.30 ✅
|
||||
↓
|
||||
使用:0.30
|
||||
↓
|
||||
下次读取:0.30(不再触发转换)✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
### 修复内容
|
||||
|
||||
1. **代码修复**:格式转换时自动更新Redis缓存
|
||||
2. **手动清除**:清除Redis缓存,强制从数据库重新加载
|
||||
|
||||
### 效果
|
||||
|
||||
- ✅ 格式转换警告会逐渐减少
|
||||
- ✅ Redis缓存会自动更新为正确的值
|
||||
- ✅ 下次读取时不再触发转换
|
||||
|
||||
### 建议
|
||||
|
||||
1. **立即清除Redis缓存**:确保从数据库加载最新数据
|
||||
2. **重启服务**:让新代码生效
|
||||
3. **监控日志**:确认警告逐渐减少
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最终效果
|
||||
|
||||
- ✅ 数据库中统一存储比例形式(0.30)
|
||||
- ✅ Redis缓存中也是比例形式(0.30)
|
||||
- ✅ 前端直接显示小数(0.30),不带%符号
|
||||
- ✅ 后端直接使用(0.30),不需要转换
|
||||
- ✅ 日志中不再出现格式转换警告
|
||||
130
docs/SELL单止损价格计算错误修复.md
Normal file
130
docs/SELL单止损价格计算错误修复.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# SELL单止损价格计算错误修复
|
||||
|
||||
## 🚨 严重问题
|
||||
|
||||
### 问题描述
|
||||
|
||||
SELL单(做空)出现巨额亏损(-91.93%),原因是止损价格计算逻辑错误,选择了"更宽松"的止损(更远离入场价),而不是"更紧"的止损(更接近入场价)。
|
||||
|
||||
### 具体案例
|
||||
|
||||
**AXLUSDT SELL 单(交易ID: 1727)**:
|
||||
- 入场价:0.0731
|
||||
- 出场价:0.0815
|
||||
- 方向:SELL(做空)
|
||||
- 盈亏比例:-91.93%(几乎亏光保证金)
|
||||
|
||||
**问题分析**:
|
||||
- 做空单,价格从0.0731涨到0.0815,涨幅11.22%
|
||||
- 如果止损价格正确(更接近入场价,比如0.075),应该在价格涨到0.075时止损,亏损约5%
|
||||
- 但实际亏损-91.93%,说明止损价格设置错误,选择了"更宽松"的止损(更远离入场价,比如0.082)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 根本原因
|
||||
|
||||
### 代码逻辑矛盾
|
||||
|
||||
**位置**:`trading_system/risk_manager.py:689-757`
|
||||
|
||||
**问题**:
|
||||
1. **第689-700行**:选择"更紧的止损"(更接近入场价)
|
||||
- BUY: 取最大值(更高的止损价,更接近入场价)✅
|
||||
- SELL: 取最小值(更低的止损价,更接近入场价)✅
|
||||
|
||||
2. **第750-757行**:重新选择最终的止损价,保持"更宽松/更远"的选择规则 ❌
|
||||
- BUY: 取最小值(更低的止损价,更远离入场价)❌
|
||||
- SELL: 取最大值(更高的止损价,更远离入场价)❌
|
||||
|
||||
**结果**:
|
||||
- 第750-757行的逻辑会覆盖第689-700行的逻辑
|
||||
- 导致SELL单选择了"更宽松"的止损(更远离入场价)
|
||||
- 这就是为什么会出现-91.93%的巨额亏损
|
||||
|
||||
---
|
||||
|
||||
## ✅ 修复方案
|
||||
|
||||
### 修复内容
|
||||
|
||||
**修改位置**:`trading_system/risk_manager.py:750-757`
|
||||
|
||||
**修复前**:
|
||||
```python
|
||||
# 重新选择最终的止损价(包括技术止损)
|
||||
# 仍保持"更宽松/更远"的选择规则
|
||||
if side == 'BUY':
|
||||
final_stop_loss = min(p[1] for p in candidate_prices) # ❌ 更宽松
|
||||
selected_method = [p[0] for p in candidate_prices if p[1] == final_stop_loss][0]
|
||||
else:
|
||||
final_stop_loss = max(p[1] for p in candidate_prices) # ❌ 更宽松
|
||||
selected_method = [p[0] for p in candidate_prices if p[1] == final_stop_loss][0]
|
||||
```
|
||||
|
||||
**修复后**:
|
||||
```python
|
||||
# ⚠️ 关键修复:重新选择最终的止损价(包括技术止损)
|
||||
# 必须保持"更紧的止损"(更接近入场价)的选择规则,保护资金
|
||||
# - 做多(BUY):止损价越低越紧 → 取最大值(更高的止损价,更接近入场价)
|
||||
# - 做空(SELL):止损价越高越紧 → 取最小值(更低的止损价,更接近入场价)
|
||||
if side == 'BUY':
|
||||
# 做多:选择更高的止损价(更接近入场价,更紧)
|
||||
final_stop_loss = max(p[1] for p in candidate_prices) # ✅ 更紧
|
||||
selected_method = [p[0] for p in candidate_prices if p[1] == final_stop_loss][0]
|
||||
else:
|
||||
# 做空:选择更低的止损价(更接近入场价,更紧)
|
||||
# ⚠️ 注意:对于SELL单,止损价高于入场价,所以"更低的止损价"意味着更接近入场价
|
||||
final_stop_loss = min(p[1] for p in candidate_prices) # ✅ 更紧
|
||||
selected_method = [p[0] for p in candidate_prices if p[1] == final_stop_loss][0]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 修复效果
|
||||
|
||||
### 修复前
|
||||
|
||||
**SELL单止损价格选择**:
|
||||
- 入场价:0.0731
|
||||
- 候选止损价:0.075(保证金止损)、0.082(ATR止损)
|
||||
- 选择:max(0.075, 0.082) = 0.082(更宽松,更远离入场价)❌
|
||||
- 结果:价格涨到0.0815时触发止损,亏损-91.93%
|
||||
|
||||
### 修复后
|
||||
|
||||
**SELL单止损价格选择**:
|
||||
- 入场价:0.0731
|
||||
- 候选止损价:0.075(保证金止损)、0.082(ATR止损)
|
||||
- 选择:min(0.075, 0.082) = 0.075(更紧,更接近入场价)✅
|
||||
- 结果:价格涨到0.075时触发止损,亏损约5%
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期效果
|
||||
|
||||
修复后预期:
|
||||
- ✅ SELL单止损价格正确,选择"更紧"的止损(更接近入场价)
|
||||
- ✅ 不再出现巨额亏损(-91.93%)
|
||||
- ✅ 止损及时触发,保护资金
|
||||
- ✅ 盈亏比改善(从0.39:1提升到1.5:1+)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **立即重启交易进程**:修复后需要重启所有交易进程,让新代码生效
|
||||
2. **监控SELL单**:修复后需要密切监控SELL单的止损价格和止损触发情况
|
||||
3. **检查现有持仓**:如果有现有的SELL单持仓,需要检查止损价格是否正确
|
||||
|
||||
---
|
||||
|
||||
## 📝 相关配置
|
||||
|
||||
当前配置:
|
||||
- `STOP_LOSS_PERCENT`: 0.15(15%)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
|
||||
- `MIN_STOP_LOSS_PRICE_PCT`: 0.02(2%)
|
||||
|
||||
建议:
|
||||
- 保持当前配置,修复后应该能正常工作
|
||||
- 如果仍然出现止损过宽的问题,可以考虑降低`ATR_STOP_LOSS_MULTIPLIER`到1.5
|
||||
128
docs/STOP_LOSS_IMMEDIATE_CLOSE_FIX.md
Normal file
128
docs/STOP_LOSS_IMMEDIATE_CLOSE_FIX.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# 止损立即平仓修复说明
|
||||
|
||||
## 🔍 问题描述
|
||||
|
||||
**时间范围**:23点后到早上
|
||||
**症状**:系统检测到价格已触发止损价,但只记录错误日志,**没有执行平仓操作**,导致亏损持续扩大。
|
||||
|
||||
### 错误日志示例
|
||||
```
|
||||
NOMUSDT ⚠️ 当前价格(0.00944000)已触发止损价(0.00977746),无法挂止损单,应该立即平仓!
|
||||
ZROUSDT ⚠️ 当前价格(2.02533560)已触发止损价(2.02531200),无法挂止损单,应该立即平仓!
|
||||
WCTUSDT ⚠️ 当前价格(0.08786000)已触发止损价(0.08963080),无法挂止损单,应该立即平仓!
|
||||
```
|
||||
|
||||
## 🎯 根本原因
|
||||
|
||||
在 `trading_system/position_manager.py` 的 `_ensure_exchange_sltp_orders()` 方法中:
|
||||
|
||||
1. **挂单逻辑死锁**:当 `current_price` 已经低于 `stop_loss_price`(做多时),币安 API 会拒绝 `STOP_MARKET` 订单,返回 `Order would immediately trigger`(错误代码 -2021)。
|
||||
|
||||
2. **只报警不执行**:代码检测到了这个情况并打印了警告日志,但**只设置了 `sl_order = None`,没有触发市价平仓**。
|
||||
|
||||
3. **依赖WebSocket延迟**:代码注释说"依赖WebSocket监控立即平仓",但WebSocket监控可能有延迟,在深夜价格剧烈波动时,无法及时止损。
|
||||
|
||||
## ✅ 修复方案
|
||||
|
||||
### 修复位置
|
||||
`trading_system/position_manager.py` 的 `_ensure_exchange_sltp_orders()` 方法
|
||||
|
||||
### 修复内容
|
||||
|
||||
#### 1. 止损价触发时立即平仓(第1199-1223行)
|
||||
|
||||
**修复前**:
|
||||
```python
|
||||
if current_price_val <= stop_loss_val:
|
||||
logger.error(f"{symbol} ⚠️ 当前价格(...)已触发止损价(...),无法挂止损单,应该立即平仓!")
|
||||
logger.error(f" 建议: 立即手动平仓或等待WebSocket监控触发平仓")
|
||||
sl_order = None # ❌ 只设置None,没有执行平仓
|
||||
```
|
||||
|
||||
**修复后**:
|
||||
```python
|
||||
if current_price_val <= stop_loss_val:
|
||||
logger.error(f"{symbol} ⚠️ 当前价格({current_price_val:.8f})已触发止损价({stop_loss_val:.8f}),无法挂止损单,立即执行市价平仓保护!")
|
||||
logger.error(f" 入场价: {entry_price_val:.8f if entry_price_val else 'N/A'}")
|
||||
# ✅ 立即执行市价平仓
|
||||
await self.close_position(symbol, reason='stop_loss')
|
||||
return # 直接返回,不再尝试挂单
|
||||
```
|
||||
|
||||
#### 2. 止盈价触发时立即平仓(第1272-1288行)
|
||||
|
||||
**新增逻辑**:在挂止盈单前,也检查价格是否已经达到止盈价,如果达到则立即执行市价平仓。
|
||||
|
||||
```python
|
||||
# 在挂止盈单前,检查当前价格是否已经触发止盈
|
||||
if current_price and take_profit:
|
||||
try:
|
||||
current_price_val = float(current_price)
|
||||
take_profit_val = float(take_profit)
|
||||
|
||||
# 检查是否已经触发止盈
|
||||
triggered_tp = False
|
||||
if side == "BUY" and current_price_val >= take_profit_val:
|
||||
triggered_tp = True
|
||||
elif side == "SELL" and current_price_val <= take_profit_val:
|
||||
triggered_tp = True
|
||||
|
||||
if triggered_tp:
|
||||
logger.info(f"{symbol} 🎯 当前价格({current_price_val:.8f})已达到止盈价({take_profit_val:.8f}),立即执行市价止盈!")
|
||||
await self.close_position(symbol, reason='take_profit')
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"{symbol} 检查止盈触发条件时出错: {e}")
|
||||
```
|
||||
|
||||
## 📊 修复效果
|
||||
|
||||
### 修复前
|
||||
- ❌ 检测到止损触发 → 只记录错误日志 → 等待WebSocket监控 → **可能延迟或失败**
|
||||
- ❌ 价格继续下跌 → 亏损扩大 → 直到下次扫描才可能止损
|
||||
|
||||
### 修复后
|
||||
- ✅ 检测到止损触发 → **立即执行市价平仓** → 止损保护立即生效
|
||||
- ✅ 价格继续下跌 → **已平仓,不再亏损**
|
||||
|
||||
## 🔄 触发场景
|
||||
|
||||
这个修复会在以下场景生效:
|
||||
|
||||
1. **开仓后立即检查**:在 `_ensure_exchange_sltp_orders()` 被调用时(开仓后立即执行)
|
||||
2. **系统重启后同步**:如果系统重启,同步持仓时会调用 `_ensure_exchange_sltp_orders()` 补挂保护单
|
||||
3. **定期检查**:`check_stop_loss_take_profit()` 方法会定期检查(通过扫描间隔)
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **市价平仓**:修复使用 `close_position()` 方法执行市价平仓,可能会有轻微滑点,但能确保及时止损。
|
||||
|
||||
2. **扫描间隔影响**:如果扫描间隔较长(如1小时),在间隔期间价格暴跌穿透止损线,等到下次扫描时,`_ensure_exchange_sltp_orders()` 会被调用(例如系统重启后),此时会立即平仓。
|
||||
|
||||
3. **WebSocket监控**:WebSocket监控仍然有效,作为第二层保护。但修复后,即使WebSocket延迟,也能通过价格检查立即平仓。
|
||||
|
||||
## 🚀 部署建议
|
||||
|
||||
1. **重启交易进程**:修复后需要重启所有 `trading_system` 进程才能生效。
|
||||
```bash
|
||||
supervisorctl restart auto_sys_acc1 auto_sys_acc2 auto_sys_acc3 ...
|
||||
```
|
||||
|
||||
2. **验证修复**:查看日志,确认当价格触发止损时,会看到:
|
||||
```
|
||||
{symbol} ⚠️ 当前价格(...)已触发止损价(...),无法挂止损单,立即执行市价平仓保护!
|
||||
{symbol} [平仓] 开始平仓操作 (原因: stop_loss)
|
||||
{symbol} [平仓] ✓ 平仓订单已提交
|
||||
```
|
||||
|
||||
3. **监控效果**:观察后续交易,确认深夜价格波动时能及时止损,不再出现"只报警不平仓"的情况。
|
||||
|
||||
## 📝 相关文件
|
||||
|
||||
- `trading_system/position_manager.py`:主要修复文件
|
||||
- `_ensure_exchange_sltp_orders()` 方法(第1101-1320行)
|
||||
- `close_position()` 方法(第669-769行)
|
||||
|
||||
## ✅ 修复完成时间
|
||||
|
||||
2026-01-25
|
||||
246
docs/STOP_LOSS_ORDER_FAILURE_ANALYSIS.md
Normal file
246
docs/STOP_LOSS_ORDER_FAILURE_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# 止损单挂单失败分析
|
||||
|
||||
## 📋 问题描述
|
||||
|
||||
INUSDT 止损单挂单失败,系统将依赖WebSocket监控,但可能无法及时止损。
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
INUSDT ❌ 止损单挂单失败!将依赖WebSocket监控,但可能无法及时止损
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 风险
|
||||
|
||||
**止损单挂单失败的风险**:
|
||||
1. **没有交易所级别保护**:如果系统崩溃或网络中断,可能无法及时止损
|
||||
2. **依赖WebSocket监控**:如果WebSocket断开,可能无法及时止损
|
||||
3. **用户无法在币安界面看到止损单**:无法手动确认止损单是否已设置
|
||||
|
||||
---
|
||||
|
||||
## 🔍 可能的原因
|
||||
|
||||
### 1. 止损价格计算错误
|
||||
|
||||
**问题**:
|
||||
- 止损价格可能不在正确的一侧
|
||||
- BUY时止损价应低于入场价,SELL时止损价应高于入场价
|
||||
- 如果止损价计算错误,币安会拒绝挂单
|
||||
|
||||
**检查**:
|
||||
- 查看日志中的止损价格和入场价格
|
||||
- 确认止损价格方向是否正确
|
||||
|
||||
### 2. 价格精度问题
|
||||
|
||||
**问题**:
|
||||
- 止损价格可能不符合币安的精度要求(tickSize)
|
||||
- 错误代码:-4014(Price not increased by tick size)
|
||||
|
||||
**检查**:
|
||||
- 查看日志中的价格精度信息
|
||||
- 确认止损价格是否对齐到 tickSize
|
||||
|
||||
### 3. 持仓不存在或方向不对
|
||||
|
||||
**问题**:
|
||||
- 可能没有持仓或持仓方向不匹配
|
||||
- 错误代码:-2022(ReduceOnly Order is rejected)
|
||||
|
||||
**检查**:
|
||||
- 确认币安账户中是否有持仓
|
||||
- 确认持仓方向是否匹配
|
||||
|
||||
### 4. 对冲/单向模式问题
|
||||
|
||||
**问题**:
|
||||
- 币安账户可能是对冲模式,但代码按单向模式处理(或反之)
|
||||
- 需要正确设置 `positionSide` 参数
|
||||
|
||||
**检查**:
|
||||
- 查看日志中的对冲模式信息
|
||||
- 确认 `positionSide` 参数是否正确
|
||||
|
||||
### 5. 触发价格会导致立即触发
|
||||
|
||||
**问题**:
|
||||
- 止损价格太接近当前价格,会导致立即触发
|
||||
- 币安会拒绝这种订单
|
||||
|
||||
**检查**:
|
||||
- 查看日志中的当前价格和止损价格
|
||||
- 确认止损价格是否在正确的一侧
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成的改进
|
||||
|
||||
### 1. 增强错误日志
|
||||
|
||||
**改进内容**:
|
||||
- 添加详细的错误信息(错误代码、错误消息)
|
||||
- 记录止损价格、当前价格、持仓方向等关键信息
|
||||
- 针对常见错误码提供具体的解决建议
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/binance_client.py:1535-1580`
|
||||
- `trading_system/position_manager.py:1154-1155`
|
||||
|
||||
### 2. 添加止损价格验证
|
||||
|
||||
**改进内容**:
|
||||
- 在挂单前验证止损价格方向是否正确
|
||||
- BUY时止损价应低于入场价,SELL时止损价应高于入场价
|
||||
- 如果验证失败,记录错误并跳过挂单
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py:1136-1148`
|
||||
|
||||
### 3. 改进重试逻辑
|
||||
|
||||
**改进内容**:
|
||||
- 如果首次挂单失败,尝试切换 `positionSide` 重试
|
||||
- 记录重试过程和结果
|
||||
- 如果所有重试都失败,记录详细参数用于调试
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/binance_client.py:1526-1549`
|
||||
|
||||
### 4. 自动获取当前价格
|
||||
|
||||
**改进内容**:
|
||||
- 如果未提供当前价格,自动从币安获取
|
||||
- 确保止损价格验证和调整使用最新的价格
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py:1150-1155`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 故障排查步骤
|
||||
|
||||
### 步骤1:查看详细错误日志
|
||||
|
||||
检查交易日志,查找以下信息:
|
||||
```
|
||||
INUSDT ❌ 挂保护单失败(STOP_MARKET): ...
|
||||
错误代码: ...
|
||||
触发价格: ...
|
||||
当前价格: ...
|
||||
持仓方向: ...
|
||||
平仓方向: ...
|
||||
价格精度: ..., 价格步长: ...
|
||||
```
|
||||
|
||||
### 步骤2:检查止损价格计算
|
||||
|
||||
确认止损价格是否正确:
|
||||
- **BUY订单**:止损价应 < 入场价
|
||||
- **SELL订单**:止损价应 > 入场价
|
||||
|
||||
如果止损价格方向错误,检查:
|
||||
1. `risk_manager.get_stop_loss_price()` 的计算逻辑
|
||||
2. ATR 值是否正确
|
||||
3. `ATR_STOP_LOSS_MULTIPLIER` 配置是否正确
|
||||
|
||||
### 步骤3:检查持仓状态
|
||||
|
||||
确认币安账户中是否有持仓:
|
||||
- 登录币安,查看是否有 INUSDT 的持仓
|
||||
- 确认持仓方向(LONG/SHORT)是否匹配
|
||||
|
||||
### 步骤4:检查价格精度
|
||||
|
||||
确认止损价格是否符合精度要求:
|
||||
- 查看日志中的 `价格精度` 和 `价格步长`
|
||||
- 确认止损价格是否对齐到 tickSize
|
||||
|
||||
### 步骤5:检查对冲模式
|
||||
|
||||
确认币安账户的持仓模式:
|
||||
- 查看日志中的 `对冲模式` 信息
|
||||
- 确认 `positionSide` 参数是否正确
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案
|
||||
|
||||
### 方案1:修复止损价格计算(如果计算错误)
|
||||
|
||||
**如果止损价格方向错误**:
|
||||
1. 检查 `risk_manager.get_stop_loss_price()` 方法
|
||||
2. 确认 ATR 计算是否正确
|
||||
3. 确认 `ATR_STOP_LOSS_MULTIPLIER` 配置是否正确
|
||||
|
||||
### 方案2:调整价格精度(如果精度问题)
|
||||
|
||||
**如果价格精度错误**:
|
||||
1. 检查 `_format_price_str_with_rounding()` 方法
|
||||
2. 确认价格格式化是否正确
|
||||
3. 确保止损价格对齐到 tickSize
|
||||
|
||||
### 方案3:手动设置止损(临时方案)
|
||||
|
||||
**如果自动挂单失败**:
|
||||
1. 登录币安,手动设置止损单
|
||||
2. 确保止损价格在正确的一侧
|
||||
3. 等待系统修复后,再使用自动挂单
|
||||
|
||||
### 方案4:检查持仓模式(如果模式问题)
|
||||
|
||||
**如果对冲模式问题**:
|
||||
1. 确认币安账户的持仓模式(对冲/单向)
|
||||
2. 检查代码中的 `dual` 变量是否正确
|
||||
3. 确保 `positionSide` 参数正确设置
|
||||
|
||||
---
|
||||
|
||||
## 📊 预期改善
|
||||
|
||||
改进后预期:
|
||||
1. **详细的错误日志**:能够快速定位问题原因
|
||||
2. **价格验证**:在挂单前验证止损价格,避免无效请求
|
||||
3. **自动重试**:尝试切换 `positionSide` 重试,提高成功率
|
||||
4. **更好的诊断**:记录所有关键参数,便于调试
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**止损单挂单失败是严重问题**,因为:
|
||||
1. 没有交易所级别保护,系统崩溃时可能无法止损
|
||||
2. 依赖WebSocket监控,网络中断时可能无法止损
|
||||
3. 用户无法在币安界面看到止损单
|
||||
|
||||
**必须尽快修复**,否则可能导致大额亏损。
|
||||
|
||||
---
|
||||
|
||||
## 🔍 需要检查的信息
|
||||
|
||||
1. **交易日志**:
|
||||
- 止损单挂单失败的详细错误信息
|
||||
- 错误代码和错误消息
|
||||
- 止损价格、当前价格、持仓方向
|
||||
|
||||
2. **币安账户**:
|
||||
- 是否有 INUSDT 的持仓
|
||||
- 持仓方向(LONG/SHORT)
|
||||
- 持仓模式(对冲/单向)
|
||||
|
||||
3. **配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER` 的值
|
||||
- `EXCHANGE_SLTP_ENABLED` 的值
|
||||
- 止损价格计算逻辑
|
||||
|
||||
---
|
||||
|
||||
## 📝 下一步行动
|
||||
|
||||
1. **查看详细日志**:检查最新的错误日志,确认具体失败原因
|
||||
2. **验证止损价格**:确认止损价格计算是否正确
|
||||
3. **检查持仓状态**:确认币安账户中是否有持仓
|
||||
4. **修复问题**:根据错误信息修复相应的问题
|
||||
5. **测试验证**:修复后测试止损单挂单是否成功
|
||||
263
docs/STRATEGY_LOGIC_ANALYSIS.md
Normal file
263
docs/STRATEGY_LOGIC_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
# 交易策略逻辑完整分析
|
||||
|
||||
## 📊 当前策略参数配置
|
||||
|
||||
### 核心参数
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `ATR_STOP_LOSS_MULTIPLIER` | 1.8 | ATR止损倍数(止损距离 = ATR × 1.8) |
|
||||
| `ATR_TAKE_PROFIT_MULTIPLIER` | 1.5 | ATR止盈倍数(备选方法,当无止损距离时使用) |
|
||||
| `RISK_REWARD_RATIO` | 1.5 | 盈亏比(止盈距离 = 止损距离 × 1.5) |
|
||||
| `MIN_TAKE_PROFIT_PRICE_PCT` | 0.02 (2%) | 最小止盈价格变动保护 |
|
||||
| `MIN_HOLD_TIME_SEC` | 1800 (30分钟) | 最小持仓时间锁 |
|
||||
| `USE_TRAILING_STOP` | False | 移动止损(已禁用) |
|
||||
|
||||
## 🎯 止盈计算逻辑(优先级顺序)
|
||||
|
||||
### 方法1:基于止损距离和盈亏比(优先使用)
|
||||
```
|
||||
止盈距离 = 止损距离 × RISK_REWARD_RATIO (1.5)
|
||||
止盈价 = 入场价 ± 止盈距离
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 入场价:100 USDT
|
||||
- ATR:3 USDT (3%)
|
||||
- 止损距离:100 × 0.03 × 1.8 = 5.4 USDT (5.4%)
|
||||
- 止损价:100 - 5.4 = 94.6 USDT
|
||||
- **止盈距离**:5.4 × 1.5 = **8.1 USDT (8.1%)**
|
||||
- **止盈价**:100 + 8.1 = **108.1 USDT**
|
||||
|
||||
### 方法2:基于ATR倍数(备选,当无止损距离时)
|
||||
```
|
||||
止盈距离 = ATR百分比 × ATR_TAKE_PROFIT_MULTIPLIER (1.5)
|
||||
止盈价 = 入场价 × (1 ± 止盈距离百分比)
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 入场价:100 USDT
|
||||
- ATR:3 USDT (3%)
|
||||
- **止盈距离**:0.03 × 1.5 = **4.5%**
|
||||
- **止盈价**:100 × 1.045 = **104.5 USDT**
|
||||
|
||||
### 方法3:基于保证金百分比(兜底)
|
||||
```
|
||||
止盈金额 = 保证金 × TAKE_PROFIT_PERCENT (25%)
|
||||
止盈价 = 入场价 ± (止盈金额 / 数量)
|
||||
```
|
||||
|
||||
### 方法4:最小价格变动保护
|
||||
```
|
||||
止盈价 = 入场价 × (1 ± MIN_TAKE_PROFIT_PRICE_PCT) (2%)
|
||||
```
|
||||
|
||||
**最终止盈价选择**:取以上方法中最宽松(最远)的价格
|
||||
|
||||
## 🔄 分步止盈策略
|
||||
|
||||
### 第一阶段:50% 仓位在 1:1 盈亏比止盈
|
||||
```
|
||||
第一目标价 = 入场价 ± (入场价 - 止损价)
|
||||
第一目标 = 盈亏比 1:1(相对于保证金)
|
||||
```
|
||||
|
||||
**触发条件**:
|
||||
- 当前盈亏百分比(基于保证金)≥ 止损百分比(基于保证金)
|
||||
- 平仓 50% 仓位
|
||||
- **将剩余仓位止损移至入场价(保本)**
|
||||
|
||||
### 第二阶段:剩余 50% 仓位在 1.5:1 盈亏比止盈
|
||||
```
|
||||
第二目标价 = 原始止盈价(基于止损距离 × 1.5)
|
||||
第二目标 = 盈亏比 1.5:1(相对于剩余仓位的保证金)
|
||||
```
|
||||
|
||||
**触发条件**:
|
||||
- 剩余仓位盈亏百分比(基于剩余保证金)≥ 1.5 × 止损百分比
|
||||
- 平仓剩余 50% 仓位
|
||||
|
||||
## 📈 胜率要求分析
|
||||
|
||||
### 理论盈亏比计算
|
||||
|
||||
假设:
|
||||
- 止损损失:-1 单位(基于保证金)
|
||||
- 第一目标盈利(50%仓位):+1 单位(1:1)
|
||||
- 第二目标盈利(50%仓位):+1.5 单位(1.5:1)
|
||||
|
||||
**完整交易期望收益**:
|
||||
- 如果第一目标触发(概率 P1),第二目标也触发(概率 P2):
|
||||
- 总盈利 = 0.5 × 1 + 0.5 × 1.5 = **1.25 单位**
|
||||
- 如果第一目标触发,但第二目标未触发(概率 P1 × (1-P2)):
|
||||
- 总盈利 = 0.5 × 1 + 0.5 × 0 = **0.5 单位**
|
||||
- 如果第一目标未触发,直接止损:
|
||||
- 总损失 = **-1 单位**
|
||||
|
||||
### 盈亏平衡点计算
|
||||
|
||||
**最理想情况**(第一目标100%触发,第二目标100%触发):
|
||||
```
|
||||
胜率 × 1.25 = 败率 × 1
|
||||
胜率 × 1.25 = (1 - 胜率) × 1
|
||||
胜率 × 2.25 = 1
|
||||
胜率 = 44.4%
|
||||
```
|
||||
|
||||
**保守情况**(第一目标100%触发,第二目标50%触发):
|
||||
```
|
||||
平均盈利 = 0.5 × 1.25 + 0.5 × 0.5 = 0.875 单位
|
||||
胜率 × 0.875 = (1 - 胜率) × 1
|
||||
胜率 × 1.875 = 1
|
||||
胜率 = 53.3%
|
||||
```
|
||||
|
||||
**最保守情况**(第一目标100%触发,第二目标0%触发):
|
||||
```
|
||||
平均盈利 = 0.5 单位
|
||||
胜率 × 0.5 = (1 - 胜率) × 1
|
||||
胜率 × 1.5 = 1
|
||||
胜率 = 66.7%
|
||||
```
|
||||
|
||||
### 实际胜率要求评估
|
||||
|
||||
**关键因素**:
|
||||
1. **第一目标触发率**:1:1 盈亏比相对容易触发(预期 60-70%)
|
||||
2. **第二目标触发率**:1.5:1 盈亏比需要趋势延续(预期 40-50%)
|
||||
3. **保本保护**:第一目标触发后,剩余仓位止损移至入场价,**彻底杜绝亏损可能**
|
||||
|
||||
**实际期望**:
|
||||
- 如果第一目标触发率 = 65%,第二目标触发率 = 45%
|
||||
- 平均盈利 = 0.65 × (0.45 × 1.25 + 0.55 × 0.5) = **0.65 × 0.8375 = 0.544 单位**
|
||||
- 盈亏平衡点:胜率 × 0.544 = (1 - 胜率) × 1
|
||||
- **胜率 = 64.8%**
|
||||
|
||||
## ⚠️ 潜在问题分析
|
||||
|
||||
### 1. 胜率要求较高
|
||||
**问题**:如果第一目标触发率低,或第二目标触发率低,需要更高的胜率才能盈利。
|
||||
|
||||
**缓解措施**:
|
||||
- ✅ 分步止盈确保至少锁定部分利润
|
||||
- ✅ 保本保护确保第一目标触发后不会亏损
|
||||
- ✅ 最小持仓时间锁(30分钟)避免过早平仓
|
||||
- ⚠️ **需要监控实际第一/第二目标触发率**
|
||||
|
||||
### 2. ATR_TAKE_PROFIT_MULTIPLIER 与 RISK_REWARD_RATIO 的关系
|
||||
**当前逻辑**:
|
||||
- 优先使用 `止损距离 × RISK_REWARD_RATIO (1.5)` 计算止盈
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER (1.5)` 仅作为备选(当无止损距离时)
|
||||
|
||||
**潜在问题**:
|
||||
- 如果 ATR 很小,`ATR_TAKE_PROFIT_MULTIPLIER` 可能计算出过小的止盈距离
|
||||
- 但 `MIN_TAKE_PROFIT_PRICE_PCT (2%)` 提供了保护
|
||||
|
||||
**建议**:
|
||||
- ✅ 当前逻辑合理,`ATR_TAKE_PROFIT_MULTIPLIER` 主要作为备选
|
||||
- ✅ `MIN_TAKE_PROFIT_PRICE_PCT` 确保最小止盈距离
|
||||
|
||||
### 3. 分步止盈的保本逻辑
|
||||
**当前实现**:
|
||||
- 第一目标触发后,剩余仓位止损移至入场价(保本)
|
||||
- **无论 `USE_TRAILING_STOP` 是否启用,都会移至保本**
|
||||
|
||||
**优势**:
|
||||
- ✅ 彻底杜绝第一目标触发后的亏损可能
|
||||
- ✅ 剩余仓位可以追求更高收益
|
||||
|
||||
**潜在问题**:
|
||||
- ⚠️ 如果价格在入场价附近震荡,可能频繁触发保本止损
|
||||
- ⚠️ 但这是可接受的,因为已经锁定了50%的利润
|
||||
|
||||
### 4. 止盈价选择逻辑
|
||||
**当前实现**:取所有方法中最宽松(最远)的价格
|
||||
|
||||
**潜在问题**:
|
||||
- 如果 `TAKE_PROFIT_PERCENT (25%)` 计算出的止盈价很远,可能难以触发
|
||||
- 但 ATR 方法通常会给出更合理的价格
|
||||
|
||||
**建议**:
|
||||
- ✅ 当前逻辑合理,优先使用 ATR 方法
|
||||
- ⚠️ 需要监控实际止盈触发率
|
||||
|
||||
## 📋 策略逻辑流程图
|
||||
|
||||
```
|
||||
开仓
|
||||
↓
|
||||
计算止损(ATR × 1.8)
|
||||
↓
|
||||
计算止盈(止损距离 × 1.5 或 ATR × 1.5)
|
||||
↓
|
||||
设置第一目标(1:1 盈亏比,50%仓位)
|
||||
↓
|
||||
设置第二目标(1.5:1 盈亏比,剩余50%仓位)
|
||||
↓
|
||||
监控持仓
|
||||
↓
|
||||
├─→ 触发止损 → 平仓(损失 -1 单位)
|
||||
│
|
||||
├─→ 触发第一目标 → 平仓50% → 止损移至保本 → 继续监控
|
||||
│ │
|
||||
│ └─→ 触发第二目标 → 平仓剩余50%(总盈利 1.25 单位)
|
||||
│ └─→ 触发保本止损 → 平仓剩余50%(总盈利 0.5 单位)
|
||||
│
|
||||
└─→ 最小持仓时间未到 → 继续监控
|
||||
```
|
||||
|
||||
## 🎯 优化建议
|
||||
|
||||
### 1. 监控关键指标
|
||||
- **第一目标触发率**:目标 ≥ 60%
|
||||
- **第二目标触发率**:目标 ≥ 40%
|
||||
- **实际盈亏比**:目标 ≥ 1.2
|
||||
- **盈利因子**:目标 ≥ 1.1
|
||||
|
||||
### 2. 如果胜率不足
|
||||
**选项A**:提高第一目标触发率
|
||||
- 降低第一目标到 0.8:1 盈亏比
|
||||
- 但会降低平均盈利
|
||||
|
||||
**选项B**:提高第二目标触发率
|
||||
- 降低第二目标到 1.2:1 盈亏比
|
||||
- 但会降低平均盈利
|
||||
|
||||
**选项C**:提高入场信号质量
|
||||
- 提高 `MIN_SIGNAL_STRENGTH`(当前 8)
|
||||
- 仅在 `marketRegime=trending` 时交易
|
||||
- 提高 `MIN_SIGNAL_STRENGTH` 到 9 或 10
|
||||
|
||||
### 3. 如果第一目标触发率低
|
||||
- 检查是否因为最小持仓时间锁导致过早平仓
|
||||
- 检查止损是否过紧(ATR_STOP_LOSS_MULTIPLIER = 1.8 是否合理)
|
||||
- 考虑降低第一目标到 0.9:1
|
||||
|
||||
### 4. 如果第二目标触发率低
|
||||
- 检查止盈价是否过远
|
||||
- 考虑降低第二目标到 1.3:1 或 1.2:1
|
||||
- 但需要权衡:降低目标会降低平均盈利
|
||||
|
||||
## ✅ 总结
|
||||
|
||||
### 当前策略的优势
|
||||
1. ✅ **分步止盈**:锁定部分利润,降低风险
|
||||
2. ✅ **保本保护**:第一目标触发后不会亏损
|
||||
3. ✅ **动态止损**:基于 ATR,适应市场波动
|
||||
4. ✅ **最小持仓时间**:避免过早平仓
|
||||
|
||||
### 当前策略的挑战
|
||||
1. ⚠️ **胜率要求**:需要 45-65% 胜率(取决于第二目标触发率)
|
||||
2. ⚠️ **第二目标触发率**:需要趋势延续,可能较低
|
||||
3. ⚠️ **需要监控**:实际触发率可能与理论不符
|
||||
|
||||
### 建议
|
||||
1. **先运行观察**:收集实际数据(第一/第二目标触发率、实际盈亏比)
|
||||
2. **根据数据调整**:
|
||||
- 如果第一目标触发率 < 60%:考虑降低到 0.9:1
|
||||
- 如果第二目标触发率 < 40%:考虑降低到 1.3:1
|
||||
- 如果胜率 < 50%:提高入场信号质量
|
||||
3. **目标指标**:
|
||||
- 第一目标触发率 ≥ 60%
|
||||
- 第二目标触发率 ≥ 40%
|
||||
- 实际盈亏比 ≥ 1.2
|
||||
- 盈利因子 ≥ 1.1
|
||||
247
docs/STRATEGY_OPTIMIZATION_PLAN.md
Normal file
247
docs/STRATEGY_OPTIMIZATION_PLAN.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# 交易策略优化计划
|
||||
|
||||
## 📋 优化目标
|
||||
|
||||
根据专业建议,系统化提升:
|
||||
1. **入场信号质量** (Win Rate Up)
|
||||
2. **利润捕获能力** (Profit Up)
|
||||
3. **风险控制** (Survival First)
|
||||
4. **系统可靠性** (Reliability Up)
|
||||
5. **小众币专项优化**
|
||||
|
||||
---
|
||||
|
||||
## 1. 动态过滤:提升入场信号质量 (Win Rate Up)
|
||||
|
||||
### 1.1 大盘共振(Beta Filter)✅ 优先实现
|
||||
|
||||
**目标**:当BTC或ETH在15min/1h周期剧烈下跌时,自动屏蔽所有多单信号
|
||||
|
||||
**实现方案**:
|
||||
- 在 `strategy.py` 的 `_analyze_trade_signal` 中增加大盘检查
|
||||
- 获取BTCUSDT和ETHUSDT的15min和1h K线
|
||||
- 计算最近N根K线的涨跌幅
|
||||
- 如果BTC或ETH在15min/1h周期下跌超过阈值(如-3%),屏蔽所有多单
|
||||
- 配置项:`BETA_FILTER_ENABLED`, `BETA_FILTER_THRESHOLD`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/strategy.py` - `_analyze_trade_signal()`
|
||||
- `trading_system/market_scanner.py` - 增加大盘数据获取
|
||||
|
||||
### 1.2 波动率阈值
|
||||
|
||||
**目标**:避开成交量极低或ATR异常激增的时刻
|
||||
|
||||
**实现方案**:
|
||||
- 在 `market_scanner.py` 中增加波动率检查
|
||||
- ATR异常激增:当前ATR / 平均ATR > 阈值(如2.0)
|
||||
- 成交量极低:24H Volume < 配置阈值(如1000万美金)
|
||||
- 配置项:`ATR_SPIKE_THRESHOLD`, `MIN_VOLUME_24H_STRICT`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/market_scanner.py` - `_get_symbol_change()`
|
||||
- `trading_system/indicators.py` - ATR计算
|
||||
|
||||
### 1.3 信号强度分级
|
||||
|
||||
**目标**:9-10分信号分配更高权重,8分信号仅作为轻仓试探
|
||||
|
||||
**实现方案**:
|
||||
- 在 `risk_manager.py` 的 `calculate_position_size` 中根据信号强度调整仓位
|
||||
- 9-10分:使用100%仓位(MAX_POSITION_PERCENT)
|
||||
- 8分:使用50%仓位(MAX_POSITION_PERCENT * 0.5)
|
||||
- 配置项:`SIGNAL_STRENGTH_POSITION_MULTIPLIER`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_position_size()`
|
||||
- `trading_system/strategy.py` - 传递信号强度
|
||||
|
||||
---
|
||||
|
||||
## 2. 策略优化:从"固定止盈"到"动态追踪" (Profit Up)
|
||||
|
||||
### 2.1 追踪止损(Trailing Stop)
|
||||
|
||||
**目标**:当价格达到1:1目标后,利用币安Trailing Stop Order或代码层面根据ATR向上移动止损线
|
||||
|
||||
**实现方案**:
|
||||
- 检查币安是否支持 `TRAILING_STOP_MARKET` 订单类型
|
||||
- 如果支持:在分步止盈后,挂币安Trailing Stop Order
|
||||
- 如果不支持:代码层面实现,根据ATR动态调整止损价
|
||||
- 配置项:`USE_TRAILING_STOP_AFTER_PARTIAL_PROFIT`, `TRAILING_STOP_ATR_MULTIPLIER`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py` - 分步止盈后逻辑
|
||||
- `trading_system/binance_client.py` - Trailing Stop Order支持
|
||||
|
||||
### 2.2 ADX趋势强度判断
|
||||
|
||||
**目标**:如果ADX > 25且处于上升趋势,延迟第一止盈位触发或取消50%减仓
|
||||
|
||||
**实现方案**:
|
||||
- 在 `indicators.py` 中计算ADX
|
||||
- 在 `position_manager.py` 的止盈检查中,如果ADX > 25且趋势向上,跳过第一止盈(50%减仓)
|
||||
- 配置项:`ADX_STRONG_TREND_THRESHOLD`, `ADX_SKIP_PARTIAL_PROFIT`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/indicators.py` - ADX计算
|
||||
- `trading_system/position_manager.py` - 止盈逻辑
|
||||
|
||||
---
|
||||
|
||||
## 3. 仓位管理:基于风险的头寸缩放 (Survival First)
|
||||
|
||||
### 3.1 凯利公式/固定风险百分比
|
||||
|
||||
**目标**:根据止损距离反算仓位,确保每笔单子赔掉的钱占总资金的比例恒定(如2%)
|
||||
|
||||
**实现方案**:
|
||||
- 在 `risk_manager.py` 的 `calculate_position_size` 中实现
|
||||
- 公式:`仓位大小 = (总资金 * 每笔单子承受的风险%) / (入场价 - 止损价)`
|
||||
- 配置项:`FIXED_RISK_PERCENT`, `USE_FIXED_RISK_SIZING`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_position_size()`
|
||||
|
||||
### 3.2 阶梯杠杆
|
||||
|
||||
**目标**:针对小众币,强制限制最高杠杆(如3-5倍)
|
||||
|
||||
**实现方案**:
|
||||
- 在 `risk_manager.py` 的 `calculate_dynamic_leverage` 中增加波动率检查
|
||||
- 如果ATR过高或成交量过低,限制最高杠杆
|
||||
- 配置项:`MAX_LEVERAGE_SMALL_CAP`, `ATR_LEVERAGE_REDUCTION_THRESHOLD`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/risk_manager.py` - `calculate_dynamic_leverage()`
|
||||
|
||||
---
|
||||
|
||||
## 4. 基础设施与风控 (Reliability Up)
|
||||
|
||||
### 4.1 心跳检测与延迟监控
|
||||
|
||||
**目标**:WebSocket断线重连机制 + 每1-2分钟兜底巡检
|
||||
|
||||
**实现方案**:
|
||||
- 在 `position_manager.py` 的WebSocket监控中增加心跳检测
|
||||
- 如果WebSocket断线,自动重连
|
||||
- 增加独立的定时巡检任务(每1-2分钟),作为兜底
|
||||
- 配置项:`WEBSOCKET_HEARTBEAT_INTERVAL`, `FALLBACK_CHECK_INTERVAL`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py` - WebSocket监控逻辑
|
||||
|
||||
### 4.2 滑点保护
|
||||
|
||||
**目标**:使用MARK_PRICE触发,但执行时使用LIMIT单或带保护的MARKET单
|
||||
|
||||
**实现方案**:
|
||||
- 在 `position_manager.py` 的平仓逻辑中
|
||||
- 使用MARK_PRICE判断是否触发止损/止盈
|
||||
- 执行时使用LIMIT单(当前价±滑点容差)或带保护的MARKET单
|
||||
- 配置项:`SLIPPAGE_TOLERANCE_PCT`, `USE_LIMIT_ON_CLOSE`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/position_manager.py` - `close_position()`
|
||||
|
||||
---
|
||||
|
||||
## 5. 针对小众币的专项优化
|
||||
|
||||
### 5.1 资金费率避险
|
||||
|
||||
**目标**:在费率结算前(8:00, 16:00, 24:00),如果费率过高(>0.1%),提前止盈或暂缓入场
|
||||
|
||||
**实现方案**:
|
||||
- 在 `binance_client.py` 中获取资金费率
|
||||
- 在 `strategy.py` 中检查是否接近结算时间(8:00, 16:00, 24:00)
|
||||
- 如果费率 > 0.1%,提前止盈或暂缓入场
|
||||
- 配置项:`FUNDING_RATE_THRESHOLD`, `FUNDING_RATE_EARLY_EXIT_HOURS`
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/binance_client.py` - 资金费率获取
|
||||
- `trading_system/strategy.py` - 入场检查
|
||||
- `trading_system/position_manager.py` - 止盈检查
|
||||
|
||||
### 5.2 成交量验证
|
||||
|
||||
**目标**:24H Volume低于1000万美金,直接剔除
|
||||
|
||||
**实现方案**:
|
||||
- 在 `market_scanner.py` 中增加严格成交量过滤
|
||||
- 配置项:`MIN_VOLUME_24H_STRICT` (10000000)
|
||||
|
||||
**代码位置**:
|
||||
- `trading_system/market_scanner.py` - 扫描过滤
|
||||
|
||||
---
|
||||
|
||||
## 📊 实施优先级
|
||||
|
||||
### ✅ 高优先级(已完成)
|
||||
|
||||
1. ✅ **大盘共振(Beta Filter)** - 当BTC/ETH下跌超过-3%时,屏蔽所有多单
|
||||
2. ✅ **成交量验证(1000万美金)** - 24H Volume低于1000万美金直接剔除
|
||||
3. ✅ **固定风险百分比仓位计算** - 根据止损距离反算仓位,每笔风险恒定2%
|
||||
4. ✅ **信号强度分级** - 8分50%仓位,9-10分100%仓位
|
||||
5. ✅ **阶梯杠杆** - 小众币(ATR>=5%)限制最高杠杆5倍
|
||||
|
||||
**预期效果**:
|
||||
- ✅ 减少大盘暴跌时的损失
|
||||
- ✅ 避免流动性差的币种,减少滑点损失(2-3%)
|
||||
- ✅ 每笔单子风险恒定(2%),避免30%的大额亏损
|
||||
- ✅ 高质量信号获得更大收益,低质量信号降低风险
|
||||
- ✅ 小众币风险降低,减少强平风险
|
||||
|
||||
### ⏳ 中优先级(待实施)
|
||||
|
||||
6. ⏳ 波动率阈值 - 避开ATR异常激增的时刻
|
||||
7. ⏳ 心跳检测与兜底巡检 - WebSocket断线重连和兜底巡检
|
||||
8. ⏳ 滑点保护 - 使用MARK_PRICE触发,LIMIT单执行
|
||||
|
||||
### 中优先级(本周实施)
|
||||
5. 波动率阈值
|
||||
6. 信号强度分级
|
||||
7. 阶梯杠杆(小众币)
|
||||
8. 滑点保护
|
||||
|
||||
### 低优先级(后续优化)
|
||||
9. 追踪止损(Trailing Stop)
|
||||
10. ADX趋势强度判断
|
||||
11. 资金费率避险
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置项汇总
|
||||
|
||||
```python
|
||||
# 动态过滤
|
||||
'BETA_FILTER_ENABLED': True,
|
||||
'BETA_FILTER_THRESHOLD': -0.03, # -3%
|
||||
'ATR_SPIKE_THRESHOLD': 2.0,
|
||||
'MIN_VOLUME_24H_STRICT': 10000000, # 1000万美金
|
||||
'SIGNAL_STRENGTH_POSITION_MULTIPLIER': {8: 0.5, 9: 1.0, 10: 1.0},
|
||||
|
||||
# 策略优化
|
||||
'USE_TRAILING_STOP_AFTER_PARTIAL_PROFIT': True,
|
||||
'TRAILING_STOP_ATR_MULTIPLIER': 1.5,
|
||||
'ADX_STRONG_TREND_THRESHOLD': 25,
|
||||
'ADX_SKIP_PARTIAL_PROFIT': True,
|
||||
|
||||
# 仓位管理
|
||||
'USE_FIXED_RISK_SIZING': True,
|
||||
'FIXED_RISK_PERCENT': 0.02, # 2%
|
||||
'MAX_LEVERAGE_SMALL_CAP': 5,
|
||||
'ATR_LEVERAGE_REDUCTION_THRESHOLD': 0.05, # 5%
|
||||
|
||||
# 基础设施
|
||||
'WEBSOCKET_HEARTBEAT_INTERVAL': 30, # 30秒
|
||||
'FALLBACK_CHECK_INTERVAL': 120, # 2分钟
|
||||
'SLIPPAGE_TOLERANCE_PCT': 0.002, # 0.2%
|
||||
'USE_LIMIT_ON_CLOSE': True,
|
||||
|
||||
# 小众币优化
|
||||
'FUNDING_RATE_THRESHOLD': 0.001, # 0.1%
|
||||
'FUNDING_RATE_EARLY_EXIT_HOURS': 1, # 结算前1小时
|
||||
```
|
||||
219
docs/SUPERVISOR_TROUBLESHOOTING.md
Normal file
219
docs/SUPERVISOR_TROUBLESHOOTING.md
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
# Supervisor 交易进程启动问题排查指南
|
||||
|
||||
## 🔍 问题1:UnicodeDecodeError(编码错误)
|
||||
|
||||
### 错误信息
|
||||
```
|
||||
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. 常见原因及解决方案
|
||||
|
||||
##### 原因1:Python 路径不可执行或依赖缺失
|
||||
|
||||
**症状**:
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
##### 原因2:API 密钥未配置
|
||||
|
||||
**症状**:
|
||||
```
|
||||
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` - 标准输出日志
|
||||
109
docs/TAKE_PROFIT_TIME_LOCK_ANALYSIS.md
Normal file
109
docs/TAKE_PROFIT_TIME_LOCK_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# 止盈时间锁分析与优化建议
|
||||
|
||||
## 🤔 问题:止盈时间锁是否有必要?
|
||||
|
||||
### 当前情况
|
||||
- ✅ **止损**:已修复,不受时间锁限制,立即执行
|
||||
- ⚠️ **止盈**:仍然受30分钟时间锁限制
|
||||
|
||||
### 止盈时间锁的利弊分析
|
||||
|
||||
#### ✅ 支持保留的理由(原始设计意图)
|
||||
1. **防止过早止盈**
|
||||
- 避免价格刚达到止盈目标就立即平仓
|
||||
- 给趋势更多时间发展,追求更大利润
|
||||
- 符合"让利润奔跑"的交易理念
|
||||
|
||||
2. **避免分钟级平仓**
|
||||
- 防止因短期波动触发止盈
|
||||
- 强制波段持仓纪律
|
||||
- 减少频繁交易成本
|
||||
|
||||
3. **配合分步止盈策略**
|
||||
- 第一目标(1:1)在30分钟后才能触发
|
||||
- 给市场更多时间达到第二目标(1.5:1)
|
||||
|
||||
#### ❌ 反对保留的理由(实际问题)
|
||||
1. **错过最佳止盈时机**
|
||||
- 如果价格在30分钟内达到止盈目标,但之后回落
|
||||
- 可能从盈利变成亏损
|
||||
- **对于小众币,价格波动剧烈,30分钟可能错过最佳退出点**
|
||||
|
||||
2. **与交易所级别止盈单冲突**
|
||||
- 币安交易所级别的止盈单不受时间锁限制
|
||||
- 如果交易所止盈单触发,但本地监控被时间锁阻止,可能造成不一致
|
||||
|
||||
3. **降低资金效率**
|
||||
- 资金被锁定30分钟,即使已经达到目标
|
||||
- 无法及时释放资金用于新机会
|
||||
|
||||
4. **实际案例**
|
||||
- 用户反馈亏损严重,可能也与止盈不及时有关
|
||||
- 如果止盈能及时执行,可能减少亏损
|
||||
|
||||
## 📊 数据驱动的决策建议
|
||||
|
||||
### 方案A:完全移除止盈时间锁(推荐)
|
||||
**优点**:
|
||||
- ✅ 止盈立即执行,不错过最佳退出点
|
||||
- ✅ 与交易所级别止盈单一致
|
||||
- ✅ 提高资金效率
|
||||
- ✅ 减少因价格回落导致的利润回吐
|
||||
|
||||
**缺点**:
|
||||
- ❌ 可能过早止盈,错过更大利润
|
||||
- ❌ 可能因短期波动触发止盈
|
||||
|
||||
**适用场景**:
|
||||
- 小众币(波动剧烈,需要及时止盈)
|
||||
- 短期交易策略
|
||||
- 追求稳定收益而非最大化利润
|
||||
|
||||
### 方案B:缩短时间锁(折中方案)
|
||||
**建议**:将30分钟缩短到5-10分钟
|
||||
|
||||
**优点**:
|
||||
- ✅ 保留防止过早止盈的保护
|
||||
- ✅ 减少错过最佳退出点的风险
|
||||
- ✅ 平衡利润最大化与及时止盈
|
||||
|
||||
**缺点**:
|
||||
- ❌ 仍然可能错过最佳退出点
|
||||
- ❌ 需要测试确定最佳时长
|
||||
|
||||
### 方案C:保留但可配置(灵活方案)
|
||||
**建议**:将时间锁设为可配置,默认值降低
|
||||
|
||||
**优点**:
|
||||
- ✅ 灵活性高,可根据市场调整
|
||||
- ✅ 可以针对不同币种设置不同值
|
||||
- ✅ 保留原始设计意图
|
||||
|
||||
**缺点**:
|
||||
- ❌ 增加配置复杂度
|
||||
- ❌ 需要用户理解并正确配置
|
||||
|
||||
## 🎯 推荐方案:完全移除止盈时间锁 ✅ 已实施
|
||||
|
||||
### 理由
|
||||
1. **止损已不受限制**:如果止损可以立即执行,止盈也应该可以
|
||||
2. **交易所级别保护**:币安交易所级别的止盈单已经提供保护
|
||||
3. **分步止盈策略**:分步止盈本身已经提供了利润保护(50%在1:1止盈,剩余保本)
|
||||
4. **实际需求**:用户反馈亏损严重,需要及时止盈保护利润
|
||||
|
||||
### ✅ 已实施
|
||||
1. ✅ **完全移除**:已移除所有止盈时间锁限制
|
||||
2. ✅ **保留分步止盈**:分步止盈策略仍然有效,提供利润保护
|
||||
3. ✅ **依赖交易所级别止盈单**:主要依赖币安交易所级别的止盈单
|
||||
4. ✅ **修复位置**:
|
||||
- `check_stop_loss_take_profit()` - 定期检查
|
||||
- `_check_single_position()` - WebSocket实时监控(两处)
|
||||
|
||||
## 📈 预期效果
|
||||
|
||||
移除止盈时间锁后:
|
||||
- ✅ 止盈能及时执行,保护利润
|
||||
- ✅ 减少因价格回落导致的利润回吐
|
||||
- ✅ 提高资金效率
|
||||
- ✅ 与止损逻辑一致(都不受时间锁限制)
|
||||
- ⚠️ 可能错过一些更大利润的机会(但分步止盈策略会部分补偿)
|
||||
127
docs/TRADING_FLOW_ANALYSIS.md
Normal file
127
docs/TRADING_FLOW_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# 交易流程分析与优化方案
|
||||
|
||||
## 🔴 当前严重问题:亏损达到30%以上
|
||||
|
||||
### 问题分析
|
||||
|
||||
根据最近的交易记录:
|
||||
- CLOUSDT SELL: -17.54% (手动平仓)
|
||||
- ICNTUSDT BUY: -19.60% (手动平仓)
|
||||
- 0GUSDT BUY: -31.34% (手动平仓)
|
||||
- ALCHUSDT BUY: -30.95% (同步平仓)
|
||||
|
||||
**核心问题:止损没有及时触发,导致亏损远超止损设置(通常止损设置为8-10%)**
|
||||
|
||||
### 根本原因
|
||||
|
||||
1. **最小持仓时间锁阻止止损触发** ⚠️ **最严重**
|
||||
- `MIN_HOLD_TIME_SEC = 1800秒(30分钟)`
|
||||
- 在持仓前30分钟内,即使触发止损,系统也会**禁止平仓**
|
||||
- 这导致止损单无法执行,亏损持续扩大
|
||||
- **对于小众币,30分钟内价格可能剧烈波动,亏损可能达到30%以上**
|
||||
|
||||
2. **交易所级别止损单可能未正确挂单**
|
||||
- 如果 `_ensure_exchange_sltp_orders` 失败,只有本地监控
|
||||
- 本地监控被时间锁阻止,无法平仓
|
||||
|
||||
3. **止损检查逻辑在时间锁之后**
|
||||
- 代码顺序:先检查时间锁 → 如果不足30分钟,直接 `continue`/`return`
|
||||
- 止损检查逻辑永远不会执行
|
||||
|
||||
## 📊 当前交易流程
|
||||
|
||||
### 开仓流程
|
||||
1. 市场扫描(每30分钟)
|
||||
2. 信号筛选(MIN_SIGNAL_STRENGTH >= 8)
|
||||
3. 计算止损止盈(基于ATR或保证金)
|
||||
4. 挂限价单开仓
|
||||
5. 订单成交后:
|
||||
- 保存交易记录到数据库
|
||||
- 在币安挂止损/止盈保护单(`_ensure_exchange_sltp_orders`)
|
||||
- 启动WebSocket实时监控
|
||||
|
||||
### 平仓流程(当前有严重问题)
|
||||
|
||||
#### 方式1:交易所级别止损/止盈单(最可靠)
|
||||
- 币安自动触发,不受时间锁影响
|
||||
- **但如果挂单失败,就没有保护**
|
||||
|
||||
#### 方式2:本地监控检查(被时间锁阻止)
|
||||
- `check_stop_loss_take_profit()` 定期检查
|
||||
- `_check_single_position()` WebSocket实时监控
|
||||
- **都被 `MIN_HOLD_TIME_SEC` 阻止,前30分钟无法平仓**
|
||||
|
||||
## ✅ 优化方案(已实施)
|
||||
|
||||
### 1. ✅ 完全移除最小持仓时间锁(已修复)
|
||||
|
||||
**问题**:时间锁阻止止损和止盈,导致亏损扩大和利润回吐
|
||||
|
||||
**解决方案**:✅ **完全移除时间锁限制**
|
||||
- ✅ 止损检查在时间锁之前执行,立即平仓
|
||||
- ✅ 止盈也立即执行,不受时间锁限制
|
||||
- ✅ 止损和止盈逻辑一致,都立即执行
|
||||
- ✅ 修复了三个位置:`check_stop_loss_take_profit()`、`_check_single_position()` 和移动止损检查
|
||||
|
||||
**移除理由**:
|
||||
1. 止损和止盈都应该立即执行,保护资金和利润
|
||||
2. 交易所级别的止损/止盈单已提供保护
|
||||
3. 分步止盈策略本身已提供利润保护(50%在1:1止盈,剩余保本)
|
||||
4. 及时执行可以避免价格回落导致的利润回吐
|
||||
5. 如果需要防止秒级平仓,可以通过提高入场信号质量(MIN_SIGNAL_STRENGTH)来实现
|
||||
|
||||
### 2. 确保交易所级别止损单正确挂单
|
||||
|
||||
- 增加日志,记录挂单成功/失败
|
||||
- 如果挂单失败,重试机制
|
||||
- 定期检查并补挂止损单
|
||||
|
||||
### 3. 优化止损逻辑
|
||||
|
||||
- 止损检查应该在时间锁之前(如果采用选项B)
|
||||
- 或者完全移除时间锁对止损的限制
|
||||
|
||||
### 4. 针对小众币的优化
|
||||
|
||||
- 提高最小成交量要求(避免流动性差的币)
|
||||
- 增大止损距离(ATR倍数)以应对高波动
|
||||
- 降低杠杆倍数(降低风险)
|
||||
|
||||
## 🎯 具体修复建议
|
||||
|
||||
### 立即修复(高优先级)
|
||||
|
||||
1. **移除时间锁对止损的限制**
|
||||
- 止损应该立即执行,不受时间锁影响
|
||||
- 时间锁只应用于止盈(防止过早止盈)
|
||||
|
||||
2. **增强止损单挂单可靠性**
|
||||
- 增加重试机制
|
||||
- 增加失败告警
|
||||
- 定期检查并补挂
|
||||
|
||||
3. **优化止损检查逻辑**
|
||||
- 确保止损检查在时间锁之前(如果保留时间锁)
|
||||
- 或者完全移除时间锁
|
||||
|
||||
### 中期优化
|
||||
|
||||
1. **提高入场信号质量**
|
||||
- 提高 `MIN_SIGNAL_STRENGTH` 到 9-10
|
||||
- 只交易高质量信号
|
||||
|
||||
2. **优化止损距离**
|
||||
- 对于小众币,使用更大的ATR倍数(2.0-2.5)
|
||||
- 确保止损距离足够,不会被正常波动触发
|
||||
|
||||
3. **降低杠杆**
|
||||
- 对于小众币,降低杠杆到5-8倍
|
||||
- 降低单笔仓位到5%
|
||||
|
||||
## 📈 预期效果
|
||||
|
||||
修复后:
|
||||
- ✅ 止损能及时触发,亏损控制在8-10%以内
|
||||
- ✅ 不会出现30%以上的大额亏损
|
||||
- ✅ 胜率提升(及时止损,避免大亏)
|
||||
- ✅ 盈亏比改善(小亏大赚)
|
||||
222
docs/TRADING_LOSS_ANALYSIS_2026-01-23-2.md
Normal file
222
docs/TRADING_LOSS_ANALYSIS_2026-01-23-2.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# 交易亏损分析报告 - 2026-01-23(第二批)
|
||||
|
||||
## 📊 亏损交易详情
|
||||
|
||||
### 交易 #1278 (INUSDT)
|
||||
- **方向**:BUY
|
||||
- **入场价**:0.0937 USDT
|
||||
- **出场价**:0.0914 USDT
|
||||
- **价格跌幅**:**2.45%**
|
||||
- **盈亏**:-2.54 USDT
|
||||
- **盈亏比例**:**-37.00%**(相对于保证金6.87 USDT)
|
||||
- **持仓时间**:10分钟(16:03 - 16:13)
|
||||
- **平仓类型**:手动平仓 ❌
|
||||
|
||||
### 交易 #1275 (INUSDT)
|
||||
- **方向**:BUY
|
||||
- **入场价**:0.0970 USDT
|
||||
- **出场价**:0.0952 USDT
|
||||
- **价格跌幅**:**1.86%**
|
||||
- **盈亏**:-1.97 USDT
|
||||
- **盈亏比例**:**-28.60%**(相对于保证金6.90 USDT)
|
||||
- **持仓时间**:10分钟(15:33 - 15:43)
|
||||
- **平仓类型**:手动平仓 ❌
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 核心问题分析
|
||||
|
||||
### 问题1:手动平仓误判(最严重)
|
||||
|
||||
**现象**:
|
||||
- 两笔交易都被标记为"手动平仓"
|
||||
- 但亏损比例极高(-37%和-28.6%),明显是止损触发
|
||||
- 持仓时间只有10分钟,符合止损触发的特征
|
||||
|
||||
**根本原因**:
|
||||
1. **币安保护单触发机制**:
|
||||
- 币安的保护单(STOP/TAKE_PROFIT)触发后,会生成一个 MARKET 订单
|
||||
- 这个 MARKET 订单的 `reduceOnly` 字段可能为 `false`(币安API的bug或特殊情况)
|
||||
- 导致系统误判为手动平仓
|
||||
|
||||
2. **价格匹配逻辑失效**:
|
||||
- 代码使用 `_close_to(ep, sl, max_pct=0.05)` 判断平仓价格是否接近止损价
|
||||
- 但这两笔交易的平仓价格可能离止损价较远(超过5%),导致无法匹配
|
||||
- 如果止损价设置错误或滑点太大,价格匹配会失败
|
||||
|
||||
**代码位置**:`trading_system/position_manager.py:1918-1967`
|
||||
|
||||
---
|
||||
|
||||
### 问题2:止损距离可能太紧
|
||||
|
||||
**分析**:
|
||||
- 交易 #1278:价格只跌了 2.45%,但亏损比例达到 -37%
|
||||
- 交易 #1275:价格只跌了 1.86%,但亏损比例达到 -28.6%
|
||||
|
||||
**可能原因**:
|
||||
1. **ATR 太小**:
|
||||
- 如果 ATR 只有 0.5-1%,即使使用 2.5 倍 ATR,止损距离也只有 1.25-2.5%
|
||||
- 对于波动较大的币种,这个止损距离太紧了
|
||||
|
||||
2. **固定风险百分比未生效**:
|
||||
- 如果固定风险2%生效,每笔亏损应该限制在总资金的2%左右
|
||||
- 但实际亏损比例(相对于保证金)达到 28-37%,说明固定风险可能没有生效
|
||||
|
||||
3. **仓位过大**:
|
||||
- 如果固定风险计算失败,回退到传统方法(基于 MAX_POSITION_PERCENT)
|
||||
- 可能导致仓位过大,止损距离相对较小
|
||||
|
||||
---
|
||||
|
||||
### 问题3:价格匹配容忍度可能不够
|
||||
|
||||
**当前逻辑**:
|
||||
```python
|
||||
def _close_to(a: float, b: float, max_pct: float = 0.05) -> bool:
|
||||
return abs((a - b) / b) <= max_pct # 5%容忍度
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 如果止损价是 0.0900,平仓价是 0.0914,差距是 1.56%
|
||||
- 但如果止损价计算错误(比如是 0.0920),平仓价 0.0914 与止损价 0.0920 的差距是 0.65%
|
||||
- 在极端行情下,滑点可能超过5%,导致价格匹配失败
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案
|
||||
|
||||
### 方案1:改进手动平仓识别逻辑(最高优先级)
|
||||
|
||||
**问题**:当前逻辑依赖 `reduceOnly` 字段,但币安API可能不准确。
|
||||
|
||||
**解决方案**:
|
||||
1. **优先使用价格匹配**:如果平仓价格接近止损/止盈价(5%范围内),直接标记为对应类型
|
||||
2. **检查持仓时间**:如果持仓时间很短(< 30分钟)且亏损,更可能是止损触发
|
||||
3. **检查亏损比例**:如果亏损比例超过止损目标(如 -10%),更可能是止损触发
|
||||
4. **检查订单来源**:如果是系统自动下单,不应该标记为手动平仓
|
||||
|
||||
**代码修改**:
|
||||
```python
|
||||
# 在 sync_positions_with_binance 中
|
||||
# 1. 优先检查价格匹配(已实现,但需要提高优先级)
|
||||
# 2. 如果价格不匹配,但满足以下条件,也标记为止损:
|
||||
# - 持仓时间 < 30分钟
|
||||
# - 亏损比例 > 止损目标
|
||||
# - 是系统自动下单(有 trade_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方案2:放宽止损距离(提高 ATR 倍数)
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER = 2.5`
|
||||
|
||||
**建议调整**:
|
||||
- 提高到 **3.0-3.5**,给波动留出更多空间
|
||||
- 或者根据币种波动率动态调整
|
||||
|
||||
**风险**:
|
||||
- 止损距离放宽后,单笔亏损会增加
|
||||
- 但如果固定风险2%生效,总亏损仍然可控
|
||||
|
||||
---
|
||||
|
||||
### 方案3:增强价格匹配逻辑
|
||||
|
||||
**当前问题**:
|
||||
- 5%的容忍度可能不够(极端滑点)
|
||||
- 只检查平仓价与止损价,没有检查实际亏损比例
|
||||
|
||||
**改进方案**:
|
||||
1. **提高容忍度**:从5%提高到8-10%
|
||||
2. **检查亏损比例**:如果实际亏损比例接近止损目标(如 -8% vs -10%),也标记为止损
|
||||
3. **检查价格方向**:如果平仓价在止损价的方向上(BUY时平仓价 < 止损价),更可能是止损触发
|
||||
|
||||
---
|
||||
|
||||
### 方案4:确保固定风险百分比生效
|
||||
|
||||
**检查点**:
|
||||
1. 确认 `USE_FIXED_RISK_SIZING = true`
|
||||
2. 确认 `FIXED_RISK_PERCENT = 0.02`(2%)
|
||||
3. 检查交易日志,确认是否显示"使用固定风险百分比计算仓位"
|
||||
4. 如果固定风险计算失败,需要修复bug
|
||||
|
||||
---
|
||||
|
||||
## 🎯 立即行动
|
||||
|
||||
### 1. 修复手动平仓识别逻辑(紧急)
|
||||
|
||||
**修改文件**:`trading_system/position_manager.py`
|
||||
|
||||
**修改位置**:`sync_positions_with_binance` 方法中的 `exit_reason` 判断逻辑
|
||||
|
||||
**修改内容**:
|
||||
1. 提高价格匹配的优先级
|
||||
2. 增加持仓时间和亏损比例的检查
|
||||
3. 如果满足止损特征,即使 `reduceOnly=false`,也标记为止损
|
||||
|
||||
---
|
||||
|
||||
### 2. 检查并调整止损距离
|
||||
|
||||
**检查**:
|
||||
1. 查看这两笔交易的 ATR 值
|
||||
2. 计算实际止损距离
|
||||
3. 确认是否使用了 2.5 倍 ATR
|
||||
|
||||
**调整**:
|
||||
- 如果 ATR 太小,考虑提高 `ATR_STOP_LOSS_MULTIPLIER` 到 3.0-3.5
|
||||
- 或者设置最小止损距离(如 3%)
|
||||
|
||||
---
|
||||
|
||||
### 3. 验证固定风险百分比
|
||||
|
||||
**检查**:
|
||||
1. 查看交易日志,确认是否使用固定风险计算
|
||||
2. 如果未使用,检查失败原因
|
||||
3. 修复bug,确保固定风险生效
|
||||
|
||||
---
|
||||
|
||||
## 📋 预期改善
|
||||
|
||||
修复后预期:
|
||||
1. **准确识别平仓原因**:止损触发不再被误判为手动平仓
|
||||
2. **止损距离更合理**:减少被随机波动扫损的概率
|
||||
3. **单笔亏损可控**:固定风险2%生效,每笔亏损限制在总资金的2%左右
|
||||
|
||||
---
|
||||
|
||||
## 🔍 需要检查的数据
|
||||
|
||||
1. **交易日志**:
|
||||
- 这两笔交易的 ATR 值
|
||||
- 止损价格
|
||||
- 是否使用固定风险计算
|
||||
- 币安订单的 `reduceOnly` 字段
|
||||
|
||||
2. **配置快照**:
|
||||
- `ATR_STOP_LOSS_MULTIPLIER` 的实际值
|
||||
- `USE_FIXED_RISK_SIZING` 的实际值
|
||||
- `FIXED_RISK_PERCENT` 的实际值
|
||||
|
||||
3. **数据库记录**:
|
||||
- 这两笔交易的 `stop_loss_price` 字段
|
||||
- `atr` 字段
|
||||
- `exit_reason` 字段
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
这两笔交易亏损比例极高(-37%和-28.6%),说明:
|
||||
1. **止损距离太紧**:价格只跌了1.86-2.45%就触发止损
|
||||
2. **固定风险可能未生效**:如果固定风险2%生效,亏损比例不应该这么高
|
||||
3. **手动平仓误判**:这两笔明显是止损触发,不应该标记为手动平仓
|
||||
|
||||
**必须立即修复**,否则系统会继续产生大额亏损。
|
||||
250
docs/TRADING_LOSS_ANALYSIS_2026-01-23.md
Normal file
250
docs/TRADING_LOSS_ANALYSIS_2026-01-23.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# 交易亏损分析报告 - 2026-01-23
|
||||
|
||||
## 📊 统计数据
|
||||
|
||||
- **总交易数**:107
|
||||
- **胜率**:33.68% ❌(远低于盈亏平衡点50%)
|
||||
- **总盈亏**:-4.97 USDT(亏损率 8.3%,本金60 USDT)
|
||||
- **平均盈亏**:-0.05 USDT
|
||||
- **平均持仓时长**:65分钟
|
||||
- **平仓原因**:止损 23 / 止盈 21 / 移动止损 2 / **同步 49**(45.8%)
|
||||
- **平均盈利/平均亏损**:1.22:1 ❌(远低于期望的3:1)
|
||||
- **总交易量(名义)**:1538.25 USDT
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 核心问题分析
|
||||
|
||||
### 问题1:止损距离过紧,导致大额亏损
|
||||
|
||||
**典型案例**:
|
||||
- **订单 #1246 (MANAUSDT)**:
|
||||
- 入场价:0.1815,出场价:0.1793
|
||||
- 价格跌幅:**仅 1.21%**
|
||||
- 但盈亏比例:**-18.18%**(相对于保证金)
|
||||
- 说明:止损距离太紧,价格稍微波动就触发止损
|
||||
|
||||
- **订单 #1245 (IOUSDT)**:
|
||||
- 入场价:0.1681,出场价:0.1661
|
||||
- 价格跌幅:**仅 1.19%**
|
||||
- 但盈亏比例:**-17.85%**
|
||||
|
||||
**根本原因**:
|
||||
1. **可能使用了旧的ATR止损倍数**(0.5或1.8),而不是新的2.5
|
||||
2. **固定风险百分比可能没有生效**,或者被最大仓位限制覆盖
|
||||
3. **止损距离计算错误**,导致止损价太接近入场价
|
||||
|
||||
---
|
||||
|
||||
### 问题2:固定风险百分比可能没有生效
|
||||
|
||||
**理论计算**:
|
||||
- 本金:60 USDT
|
||||
- 固定风险:2% = 1.2 USDT
|
||||
- 如果止损距离 = 2.5倍ATR,假设ATR = 0.5%,止损距离 = 1.25%
|
||||
- 仓位 = 1.2 / (入场价 × 1.25%) = 1.2 / (入场价 × 0.0125)
|
||||
|
||||
**实际情况**:
|
||||
- 大部分订单保证金:0.9-1.0 USDT(约占总资金的1.67%)
|
||||
- 但亏损比例(相对于保证金):15-31%
|
||||
- **如果固定风险2%生效,每笔亏损应该限制在总资金的2%左右,而不是保证金的15-31%**
|
||||
|
||||
**可能原因**:
|
||||
1. **固定风险计算失败**,回退到传统方法(基于MAX_POSITION_PERCENT)
|
||||
2. **止损距离太紧**,导致即使使用固定风险,实际亏损比例仍然很高
|
||||
3. **最大仓位限制覆盖了固定风险**:如果固定风险计算的保证金超过MAX_POSITION_PERCENT,会被调整为最大仓位,但止损距离不变
|
||||
|
||||
---
|
||||
|
||||
### 问题3:同步平仓过多(49笔,45.8%)
|
||||
|
||||
**问题**:
|
||||
- 49笔订单被标记为"同步平仓",说明系统无法正确识别平仓原因
|
||||
- 可能原因:
|
||||
1. **滑点太大**,超过了5%的容忍度
|
||||
2. **币安订单历史获取不完整**
|
||||
3. **WebSocket断线**,导致没有及时监控
|
||||
|
||||
**影响**:
|
||||
- 无法准确分析哪些是止损、哪些是止盈
|
||||
- 无法优化策略参数
|
||||
|
||||
---
|
||||
|
||||
### 问题4:胜率太低(33.68%)
|
||||
|
||||
**数学分析**:
|
||||
- 当前胜率:33.68%
|
||||
- 当前盈亏比:1.22:1
|
||||
- 盈亏平衡点 = 1 / (1 + 1.22) = **45.05%**
|
||||
- **当前胜率低于盈亏平衡点,必然亏损**
|
||||
|
||||
**原因**:
|
||||
1. **止损距离太紧**,导致频繁被扫损
|
||||
2. **入场信号质量不够**,或者市场环境不适合交易
|
||||
3. **止盈目标可能设置太高**,导致大部分订单无法止盈
|
||||
|
||||
---
|
||||
|
||||
## 🔍 具体案例分析
|
||||
|
||||
### 案例1:订单 #1246 (MANAUSDT)
|
||||
```
|
||||
入场价:0.1815
|
||||
出场价:0.1793
|
||||
价格跌幅:1.21%
|
||||
盈亏:-0.1716 USDT
|
||||
盈亏比例:-18.18%(相对于保证金0.9438 USDT)
|
||||
```
|
||||
|
||||
**分析**:
|
||||
- 如果使用固定风险2%,本金60 USDT,风险金额 = 1.2 USDT
|
||||
- 如果止损距离 = 1.21%,那么仓位 = 1.2 / (0.1815 × 0.0121) = 546.5
|
||||
- 实际数量:78,保证金:0.9438 USDT
|
||||
- **说明:可能使用了传统方法计算仓位,而不是固定风险**
|
||||
|
||||
### 案例2:订单 #1245 (IOUSDT)
|
||||
```
|
||||
入场价:0.1681
|
||||
出场价:0.1661
|
||||
价格跌幅:1.19%
|
||||
盈亏:-0.1726 USDT
|
||||
盈亏比例:-17.85%(相对于保证金0.967 USDT)
|
||||
```
|
||||
|
||||
**分析**:
|
||||
- 价格只跌了1.19%就触发止损
|
||||
- 如果使用2.5倍ATR止损,ATR应该约为 1.19% / 2.5 = **0.48%**
|
||||
- **但实际止损距离只有1.19%,说明可能使用了更小的ATR倍数(如0.5倍或1.8倍)**
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案
|
||||
|
||||
### 方案1:确认并应用新的策略配置(最高优先级)
|
||||
|
||||
**立即行动**:
|
||||
1. **在前端"全局配置"页面,重新应用"波段回归"方案**
|
||||
- 确保 `ATR_STOP_LOSS_MULTIPLIER = 2.5`
|
||||
- 确保 `USE_DYNAMIC_ATR_MULTIPLIER = false`
|
||||
- 确保 `USE_FIXED_RISK_SIZING = true`
|
||||
- 确保 `FIXED_RISK_PERCENT = 0.02`
|
||||
|
||||
2. **重启交易服务**,使新配置生效
|
||||
|
||||
3. **验证配置**:
|
||||
- 查看交易日志,确认是否显示"使用固定风险百分比计算仓位"
|
||||
- 确认止损距离是否基于2.5倍ATR
|
||||
|
||||
---
|
||||
|
||||
### 方案2:优化固定风险计算的逻辑
|
||||
|
||||
**问题**:如果固定风险计算的保证金超过MAX_POSITION_PERCENT,系统会调整为最大仓位,但止损距离不变,导致实际风险超过2%。
|
||||
|
||||
**建议**:
|
||||
- 当固定风险计算的保证金超过最大仓位时,应该**同时调整止损距离**,确保实际风险仍然是2%
|
||||
- 或者:**降低MAX_POSITION_PERCENT**,让固定风险计算有更多空间
|
||||
|
||||
---
|
||||
|
||||
### 方案3:降低交易频率,提高信号质量
|
||||
|
||||
**当前问题**:
|
||||
- 107笔交易,平均持仓65分钟
|
||||
- 胜率只有33.68%
|
||||
|
||||
**建议**:
|
||||
1. **提高信号强度门槛**:`MIN_SIGNAL_STRENGTH` 从8提高到9
|
||||
2. **增加扫描间隔**:`SCAN_INTERVAL` 从1800秒(30分钟)增加到3600秒(1小时)
|
||||
3. **减少TOP_N_SYMBOLS**:从8减少到5,只交易最优质的信号
|
||||
|
||||
---
|
||||
|
||||
### 方案4:优化同步平仓识别逻辑
|
||||
|
||||
**问题**:49笔同步平仓,占比45.8%
|
||||
|
||||
**建议**:
|
||||
1. **增加滑点容忍度**:从5%增加到8%,以应对极端行情
|
||||
2. **增强WebSocket监控**:确保及时接收价格更新
|
||||
3. **优化订单历史获取**:扩大时间范围,确保能获取到所有平仓订单
|
||||
|
||||
---
|
||||
|
||||
## 📋 预期改善
|
||||
|
||||
应用新的策略配置(ATR_STOP_LOSS_MULTIPLIER = 2.5)后,预期:
|
||||
|
||||
1. **止损距离放宽**:
|
||||
- 从1.2%增加到约3%(假设ATR = 1.2%)
|
||||
- 减少被随机波动扫损的概率
|
||||
|
||||
2. **胜率提升**:
|
||||
- 从33.68%提升到**50-60%**以上
|
||||
- 因为止损距离放宽,给波动留出更多空间
|
||||
|
||||
3. **单笔亏损降低**:
|
||||
- 如果固定风险2%生效,每笔亏损限制在总资金的2%左右
|
||||
- 而不是保证金的15-31%
|
||||
|
||||
4. **盈亏比改善**:
|
||||
- 从1.22:1提升到**1.5:1以上**
|
||||
- 配合止盈倍数1.5,更容易达成目标
|
||||
|
||||
---
|
||||
|
||||
## 🎯 立即行动清单
|
||||
|
||||
### 高优先级(立即执行)
|
||||
|
||||
1. ✅ **重新应用策略配置**
|
||||
- 在"全局配置"页面,点击"应用"波段回归方案
|
||||
- 确认 `ATR_STOP_LOSS_MULTIPLIER = 2.5`
|
||||
- 确认 `USE_DYNAMIC_ATR_MULTIPLIER = false`
|
||||
|
||||
2. ✅ **重启交易服务**
|
||||
- 使新配置立即生效
|
||||
|
||||
3. ✅ **验证配置**
|
||||
- 查看交易日志,确认使用固定风险计算
|
||||
- 确认止损距离基于2.5倍ATR
|
||||
|
||||
### 中优先级(本周执行)
|
||||
|
||||
4. ⏳ **提高信号质量门槛**
|
||||
- `MIN_SIGNAL_STRENGTH`: 8 → 9
|
||||
- 减少交易频率,提高胜率
|
||||
|
||||
5. ⏳ **优化固定风险计算逻辑**
|
||||
- 当超过最大仓位时,同时调整止损距离
|
||||
|
||||
---
|
||||
|
||||
## 📝 总结
|
||||
|
||||
**当前主要问题**:
|
||||
1. ❌ 止损距离太紧(可能使用了旧的0.5倍或1.8倍ATR)
|
||||
2. ❌ 固定风险百分比可能没有生效
|
||||
3. ❌ 胜率太低(33.68%)
|
||||
4. ❌ 盈亏比太低(1.22:1)
|
||||
5. ❌ 同步平仓太多(49笔,45.8%)
|
||||
|
||||
**解决方案**:
|
||||
1. ✅ 应用新的策略配置(ATR_STOP_LOSS_MULTIPLIER = 2.5)
|
||||
2. ✅ 确保固定风险百分比生效
|
||||
3. ✅ 提高信号质量门槛
|
||||
4. ✅ 优化同步平仓识别逻辑
|
||||
|
||||
**预期改善**:
|
||||
- 胜率:33.68% → **50-60%**
|
||||
- 盈亏比:1.22:1 → **1.5:1以上**
|
||||
- 单笔亏损:15-31% → **2%左右**(相对于总资金)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
**当前配置可能仍在使用旧参数**(ATR_STOP_LOSS_MULTIPLIER = 0.5或1.8),导致止损距离太紧。
|
||||
|
||||
**必须在前端重新应用策略方案**,确保数据库和Redis中的配置更新为最新值。
|
||||
290
docs/TRADING_PERFORMANCE_ANALYSIS.md
Normal file
290
docs/TRADING_PERFORMANCE_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# 交易表现分析 - 2026-01-23
|
||||
|
||||
## 📊 今日统计
|
||||
|
||||
- **总交易数**:35
|
||||
- **胜率**:44.00%
|
||||
- **总盈亏**:2.03 USDT
|
||||
- **平均盈亏**:0.08 USDT
|
||||
- **平均持仓时长**:35分钟
|
||||
- **平仓原因**:止盈 1 / 手动 9 / 同步 15
|
||||
- **平均盈利/平均亏损**:1.35 : 1(期望 3:1)
|
||||
- **总交易量(名义)**:3512.05 USDT
|
||||
|
||||
## ⚠️ 严重问题分析
|
||||
|
||||
### 问题1:盈亏比严重失衡(1.35:1 vs 期望3:1)
|
||||
|
||||
**现状**:
|
||||
- 平均盈利/平均亏损 = 1.35:1
|
||||
- 胜率 = 44%
|
||||
- 期望盈亏比 = 3:1
|
||||
|
||||
**数学分析**:
|
||||
- 盈亏平衡点 = 1 / (1 + 盈亏比) = 1 / (1 + 1.35) = **42.55%**
|
||||
- 当前胜率 44% 仅略高于盈亏平衡点,所以总盈亏只有 2.03 USDT(几乎不盈利)
|
||||
- 如果盈亏比达到 3:1,盈亏平衡点 = 1 / (1 + 3) = **25%**
|
||||
- 在胜率 44% 的情况下,盈亏比 3:1 的期望收益 = (0.44 × 3) - (0.56 × 1) = **0.76**(每笔亏损赚0.76倍)
|
||||
|
||||
**结论**:盈亏比 1.35:1 太低了,必须提升到至少 2:1 才能稳定盈利。
|
||||
|
||||
---
|
||||
|
||||
### 问题2:大额亏损(30-50%)说明止损失效
|
||||
|
||||
**具体案例**:
|
||||
- #1138 DUSKUSDT: **-31.28%**(同步平仓)
|
||||
- #1135 RIVERUSDT: **-49.06%**(同步平仓)
|
||||
- #1133 BDXNUSDT: **-31.69%**(手动平仓)
|
||||
|
||||
**问题分析**:
|
||||
1. **固定风险百分比应该限制亏损为2%**,但实际亏损达到30-50%
|
||||
2. **说明止损没有及时执行**,或者止损价格计算错误
|
||||
3. **"同步平仓"** 可能是在止损触发后,系统同步币安状态时发现已经亏损很大
|
||||
|
||||
**可能原因**:
|
||||
1. 止损单没有正确挂到交易所
|
||||
2. 止损价格计算错误(可能基于价格百分比而不是保证金百分比)
|
||||
3. WebSocket 监控断线,没有及时触发止损
|
||||
4. 固定风险百分比计算时,止损距离估算错误
|
||||
|
||||
---
|
||||
|
||||
### 问题3:止盈太少(35笔只有1笔止盈)
|
||||
|
||||
**现状**:
|
||||
- 35笔交易,只有1笔止盈(2.86%)
|
||||
- 15笔同步平仓,9笔手动平仓
|
||||
|
||||
**问题分析**:
|
||||
1. **止盈目标可能设置太高**:`ATR_TAKE_PROFIT_MULTIPLIER = 1.5` 可能仍然太高
|
||||
2. **大部分订单被提前平仓**:15笔同步平仓可能是止损触发,9笔手动平仓可能是用户干预
|
||||
3. **止盈单可能没有正确挂到交易所**
|
||||
|
||||
---
|
||||
|
||||
### 问题4:固定风险百分比可能没有生效
|
||||
|
||||
**理论**:
|
||||
- 固定风险百分比 = 2%
|
||||
- 如果止损距离 = 5%,那么仓位 = (总资金 × 2%) / 5% = 总资金的 40%
|
||||
- 如果止损触发,亏损 = 总资金的 2%(符合预期)
|
||||
|
||||
**实际情况**:
|
||||
- 亏损达到 30-50%,说明:
|
||||
1. 固定风险百分比没有生效
|
||||
2. 或者止损距离计算错误(止损距离太小,导致仓位过大)
|
||||
3. 或者止损没有及时触发
|
||||
|
||||
---
|
||||
|
||||
## 🔍 根本原因分析
|
||||
|
||||
### 1. 止损执行问题
|
||||
|
||||
**可能原因**:
|
||||
- 止损单没有正确挂到交易所
|
||||
- WebSocket 监控断线,没有及时触发止损
|
||||
- 止损价格计算错误
|
||||
|
||||
**验证方法**:
|
||||
- 查看日志,确认止损单是否成功挂到交易所
|
||||
- 检查 WebSocket 监控是否正常运行
|
||||
- 检查止损价格计算逻辑
|
||||
|
||||
### 2. 固定风险百分比可能没有生效
|
||||
|
||||
**验证方法**:
|
||||
- 检查 `USE_FIXED_RISK_SIZING` 是否启用
|
||||
- 检查开仓日志,确认是否使用了固定风险计算
|
||||
- 检查止损距离估算是否准确
|
||||
|
||||
### 3. 止盈目标设置问题
|
||||
|
||||
**当前配置**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER = 1.5`
|
||||
- `TAKE_PROFIT_PERCENT = 25%`(相对于保证金)
|
||||
|
||||
**问题**:
|
||||
- 如果 ATR 很大,1.5倍 ATR 的止盈目标可能很难达到
|
||||
- 25% 的止盈目标对于小币种可能太高
|
||||
|
||||
---
|
||||
|
||||
## 💡 解决方案
|
||||
|
||||
### 方案1:确保止损正确执行(最高优先级)
|
||||
|
||||
1. **检查止损单是否挂到交易所**
|
||||
- 在开仓后立即检查止损单状态
|
||||
- 如果挂单失败,重试或报警
|
||||
|
||||
2. **增强 WebSocket 监控可靠性**
|
||||
- 增加心跳检测
|
||||
- 增加断线重连机制
|
||||
- 增加兜底巡检(每1-2分钟检查一次)
|
||||
|
||||
3. **修复止损价格计算**
|
||||
- 确保止损基于保证金百分比,而不是价格百分比
|
||||
- 确保止损距离估算准确
|
||||
|
||||
### 方案2:验证并修复固定风险百分比
|
||||
|
||||
1. **检查配置**
|
||||
- 确认 `USE_FIXED_RISK_SIZING = True`
|
||||
- 确认 `FIXED_RISK_PERCENT = 0.02`(2%)
|
||||
|
||||
2. **检查计算逻辑**
|
||||
- 确认止损距离估算准确
|
||||
- 确认仓位计算使用了固定风险公式
|
||||
|
||||
3. **增加日志**
|
||||
- 记录固定风险计算的详细过程
|
||||
- 记录实际止损距离和仓位大小
|
||||
|
||||
### 方案3:调整止盈目标
|
||||
|
||||
1. **降低止盈目标**
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER` 从 1.5 降到 1.2
|
||||
- `TAKE_PROFIT_PERCENT` 从 25% 降到 20%
|
||||
|
||||
2. **确保止盈单正确挂到交易所**
|
||||
- 在开仓后立即挂止盈单
|
||||
- 检查止盈单状态
|
||||
|
||||
---
|
||||
|
||||
## 📋 立即行动清单
|
||||
|
||||
### 高优先级(立即执行)
|
||||
|
||||
1. ✅ **检查止损单挂单状态**
|
||||
- 在开仓后立即检查止损单是否成功挂到交易所
|
||||
- 如果失败,重试或报警
|
||||
|
||||
2. ✅ **验证固定风险百分比是否生效**
|
||||
- 检查开仓日志,确认是否使用了固定风险计算
|
||||
- 如果未生效,修复计算逻辑
|
||||
|
||||
3. ✅ **增强止损执行可靠性**
|
||||
- 增加 WebSocket 心跳检测
|
||||
- 增加兜底巡检(每1-2分钟检查一次)
|
||||
|
||||
### 中优先级(本周执行)
|
||||
|
||||
4. ⏳ **调整止盈目标**
|
||||
- 降低 `ATR_TAKE_PROFIT_MULTIPLIER` 到 1.2
|
||||
- 降低 `TAKE_PROFIT_PERCENT` 到 20%
|
||||
|
||||
5. ⏳ **增加诊断日志**
|
||||
- 记录止损单挂单状态
|
||||
- 记录固定风险计算过程
|
||||
- 记录实际止损距离
|
||||
|
||||
---
|
||||
|
||||
## 🎯 目标指标
|
||||
|
||||
### 当前表现
|
||||
- 盈亏比:1.35:1 ❌
|
||||
- 胜率:44% ✅
|
||||
- 总盈亏:2.03 USDT(几乎不盈利)❌
|
||||
|
||||
### 目标表现
|
||||
- 盈亏比:≥ 2.0:1(理想 3:1)✅
|
||||
- 胜率:≥ 40% ✅
|
||||
- 单笔最大亏损:≤ 5%(固定风险2% + 滑点)✅
|
||||
- 止盈率:≥ 30%(35笔中至少10笔止盈)✅
|
||||
|
||||
---
|
||||
|
||||
## 📝 下一步
|
||||
|
||||
1. ✅ **已修复**:固定风险百分比计算逻辑(已添加到代码中)
|
||||
2. ✅ **已增强**:止损单挂单状态日志(成功/失败都会记录)
|
||||
3. ⏳ **待验证**:重新运行后观察是否还有30%以上的亏损
|
||||
4. ⏳ **待调整**:降低止盈目标,提高止盈率
|
||||
|
||||
---
|
||||
|
||||
## 🔧 已实施的修复
|
||||
|
||||
### 1. ✅ 修复固定风险百分比计算逻辑
|
||||
|
||||
**问题**:固定风险百分比计算逻辑缺失,导致系统没有使用固定风险公式计算仓位。
|
||||
|
||||
**修复**:
|
||||
- 在 `risk_manager.py` 的 `calculate_position_size()` 中添加了完整的固定风险百分比计算逻辑
|
||||
- 如果 `USE_FIXED_RISK_SIZING = True` 且提供了 `entry_price` 和 `side`,会使用固定风险公式
|
||||
- 公式:`quantity = (总资金 × 2%) / (入场价 - 止损价)`
|
||||
|
||||
### 2. ✅ 增强止损单挂单状态日志
|
||||
|
||||
**问题**:无法知道止损单是否成功挂到交易所。
|
||||
|
||||
**修复**:
|
||||
- 在 `position_manager.py` 的 `_ensure_exchange_sltp_orders()` 中增加了日志
|
||||
- 止损单和止盈单挂单成功/失败都会记录日志
|
||||
- 如果挂单失败,会明确提示将依赖WebSocket监控
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键发现
|
||||
|
||||
### 为什么会出现30-50%的亏损?
|
||||
|
||||
**根本原因**:
|
||||
1. **固定风险百分比没有生效**(已修复)
|
||||
- 如果固定风险百分比生效,每笔亏损应该限制在2%
|
||||
- 但实际亏损达到30-50%,说明固定风险百分比没有生效
|
||||
|
||||
2. **止损单可能没有正确挂到交易所**
|
||||
- 如果止损单挂单失败,系统只能依赖WebSocket监控
|
||||
- 如果WebSocket断线,止损可能无法及时执行
|
||||
|
||||
3. **止损价格计算可能有问题**
|
||||
- 止损可能基于价格百分比而不是保证金百分比
|
||||
- 或者止损距离估算错误
|
||||
|
||||
### 为什么盈亏比只有1.35:1?
|
||||
|
||||
**原因**:
|
||||
1. **止盈目标设置太高**:`ATR_TAKE_PROFIT_MULTIPLIER = 1.5` 可能仍然太高
|
||||
2. **止盈单可能没有正确挂到交易所**:只有1笔止盈,说明大部分订单没有达到止盈目标
|
||||
3. **大部分订单被提前平仓**:15笔同步平仓(可能是止损),9笔手动平仓(用户干预)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期改善
|
||||
|
||||
修复后,预期:
|
||||
- ✅ **单笔最大亏损**:从30-50%降低到≤5%(固定风险2% + 滑点)
|
||||
- ✅ **盈亏比**:从1.35:1提升到≥2.0:1(通过降低止盈目标)
|
||||
- ✅ **止盈率**:从2.86%提升到≥30%(通过降低止盈目标)
|
||||
|
||||
---
|
||||
|
||||
## 📋 建议的配置调整
|
||||
|
||||
### 立即调整(在GlobalConfig中)
|
||||
|
||||
1. **降低止盈目标**:
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 1.5 → **1.2**
|
||||
- `TAKE_PROFIT_PERCENT`: 25% → **20%**
|
||||
|
||||
2. **确保固定风险百分比启用**:
|
||||
- `USE_FIXED_RISK_SIZING`: **True**
|
||||
- `FIXED_RISK_PERCENT`: **0.02** (2%)
|
||||
|
||||
3. **确保止损单挂单**:
|
||||
- `EXCHANGE_SLTP_ENABLED`: **True**(默认已启用)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证方法
|
||||
|
||||
修复后,请观察:
|
||||
1. **开仓日志**:是否显示"使用固定风险百分比计算仓位"
|
||||
2. **止损单日志**:是否显示"止损单已成功挂到交易所"
|
||||
3. **实际亏损**:是否还有30%以上的亏损
|
||||
4. **止盈率**:是否提升到30%以上
|
||||
103
docs/WEBSOCKET_HOLD_TIME_MINUTES_FIX.md
Normal file
103
docs/WEBSOCKET_HOLD_TIME_MINUTES_FIX.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# WebSocket监控 hold_time_minutes 变量未初始化修复
|
||||
|
||||
## 🔍 问题描述
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
trading_system.position_manager - WARNING - FLUIDUSDT WebSocket监控出错 (重试 1/5):
|
||||
cannot access local variable 'hold_time_minutes' where it is not associated with a value
|
||||
```
|
||||
|
||||
**问题根源**:
|
||||
在 `_check_single_position()` 方法中,`hold_time_minutes` 变量只在**止损分支**(第2725-2734行)中被初始化,但在**止盈分支**(第2889行)中也被使用。当代码走止盈路径时,变量未被初始化,导致 `UnboundLocalError`。
|
||||
|
||||
## ✅ 修复方案
|
||||
|
||||
### 修复位置
|
||||
`trading_system/position_manager.py` 的 `_check_single_position()` 方法
|
||||
|
||||
### 修复内容
|
||||
|
||||
在止盈分支(第2877行之后)中,添加 `hold_time_minutes` 的初始化逻辑:
|
||||
|
||||
**修复前**:
|
||||
```python
|
||||
# 直接比较当前盈亏百分比与止盈目标(基于保证金)
|
||||
if pnl_percent_margin >= take_profit_pct_margin:
|
||||
should_close = True
|
||||
exit_reason = 'take_profit'
|
||||
|
||||
# 详细诊断日志:记录平仓时的所有关键信息
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"{symbol} [实时监控-平仓诊断日志] ===== 触发止盈平仓 =====")
|
||||
# ...
|
||||
logger.info(f" 持仓时间: {hold_time_minutes:.1f} 分钟") # ❌ 变量未初始化
|
||||
# ...
|
||||
```
|
||||
|
||||
**修复后**:
|
||||
```python
|
||||
# 直接比较当前盈亏百分比与止盈目标(基于保证金)
|
||||
if pnl_percent_margin >= take_profit_pct_margin:
|
||||
should_close = True
|
||||
exit_reason = 'take_profit'
|
||||
|
||||
# 计算持仓时间(用于日志)
|
||||
entry_time = position_info.get('entryTime')
|
||||
hold_time_minutes = 0
|
||||
if entry_time:
|
||||
try:
|
||||
if isinstance(entry_time, datetime):
|
||||
hold_time_sec = int((get_beijing_time() - entry_time).total_seconds())
|
||||
else:
|
||||
hold_time_sec = int(time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0))
|
||||
hold_time_minutes = hold_time_sec / 60.0
|
||||
except Exception:
|
||||
hold_time_minutes = 0
|
||||
|
||||
# 详细诊断日志:记录平仓时的所有关键信息
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"{symbol} [实时监控-平仓诊断日志] ===== 触发止盈平仓 =====")
|
||||
# ...
|
||||
logger.info(f" 持仓时间: {hold_time_minutes:.1f} 分钟") # ✅ 变量已初始化
|
||||
# ...
|
||||
```
|
||||
|
||||
## 📊 修复效果
|
||||
|
||||
### 修复前
|
||||
- ❌ 止盈触发时 → 尝试使用 `hold_time_minutes` → `UnboundLocalError` → WebSocket监控出错 → 重试
|
||||
|
||||
### 修复后
|
||||
- ✅ 止盈触发时 → `hold_time_minutes` 已初始化 → 正常记录日志 → WebSocket监控正常
|
||||
|
||||
## 🔄 相关代码路径
|
||||
|
||||
1. **止损分支**(第2725-2734行):已正确初始化 `hold_time_minutes`
|
||||
2. **止盈分支**(第2877-2907行):**已修复**,现在也会初始化 `hold_time_minutes`
|
||||
3. **第二目标止盈分支**(第2838-2846行):未使用 `hold_time_minutes`,无需修复
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **变量作用域**:`hold_time_minutes` 是局部变量,只在各自的代码分支内有效。
|
||||
2. **初始化逻辑**:与止损分支的初始化逻辑保持一致,确保计算方式统一。
|
||||
3. **异常处理**:如果计算持仓时间失败,默认设置为 0,避免程序崩溃。
|
||||
|
||||
## 🚀 部署建议
|
||||
|
||||
1. **重启交易进程**:修复后需要重启所有 `trading_system` 进程才能生效。
|
||||
```bash
|
||||
supervisorctl restart auto_sys_acc1 auto_sys_acc2 auto_sys_acc3 ...
|
||||
```
|
||||
|
||||
2. **验证修复**:查看日志,确认 WebSocket 监控不再出现 `hold_time_minutes` 相关错误。
|
||||
|
||||
## 📝 相关文件
|
||||
|
||||
- `trading_system/position_manager.py`:主要修复文件
|
||||
- `_check_single_position()` 方法(第2580-2960行)
|
||||
- `_monitor_position_price()` 方法(第2499-2578行)
|
||||
|
||||
## ✅ 修复完成时间
|
||||
|
||||
2026-01-25
|
||||
100
docs/apply_altcoin_strategy.sh
Executable file
100
docs/apply_altcoin_strategy.sh
Executable 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
144
docs/check_accounts_no_trades.sh
Executable 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 的配置项"
|
||||
135
docs/亏损分析_ZENUSDT.md
Normal file
135
docs/亏损分析_ZENUSDT.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# 亏损分析 - ZENUSDT
|
||||
|
||||
## 📊 当前情况
|
||||
|
||||
### 交易信息
|
||||
|
||||
```
|
||||
ZENUSDT [实时监控] 诊断: 亏损-10.19% of margin |
|
||||
当前价: 9.6910 |
|
||||
入场价: 9.8160 |
|
||||
止损价: 9.6197 (目标: -16.00% of margin) |
|
||||
方向: BUY |
|
||||
是否触发: False |
|
||||
监控状态: 运行中
|
||||
```
|
||||
|
||||
### 计算验证
|
||||
|
||||
**价格变化**:
|
||||
- 入场价:9.8160
|
||||
- 当前价:9.6910
|
||||
- 价格跌幅 = (9.8160 - 9.6910) / 9.8160 = **1.27%**
|
||||
|
||||
**保证金亏损**:
|
||||
- 假设杠杆:8倍(山寨币策略默认)
|
||||
- 保证金亏损 = 1.27% × 8 = **10.16%**(接近-10.19%)
|
||||
|
||||
**止损价计算**:
|
||||
- 止损价:9.6197
|
||||
- 止损距离 = 9.8160 - 9.6197 = 0.1963
|
||||
- 止损百分比(价格)= 0.1963 / 9.8160 = **2.00%**
|
||||
- 止损目标(保证金)= 2.00% × 8 = **16.00%**
|
||||
|
||||
---
|
||||
|
||||
## ✅ 是否正常?
|
||||
|
||||
### 结论:**正常** ✅
|
||||
|
||||
**理由**:
|
||||
|
||||
1. **亏损-10.19%是正常的市场波动**
|
||||
- 价格只下跌了1.27%,这是正常的市场波动
|
||||
- 山寨币波动大,1-2%的价格波动很常见
|
||||
|
||||
2. **止损目标-16%符合策略配置**
|
||||
- 山寨币策略配置:`STOP_LOSS_PERCENT = 15%`(固定止损)
|
||||
- 实际止损目标-16%可能是因为:
|
||||
- ATR止损计算的结果(`ATR_STOP_LOSS_MULTIPLIER = 2.0`)
|
||||
- 止损价选择逻辑选择了ATR止损而不是固定止损
|
||||
- 16%接近15%,在合理范围内
|
||||
|
||||
3. **止损未触发是正常的**
|
||||
- 当前亏损-10.19% < 止损目标-16%
|
||||
- 止损机制正常工作,会在亏损达到-16%时触发
|
||||
|
||||
4. **符合山寨币策略设计**
|
||||
- 山寨币策略设计:宽止损(15%)+ 高盈亏比(4:1)
|
||||
- 允许较大的价格波动,避免被正常波动扫损
|
||||
|
||||
---
|
||||
|
||||
## 📈 止损价分析
|
||||
|
||||
### 止损价计算方式
|
||||
|
||||
根据山寨币策略配置:
|
||||
- `STOP_LOSS_PERCENT = 15%`(固定止损15%)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER = 2.0`(ATR止损2.0倍)
|
||||
|
||||
**实际止损价**:9.6197
|
||||
- 止损距离 = 9.8160 - 9.6197 = 0.1963
|
||||
- 止损百分比 = 0.1963 / 9.8160 = 2.00%
|
||||
- 保证金亏损 = 2.00% × 8 = 16.00%
|
||||
|
||||
**分析**:
|
||||
- 如果使用固定止损15%,止损价应该是:9.8160 × (1 - 0.15/8) = 9.8160 × 0.98125 = 9.6315
|
||||
- 实际止损价9.6197 < 9.6315,说明可能使用了ATR止损
|
||||
- ATR止损可能计算出了更远的止损价(更宽松)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 需要注意的情况
|
||||
|
||||
### 1. 如果亏损继续扩大
|
||||
|
||||
**如果价格继续下跌**:
|
||||
- 当前亏损:-10.19%
|
||||
- 止损目标:-16.00%
|
||||
- **还有5.81%的缓冲空间**
|
||||
|
||||
**建议**:
|
||||
- 如果亏损达到-15%,接近止损目标,可以关注
|
||||
- 如果亏损达到-16%,止损会自动触发
|
||||
|
||||
### 2. 如果止损未及时触发
|
||||
|
||||
**如果价格快速下跌,止损未及时触发**:
|
||||
- 检查WebSocket监控是否正常工作
|
||||
- 检查止损单是否正常挂到交易所
|
||||
- 如果止损单失效,系统会通过WebSocket监控触发平仓
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
### ✅ 当前情况:**正常**
|
||||
|
||||
1. **亏损-10.19%是正常的市场波动**
|
||||
- 价格只下跌了1.27%,这是正常的
|
||||
- 山寨币波动大,1-2%的价格波动很常见
|
||||
|
||||
2. **止损目标-16%符合策略配置**
|
||||
- 接近15%的固定止损设置
|
||||
- 可能是ATR止损计算的结果
|
||||
|
||||
3. **止损机制正常工作**
|
||||
- 当前亏损-10.19% < 止损目标-16%
|
||||
- 止损会在亏损达到-16%时自动触发
|
||||
|
||||
4. **符合山寨币策略设计**
|
||||
- 宽止损(15%)允许较大的价格波动
|
||||
- 避免被正常波动扫损
|
||||
|
||||
### 💡 建议
|
||||
|
||||
1. **继续观察**:如果亏损继续扩大,接近-15%时可以关注
|
||||
2. **信任策略**:止损机制正常工作,会在达到-16%时自动触发
|
||||
3. **不要手动干预**:除非系统故障,否则不要手动平仓
|
||||
|
||||
---
|
||||
|
||||
## ✅ 完成时间
|
||||
|
||||
2026-01-25
|
||||
154
docs/交易对筛选优化完成总结_2026-01-27.md
Normal file
154
docs/交易对筛选优化完成总结_2026-01-27.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# 交易对筛选优化完成总结(2026-01-27)
|
||||
|
||||
## 🎯 优化目标
|
||||
|
||||
**按真实的信号强度(signal_strength)排序,优先选择高质量信号(8-10分),而不是简单的signalScore(5-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
|
||||
- 但需要确保有足够的交易对
|
||||
175
docs/交易对筛选优化方案_2026-01-27.md
Normal file
175
docs/交易对筛选优化方案_2026-01-27.md
Normal 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` 排序(立即实施)
|
||||
- **中**:在扫描阶段过滤低质量信号(可选)
|
||||
293
docs/交易数据分析_2026-01-25.md
Normal file
293
docs/交易数据分析_2026-01-25.md
Normal 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
|
||||
352
docs/交易数据分析_2026-01-25_完整版.md
Normal file
352
docs/交易数据分析_2026-01-25_完整版.md
Normal 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
|
||||
259
docs/交易数据分析_2026-01-27_ATR使用合理性分析.md
Normal file
259
docs/交易数据分析_2026-01-27_ATR使用合理性分析.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# 交易数据分析 - ATR使用合理性分析(2026-01-27)
|
||||
|
||||
## 📊 交易数据统计
|
||||
|
||||
### 基本统计(基于交易记录_2026-01-27T02-26-05.json)
|
||||
|
||||
**总交易数**:20单
|
||||
- **持仓中**:6单(30%)
|
||||
- **已平仓**:14单(70%)
|
||||
|
||||
**已平仓交易分析**:
|
||||
- **止盈单**:2单(14.3%)
|
||||
- CHZUSDT BUY: +24.51%
|
||||
- ZROUSDT SELL: +30.18%
|
||||
|
||||
- **止损单**:10单(71.4%)
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
### 问题3:ATR使用合理性
|
||||
|
||||
**当前配置**:
|
||||
- `USE_ATR_STOP_LOSS`: True
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: 2.0
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0
|
||||
- `STOP_LOSS_PERCENT`: 0.12(12%)
|
||||
- `TAKE_PROFIT_PERCENT`: 0.20(20%)
|
||||
|
||||
**问题分析**:
|
||||
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.12(12%)
|
||||
- `TAKE_PROFIT_PERCENT`: 0.20(20%)
|
||||
|
||||
### 建议配置(优化)
|
||||
- `ATR_STOP_LOSS_MULTIPLIER`: **1.5**(收紧止损)
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: **2.0**(降低止盈目标)
|
||||
- `STOP_LOSS_PERCENT`: **0.12**(12%,保持)
|
||||
- `TAKE_PROFIT_PERCENT`: **0.20**(20%,保持)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 预期效果
|
||||
|
||||
### 优化后预期
|
||||
|
||||
**止损单比例**:
|
||||
- 当前:71.4%
|
||||
- 预期:50% - 60%
|
||||
|
||||
**止盈单比例**:
|
||||
- 当前:14.3%
|
||||
- 预期:30% - 40%
|
||||
|
||||
**胜率**:
|
||||
- 当前:35.7%
|
||||
- 预期:45% - 55%
|
||||
|
||||
**盈亏比**:
|
||||
- 当前:需要计算
|
||||
- 预期:1.5:1 - 2.0:1
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
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%
|
||||
275
docs/交易数据分析_2026-01-27_优化效果评估.md
Normal file
275
docs/交易数据分析_2026-01-27_优化效果评估.md
Normal 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.10(10%)
|
||||
|
||||
**建议配置**:
|
||||
- `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.05(5%)
|
||||
- `TRAILING_STOP_PROTECT`: 0.025(2.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.10(10%)
|
||||
3. ✅ **动态追踪止损优化**:0.20 → 0.05(5%激活),0.10 → 0.025(2.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
|
||||
212
docs/交易数据分析_2026-01-27_止损问题分析.md
Normal file
212
docs/交易数据分析_2026-01-27_止损问题分析.md
Normal 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(严重失衡)
|
||||
|
||||
**问题分析**:
|
||||
- 平均盈利远小于平均亏损
|
||||
- 说明盈利单盈利幅度小,亏损单亏损幅度大
|
||||
- 可能是止损设置过宽,止盈设置过紧
|
||||
|
||||
---
|
||||
|
||||
## 🔍 根本原因分析
|
||||
|
||||
### 问题1:SELL单止损价格计算错误
|
||||
|
||||
**可能原因**:
|
||||
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.30(30%)
|
||||
- `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.30(30%,过高)
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 4.0(过高)
|
||||
- `RISK_REWARD_RATIO`: 4.0(过高)
|
||||
- `STOP_LOSS_PERCENT`: 0.15(15%,可能过宽)
|
||||
|
||||
### 建议配置(优化)
|
||||
- `TAKE_PROFIT_PERCENT`: 0.20(20%,更容易触发)
|
||||
- `ATR_TAKE_PROFIT_MULTIPLIER`: 3.0(降低,更容易触发)
|
||||
- `RISK_REWARD_RATIO`: 3.0(降低,更容易触发)
|
||||
- `STOP_LOSS_PERCENT`: 0.12(12%,收紧止损)
|
||||
- `USE_TRAILING_STOP`: True(启用移动止损,保护利润)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 优先级
|
||||
|
||||
1. **紧急**:修复SELL单止损价格计算
|
||||
2. **紧急**:增强止损单挂单失败后的处理
|
||||
3. **重要**:调整止盈策略(降低止盈目标)
|
||||
4. **重要**:检查配置值格式
|
||||
|
||||
---
|
||||
|
||||
## 📊 预期效果
|
||||
|
||||
修复后预期:
|
||||
- ✅ SELL单止损价格正确,不再出现巨额亏损
|
||||
- ✅ 止损单挂单失败后及时平仓
|
||||
- ✅ 止盈单比例提升(从6.67%提升到20%+)
|
||||
- ✅ 盈亏比改善(从0.39:1提升到1.5:1+)
|
||||
186
docs/交易策略问题诊断_2026-01-28.md
Normal file
186
docs/交易策略问题诊断_2026-01-28.md
Normal 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=7,AUTO_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%以内)
|
||||
- 交易频率是否降低(避免过度交易)
|
||||
207
docs/修复日志格式化错误和订单状态延迟分析.md
Normal file
207
docs/修复日志格式化错误和订单状态延迟分析.md
Normal 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
|
||||
53
docs/全局配置与数据库同步.md
Normal file
53
docs/全局配置与数据库同步.md
Normal 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
Loading…
Reference in New Issue
Block a user