Compare commits
No commits in common. "master" and "all_in_one" have entirely different histories.
master
...
all_in_one
|
|
@ -1,8 +0,0 @@
|
|||
node_modules
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
venv
|
||||
.git
|
||||
logs
|
||||
*.log
|
||||
18
.cursorrules
18
.cursorrules
|
|
@ -1,18 +0,0 @@
|
|||
# 交易系统开发最高准则
|
||||
|
||||
## 1. 风险控制(核心)
|
||||
- **止损高于一切**:严禁在任何平仓逻辑前添加时间限制。任何情况下,只要触发止损条件,必须立即执行平仓。
|
||||
- **严禁恢复时间锁**:绝对不允许重新启用 `MIN_HOLD_TIME_SEC` 来限制止损或止盈。
|
||||
- **异常处理**:所有涉及 `binance.create_order` 的操作必须包含 try-catch 逻辑,并有重试机制或错误预警。
|
||||
|
||||
## 2. 币安合约逻辑
|
||||
- **挂单确认**:在开仓订单成交后,必须立即调用 `_ensure_exchange_sltp_orders` 在交易所侧挂好止损单。
|
||||
- **价格类型**:区分 Mark Price(标记价格)和 Last Price(最新价格),止损逻辑应优先参考标记价格以防插针。
|
||||
|
||||
## 3. 代码风格
|
||||
- 使用 Python 异步编程 (asyncio)。
|
||||
- 所有的交易日志必须记录 Symbol、价格、原因和时间戳。
|
||||
|
||||
## 4. 不要在本地运行交易系统,后台服务,数据库连接
|
||||
- 交易系统必须在服务器上运行,严禁在本地环境测试。
|
||||
- 所有配置(如 API 密钥、数据库连接等)必须在服务器上配置,本地环境不得包含任何敏感信息。
|
||||
|
|
@ -138,45 +138,6 @@
|
|||
|
||||
---
|
||||
|
||||
## 面向“更多用户”的演进战略准备(从现在就要做对的几件事)
|
||||
|
||||
你问“每账号一进程是不是终极方案”。我的建议是把它当作**长期默认架构**,并提前把“可演进”埋点做对,这样未来扩容不会推倒重来。
|
||||
|
||||
### 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。
|
||||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
基于币安API的Python自动交易系统,实现自动发现涨跌幅最大的货币对并执行交易策略。
|
||||
|
||||
**文档导航**:详细文档列表与优先阅读顺序见 [INDEX.md](INDEX.md)。历史/一次性分析已移至 [archive/](archive/)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
|
|
@ -93,11 +93,11 @@
|
|||
- `MIN_STOP_LOSS_PRICE_PCT`: 0.01 (1%)
|
||||
- `MIN_TAKE_PROFIT_PRICE_PCT`: 0.015 (1.5%)
|
||||
|
||||
### 激进策略(不推荐过小最小距离)
|
||||
### 激进策略
|
||||
- `STOP_LOSS_PERCENT`: 0.02 (2% of margin)
|
||||
- `TAKE_PROFIT_PERCENT`: 0.03 (3% of margin)
|
||||
- `MIN_STOP_LOSS_PRICE_PCT`: **≥ 0.015 (1.5%)**,不建议 0.5%,易被波动扫损
|
||||
- `MIN_TAKE_PROFIT_PRICE_PCT`: **≥ 0.02 (2%)**
|
||||
- `MIN_STOP_LOSS_PRICE_PCT`: 0.005 (0.5%)
|
||||
- `MIN_TAKE_PROFIT_PRICE_PCT`: 0.01 (1%)
|
||||
|
||||
## 优势
|
||||
|
||||
|
|
@ -111,7 +111,6 @@
|
|||
1. **杠杆影响**:杠杆越高,基于保证金的止损对应的价格变动越小
|
||||
2. **市场波动**:在波动大的市场,可以适当提高最小价格变动百分比
|
||||
3. **技术止损**:系统仍会考虑技术分析(支撑/阻力、布林带),如果技术止损更紧,会使用技术止损
|
||||
4. **⚠️ 最小距离不宜过小**:`MIN_STOP_LOSS_PRICE_PCT` / `MIN_TAKE_PROFIT_PRICE_PCT` 是「离入场价的最小价格距离」。设为 0.5%、0.6% 会允许极紧的止损/止盈,容易被噪音扫损或过早止盈,建议**至少 2%~2.5%**(0.02~0.025)。代码默认:止损 2.5%、止盈 2%。
|
||||
|
||||
## 如何配置
|
||||
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
# Backend 启动问题排查指南
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 语法错误
|
||||
**错误信息**: `SyntaxError: expected 'except' or 'finally' block`
|
||||
|
||||
**解决方法**:
|
||||
- 检查代码中的 `try:` 块是否都有对应的 `except` 或 `finally`
|
||||
- 检查缩进是否正确
|
||||
- 运行 `python3 -m py_compile api/routes/trades.py` 检查语法
|
||||
|
||||
### 2. 缺少依赖模块
|
||||
**错误信息**: `ModuleNotFoundError: No module named 'xxx'`
|
||||
|
||||
**解决方法**:
|
||||
```bash
|
||||
# 激活虚拟环境
|
||||
source ../.venv/bin/activate # 或 source .venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 或者单独安装缺失的模块
|
||||
pip install python-jose[cryptography]
|
||||
```
|
||||
|
||||
### 3. 导入错误
|
||||
**错误信息**: `ModuleNotFoundError: No module named 'api'`
|
||||
|
||||
**解决方法**:
|
||||
- 确保在 `backend` 目录下运行
|
||||
- 检查 `PYTHONPATH` 是否正确设置
|
||||
- 使用 `cd backend && python3 -m uvicorn api.main:app` 启动
|
||||
|
||||
## 排查步骤
|
||||
|
||||
### 步骤 1: 检查依赖
|
||||
```bash
|
||||
cd backend
|
||||
bash check_dependencies.sh
|
||||
```
|
||||
|
||||
### 步骤 2: 检查语法
|
||||
```bash
|
||||
cd backend
|
||||
source ../.venv/bin/activate
|
||||
python3 -m py_compile api/main.py
|
||||
python3 -m py_compile api/routes/*.py
|
||||
```
|
||||
|
||||
### 步骤 3: 测试导入
|
||||
```bash
|
||||
cd backend
|
||||
source ../.venv/bin/activate
|
||||
python3 -c "import api.main; print('✓ 导入成功')"
|
||||
```
|
||||
|
||||
### 步骤 4: 查看日志
|
||||
```bash
|
||||
# 查看最新的错误日志
|
||||
tail -50 backend/logs/api.log
|
||||
tail -50 backend/logs/uvicorn.log
|
||||
```
|
||||
|
||||
### 步骤 5: 手动启动测试
|
||||
```bash
|
||||
cd backend
|
||||
source ../.venv/bin/activate
|
||||
export DB_HOST=your_db_host
|
||||
export DB_PORT=3306
|
||||
export DB_USER=your_db_user
|
||||
export DB_PASSWORD=your_db_password
|
||||
export DB_NAME=auto_trade_sys
|
||||
uvicorn api.main:app --host 0.0.0.0 --port 8001 --log-level info
|
||||
```
|
||||
|
||||
## 启动脚本
|
||||
|
||||
### 开发模式(自动重载)
|
||||
```bash
|
||||
cd backend
|
||||
./start_dev.sh
|
||||
```
|
||||
|
||||
### 生产模式(后台运行)
|
||||
```bash
|
||||
cd backend
|
||||
./start.sh
|
||||
```
|
||||
|
||||
## 检查服务状态
|
||||
|
||||
```bash
|
||||
# 检查进程
|
||||
ps aux | grep uvicorn
|
||||
|
||||
# 检查端口
|
||||
lsof -i :8001
|
||||
|
||||
# 测试健康检查
|
||||
curl http://localhost:8001/api/health
|
||||
```
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
"""
|
||||
FastAPI 依赖:解析 JWT、获取当前用户、校验 admin、校验 account_id 访问权
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Header, HTTPException, Depends, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
|
||||
from api.auth_utils import jwt_decode
|
||||
from database.models import User, UserAccountMembership
|
||||
|
||||
|
||||
def _auth_enabled() -> bool:
|
||||
v = (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower()
|
||||
return v not in {"0", "false", "no"}
|
||||
|
||||
|
||||
_bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer_scheme)) -> Dict[str, Any]:
|
||||
if not _auth_enabled():
|
||||
# 未启用登录:视为超级管理员(兼容开发/灰度)
|
||||
return {"id": 0, "username": "dev", "role": "admin", "status": "active"}
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
if (credentials.scheme or "").lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
token = (credentials.credentials or "").strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
try:
|
||||
payload = jwt_decode(token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="登录已失效")
|
||||
|
||||
sub = payload.get("sub")
|
||||
try:
|
||||
uid = int(sub)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="登录已失效")
|
||||
|
||||
u = User.get_by_id(uid)
|
||||
if not u:
|
||||
raise HTTPException(status_code=401, detail="登录已失效")
|
||||
if (u.get("status") or "active") != "active":
|
||||
raise HTTPException(status_code=403, detail="用户已被禁用")
|
||||
return {"id": int(u["id"]), "username": u.get("username") or "", "role": u.get("role") or "user", "status": u.get("status") or "active"}
|
||||
|
||||
|
||||
def require_admin(user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if (user.get("role") or "user") != "admin":
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
return user
|
||||
|
||||
|
||||
def require_account_access(account_id: int, user: Dict[str, Any]) -> int:
|
||||
aid = int(account_id or 1)
|
||||
if (user.get("role") or "user") == "admin":
|
||||
return aid
|
||||
if UserAccountMembership.has_access(int(user["id"]), aid):
|
||||
return aid
|
||||
raise HTTPException(status_code=403, detail="无权访问该账号")
|
||||
|
||||
|
||||
def require_account_owner(account_id: int, user: Dict[str, Any]) -> int:
|
||||
"""
|
||||
账号“拥有者”权限:用于启停交易进程等高危操作。
|
||||
"""
|
||||
aid = int(account_id or 1)
|
||||
if (user.get("role") or "user") == "admin":
|
||||
return aid
|
||||
role = UserAccountMembership.get_role(int(user["id"]), aid)
|
||||
if role == "owner":
|
||||
return aid
|
||||
raise HTTPException(status_code=403, detail="需要该账号 owner 权限")
|
||||
|
||||
|
||||
def get_admin_user(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
return require_admin(user)
|
||||
|
||||
|
||||
def get_account_id(
|
||||
x_account_id: Optional[int] = Header(None, alias="X-Account-Id"),
|
||||
user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> int:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 1. 如果 header 存在,直接校验
|
||||
if x_account_id is not None:
|
||||
aid = int(x_account_id)
|
||||
return require_account_access(aid, user)
|
||||
|
||||
# 2. 如果 header 不存在
|
||||
# 如果是 admin,默认访问 1
|
||||
if (user.get("role") or "user") == "admin":
|
||||
return require_account_access(1, user)
|
||||
|
||||
# 如果是普通用户,尝试查找他拥有的第一个账号
|
||||
try:
|
||||
# 查找用户关联的账号
|
||||
accounts = UserAccountMembership.get_user_accounts(int(user["id"]))
|
||||
if accounts and len(accounts) > 0:
|
||||
first_aid = int(accounts[0]["id"])
|
||||
logger.info(f"get_account_id: No header provided, auto-selected account_id={first_aid} for user {user['id']}")
|
||||
return first_aid
|
||||
except Exception as e:
|
||||
logger.error(f"get_account_id: Failed to auto-select account for user {user['id']}: {e}")
|
||||
|
||||
# 兜底:仍然尝试 1,然后会由 require_account_access 抛出 403
|
||||
logger.warning(f"get_account_id: No header provided and no accounts found for user {user['id']}, defaulting to 1")
|
||||
return require_account_access(1, user)
|
||||
|
||||
|
||||
def require_system_admin(
|
||||
x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
|
||||
user: Dict[str, Any] = Depends(get_admin_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
/api/system/* 管理员保护:
|
||||
- 启用登录(ATS_AUTH_ENABLED=true):要求 JWT 为 admin
|
||||
- 未启用登录:兼容旧逻辑,若配置了 SYSTEM_CONTROL_TOKEN,则要求 X-Admin-Token
|
||||
"""
|
||||
if _auth_enabled():
|
||||
return user
|
||||
|
||||
token = (os.getenv("SYSTEM_CONTROL_TOKEN") or "").strip()
|
||||
if not token:
|
||||
return user
|
||||
if not x_admin_token or x_admin_token != token:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return user
|
||||
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
"""
|
||||
登录鉴权工具(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,9 +3,8 @@ FastAPI应用主入口
|
|||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from api.routes import config, trades, stats, dashboard, account, recommendations, system, accounts, auth, admin, public, data_management
|
||||
from api.routes import config, trades, stats, dashboard, account, recommendations, system
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
|
@ -142,12 +141,12 @@ logger.info(f"日志级别: {os.getenv('LOG_LEVEL', 'INFO')}")
|
|||
|
||||
# 检查 redis-py 是否可用(redis-py 4.2+ 同时支持同步和异步,可替代aioredis)
|
||||
try:
|
||||
import redis # type: ignore
|
||||
import redis
|
||||
# 检查是否是 redis-py 4.2+(支持异步)
|
||||
if hasattr(redis, 'asyncio'):
|
||||
logger.info(f"✓ redis-py 已安装 (版本: {redis.__version__ if hasattr(redis, '__version__') else '未知'}),支持同步和异步客户端")
|
||||
logger.info(" - redis.Redis: 同步客户端(用于config_manager)")
|
||||
logger.info(" - redis.asyncio.Redis: 异步客户端(用于trading_system,可替代aioredis)")
|
||||
logger.info(f" - redis.Redis: 同步客户端(用于config_manager)")
|
||||
logger.info(f" - redis.asyncio.Redis: 异步客户端(用于trading_system,可替代aioredis)")
|
||||
else:
|
||||
logger.warning("⚠ redis-py 版本可能过低,建议升级到 4.2+ 以获得异步支持")
|
||||
except ImportError as e:
|
||||
|
|
@ -155,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(" 提示: 请运行 'pip install redis>=4.2.0' 安装 redis-py")
|
||||
logger.warning(" 注意: redis-py 4.2+ 同时支持同步和异步,无需安装 aioredis")
|
||||
logger.warning(" 或者运行 'pip install -r backend/requirements.txt' 安装所有依赖")
|
||||
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' 安装所有依赖")
|
||||
|
||||
app = FastAPI(
|
||||
title="Auto Trade System API",
|
||||
|
|
@ -166,79 +165,9 @@ app = FastAPI(
|
|||
redirect_slashes=False # 禁用自动重定向,避免307重定向问题
|
||||
)
|
||||
|
||||
# 现货推荐定时扫描间隔(秒),默认 15 分钟;设为 0 关闭定时扫描
|
||||
SPOT_SCAN_INTERVAL_SEC = int(os.getenv("SPOT_SCAN_INTERVAL_SEC", "900"))
|
||||
|
||||
|
||||
async def _spot_scan_loop():
|
||||
"""后台循环:每隔 SPOT_SCAN_INTERVAL_SEC 执行一次现货扫描并写入 Redis。"""
|
||||
if SPOT_SCAN_INTERVAL_SEC <= 0:
|
||||
logger.info("现货推荐定时扫描已关闭(SPOT_SCAN_INTERVAL_SEC=0)")
|
||||
return
|
||||
import asyncio
|
||||
backend_dir = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
try:
|
||||
from spot_scanner import run_spot_scan_and_cache
|
||||
except Exception as e:
|
||||
logger.warning("现货扫描模块加载失败,跳过定时任务: %s", e)
|
||||
return
|
||||
logger.info("现货推荐定时扫描已启动,间隔 %d 秒", SPOT_SCAN_INTERVAL_SEC)
|
||||
while True:
|
||||
try:
|
||||
await run_spot_scan_and_cache(ttl_sec=900)
|
||||
except Exception as e:
|
||||
logger.warning("现货扫描执行失败: %s", e)
|
||||
await asyncio.sleep(SPOT_SCAN_INTERVAL_SEC)
|
||||
|
||||
|
||||
# 启动时:确保存在一个初始管理员(通过环境变量配置)
|
||||
@app.on_event("startup")
|
||||
async def _ensure_initial_admin():
|
||||
try:
|
||||
import os
|
||||
from database.models import User, UserAccountMembership
|
||||
from api.auth_utils import hash_password
|
||||
|
||||
username = (os.getenv("ATS_ADMIN_USERNAME") or "admin").strip()
|
||||
password = (os.getenv("ATS_ADMIN_PASSWORD") or "").strip()
|
||||
if not password:
|
||||
# 不强制创建,避免你忘记改默认密码导致安全风险
|
||||
# 你可以设置 ATS_ADMIN_PASSWORD 后重启后端自动创建
|
||||
logger.warning("未设置 ATS_ADMIN_PASSWORD,跳过自动创建初始管理员")
|
||||
return
|
||||
|
||||
u = User.get_by_username(username)
|
||||
if not u:
|
||||
uid = User.create(username=username, password_hash=hash_password(password), role="admin", status="active")
|
||||
# 默认给管理员绑定 account_id=1(default)
|
||||
try:
|
||||
UserAccountMembership.add(int(uid), 1, role="owner")
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"✓ 已创建初始管理员用户: {username} (id={uid})")
|
||||
else:
|
||||
# 若已存在但不是 admin,则提升为 admin(可注释掉更保守)
|
||||
if (u.get("role") or "user") != "admin":
|
||||
try:
|
||||
User.set_role(int(u["id"]), "admin")
|
||||
logger.warning(f"已将用户 {username} 提升为 admin")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"初始化管理员失败(可忽略): {e}")
|
||||
|
||||
# 启动现货推荐定时扫描(后台任务)
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.create_task(_spot_scan_loop())
|
||||
except Exception as e:
|
||||
logger.warning("启动现货扫描定时任务失败(可忽略): %s", e)
|
||||
|
||||
|
||||
# CORS配置(允许React前端访问)
|
||||
# 默认包含:本地开发端口、主前端域名、推荐查看器域名
|
||||
cors_origins_str = os.getenv('CORS_ORIGINS', 'http://localhost:3000,http://localhost:3001,http://localhost:5173,http://as.deepx1.com,http://asapi.deepx1.com,http://r.deepx1.com,https://r.deepx1.com,http://asapi-new.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')
|
||||
cors_origins = [origin.strip() for origin in cors_origins_str.split(',') if origin.strip()]
|
||||
|
||||
logger.info(f"CORS允许的源: {cors_origins}")
|
||||
|
|
@ -254,17 +183,12 @@ app.add_middleware(
|
|||
|
||||
# 注册路由
|
||||
app.include_router(config.router, prefix="/api/config", tags=["配置管理"])
|
||||
app.include_router(auth.router, tags=["auth"])
|
||||
app.include_router(admin.router)
|
||||
app.include_router(accounts.router, prefix="/api/accounts", tags=["账号管理"])
|
||||
app.include_router(trades.router, prefix="/api/trades", tags=["交易记录"])
|
||||
app.include_router(stats.router, prefix="/api/stats", tags=["统计分析"])
|
||||
app.include_router(dashboard.router, prefix="/api/dashboard", tags=["仪表板"])
|
||||
app.include_router(account.router, prefix="/api/account", tags=["账户数据"])
|
||||
app.include_router(recommendations.router, tags=["交易推荐"])
|
||||
app.include_router(system.router, tags=["系统控制"])
|
||||
app.include_router(data_management.router)
|
||||
app.include_router(public.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,301 +0,0 @@
|
|||
"""
|
||||
账号管理 API(多账号)
|
||||
|
||||
说明:
|
||||
- 这是“多账号第一步”的管理入口:创建/禁用/更新密钥
|
||||
- 交易/配置/统计接口通过 X-Account-Id 头来选择账号(默认 1)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
import logging
|
||||
|
||||
from database.models import Account, UserAccountMembership
|
||||
from api.auth_deps import get_current_user, get_admin_user, require_account_access, require_account_owner
|
||||
|
||||
from api.supervisor_account import (
|
||||
ensure_account_program,
|
||||
run_supervisorctl,
|
||||
parse_supervisor_status,
|
||||
program_name_for_account,
|
||||
tail_supervisor,
|
||||
tail_supervisord_log,
|
||||
tail_trading_log_files,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AccountCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
api_key: Optional[str] = ""
|
||||
api_secret: Optional[str] = ""
|
||||
use_testnet: bool = False
|
||||
status: str = Field("active", pattern="^(active|disabled)$")
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
status: Optional[str] = Field(None, pattern="^(active|disabled)$")
|
||||
use_testnet: Optional[bool] = None
|
||||
|
||||
|
||||
class AccountCredentialsUpdate(BaseModel):
|
||||
api_key: Optional[str] = None
|
||||
api_secret: Optional[str] = None
|
||||
use_testnet: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_my_accounts(user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""列出我有权访问的账号"""
|
||||
try:
|
||||
if user.get("role") == "admin":
|
||||
accounts = Account.list_all()
|
||||
else:
|
||||
accounts = UserAccountMembership.get_user_accounts(user["id"])
|
||||
|
||||
# 补充一些运行时信息(可选),并处理敏感字段
|
||||
for acc in accounts:
|
||||
acc['has_api_key'] = bool(acc.get('api_key_enc'))
|
||||
acc['has_api_secret'] = bool(acc.get('api_secret_enc'))
|
||||
# 移除加密字段,不直接暴露给前端
|
||||
acc.pop('api_key_enc', None)
|
||||
acc.pop('api_secret_enc', None)
|
||||
|
||||
return accounts
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_account(
|
||||
data: AccountCreate,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""创建新账号(仅管理员或允许的用户)"""
|
||||
# 暂时只允许 admin 创建
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Only admin can create accounts")
|
||||
|
||||
try:
|
||||
aid = Account.create(
|
||||
name=data.name,
|
||||
api_key=data.api_key,
|
||||
api_secret=data.api_secret,
|
||||
use_testnet=data.use_testnet,
|
||||
status=data.status
|
||||
)
|
||||
# 自动将创建者关联为 owner
|
||||
UserAccountMembership.add_membership(user["id"], aid, "owner")
|
||||
|
||||
return {"id": aid, "message": "Account created successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{account_id}")
|
||||
async def get_account_detail(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""获取账号详情"""
|
||||
require_account_access(account_id, user)
|
||||
try:
|
||||
acc = Account.get_by_id(account_id)
|
||||
if not acc:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return acc
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{account_id}")
|
||||
async def update_account(
|
||||
account_id: int,
|
||||
data: AccountUpdate,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""更新账号基本信息"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
updates = {}
|
||||
if data.name is not None:
|
||||
updates['name'] = data.name
|
||||
if data.status is not None:
|
||||
updates['status'] = data.status
|
||||
if data.use_testnet is not None:
|
||||
updates['testnet'] = 1 if data.use_testnet else 0
|
||||
|
||||
if updates:
|
||||
Account.update(account_id, **updates)
|
||||
|
||||
return {"message": "Account updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{account_id}/credentials")
|
||||
async def update_credentials(
|
||||
account_id: int,
|
||||
data: AccountCredentialsUpdate,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""更新API密钥"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
updates = {}
|
||||
if data.api_key is not None:
|
||||
updates['api_key'] = data.api_key
|
||||
if data.api_secret is not None:
|
||||
updates['api_secret'] = data.api_secret
|
||||
if data.use_testnet is not None:
|
||||
updates['testnet'] = 1 if data.use_testnet else 0
|
||||
|
||||
if updates:
|
||||
Account.update(account_id, **updates)
|
||||
|
||||
return {"message": "Credentials updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# --- Service Management ---
|
||||
|
||||
@router.get("/{account_id}/trading/status")
|
||||
@router.get("/{account_id}/service/status", include_in_schema=False) # 兼容旧路由
|
||||
async def get_service_status(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""获取该账号关联的交易服务状态"""
|
||||
# 手动调用权限检查,因为 Depends(require_account_access) 无法直接获取路径参数 account_id
|
||||
require_account_access(account_id, user)
|
||||
|
||||
try:
|
||||
program = program_name_for_account(account_id)
|
||||
# status <program>
|
||||
try:
|
||||
out = run_supervisorctl(["status", program])
|
||||
running, pid, state = parse_supervisor_status(out)
|
||||
return {
|
||||
"program": program,
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"state": state,
|
||||
"raw": out
|
||||
}
|
||||
except RuntimeError as e:
|
||||
# 可能进程不存在
|
||||
return {
|
||||
"program": program,
|
||||
"running": False,
|
||||
"pid": None,
|
||||
"state": "UNKNOWN",
|
||||
"raw": str(e),
|
||||
"error": "Process likely not configured or supervisor error"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{account_id}/trading/start")
|
||||
@router.post("/{account_id}/service/start", include_in_schema=False)
|
||||
async def start_service(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""启动交易服务(需该账号 owner 或管理员)"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
program = program_name_for_account(account_id)
|
||||
out = run_supervisorctl(["start", program])
|
||||
# Check status again
|
||||
status_out = run_supervisorctl(["status", program])
|
||||
running, pid, state = parse_supervisor_status(status_out)
|
||||
return {
|
||||
"message": "Service start command sent",
|
||||
"output": out,
|
||||
"status": {
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"state": state
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{account_id}/trading/stop")
|
||||
@router.post("/{account_id}/service/stop", include_in_schema=False)
|
||||
async def stop_service(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""停止交易服务(需该账号 owner 或管理员)"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
program = program_name_for_account(account_id)
|
||||
out = run_supervisorctl(["stop", program])
|
||||
# Check status again
|
||||
status_out = run_supervisorctl(["status", program])
|
||||
running, pid, state = parse_supervisor_status(status_out)
|
||||
return {
|
||||
"message": "Service stop command sent",
|
||||
"output": out,
|
||||
"status": {
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"state": state
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{account_id}/trading/restart")
|
||||
@router.post("/{account_id}/service/restart", include_in_schema=False)
|
||||
async def restart_service(
|
||||
account_id: int,
|
||||
user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""重启交易服务(需该账号 owner 或管理员)"""
|
||||
require_account_owner(account_id, user)
|
||||
try:
|
||||
program = program_name_for_account(account_id)
|
||||
out = run_supervisorctl(["restart", program])
|
||||
# Check status again
|
||||
status_out = run_supervisorctl(["status", program])
|
||||
running, pid, state = parse_supervisor_status(status_out)
|
||||
return {
|
||||
"message": "Service restart command sent",
|
||||
"output": out,
|
||||
"status": {
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
"state": state
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{account_id}/trading/ensure-program")
|
||||
async def ensure_trading_program(account_id: int, user: Dict[str, Any] = Depends(get_current_user)):
|
||||
if int(account_id) <= 0:
|
||||
raise HTTPException(status_code=400, detail="account_id 必须 >= 1")
|
||||
require_account_owner(int(account_id), user)
|
||||
sup = ensure_account_program(int(account_id))
|
||||
if not sup.ok:
|
||||
raise HTTPException(status_code=500, detail=sup.error or "生成 supervisor 配置失败")
|
||||
return {
|
||||
"ok": True,
|
||||
"program": sup.program,
|
||||
"ini_path": sup.ini_path,
|
||||
"program_dir": sup.program_dir,
|
||||
"supervisor_conf": sup.supervisor_conf,
|
||||
"reread": sup.reread,
|
||||
"update": sup.update,
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
"""
|
||||
管理员接口:用户管理 / 授权管理
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from api.auth_deps import get_admin_user
|
||||
from api.auth_utils import hash_password
|
||||
from database.models import User, UserAccountMembership, Account
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
class UserCreateReq(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=200)
|
||||
role: str = Field("user", pattern="^(admin|user)$")
|
||||
status: str = Field("active", pattern="^(active|disabled)$")
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(_admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
return User.list_all()
|
||||
|
||||
|
||||
@router.get("/users/detailed")
|
||||
async def list_users_with_accounts(_admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
"""获取所有用户及其关联账号列表"""
|
||||
users = User.list_all()
|
||||
out = []
|
||||
|
||||
# 获取所有授权关系
|
||||
# 优化:一次性查询所有 memberships 并在内存中分组,避免 N+1 查询
|
||||
# 但由于 UserAccountMembership 没有 list_all 方法,暂时循环查询或添加 list_all
|
||||
# 考虑到用户量不大,循环查询尚可接受。
|
||||
|
||||
for u in users:
|
||||
uid = u['id']
|
||||
memberships = UserAccountMembership.get_user_accounts(uid)
|
||||
user_accounts = []
|
||||
for m in memberships or []:
|
||||
user_accounts.append({
|
||||
"id": m.get("id"),
|
||||
"name": m.get("name"),
|
||||
"status": m.get("status"),
|
||||
"role": m.get("role"),
|
||||
"has_api_key": bool(m.get("api_key_enc")),
|
||||
"has_api_secret": bool(m.get("api_secret_enc"))
|
||||
})
|
||||
|
||||
out.append({
|
||||
"id": uid,
|
||||
"username": u['username'],
|
||||
"role": u['role'],
|
||||
"status": u['status'],
|
||||
"accounts": user_accounts
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(payload: UserCreateReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
exists = User.get_by_username(payload.username)
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
uid = User.create(
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
role=payload.role,
|
||||
status=payload.status,
|
||||
)
|
||||
return {"success": True, "id": int(uid)}
|
||||
|
||||
|
||||
class UserPasswordReq(BaseModel):
|
||||
password: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/password")
|
||||
async def set_user_password(user_id: int, payload: UserPasswordReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
User.set_password(int(user_id), hash_password(payload.password))
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class UserRoleReq(BaseModel):
|
||||
role: str = Field(..., pattern="^(admin|user)$")
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/role")
|
||||
async def set_user_role(user_id: int, payload: UserRoleReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
User.set_role(int(user_id), payload.role)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class UserStatusReq(BaseModel):
|
||||
status: str = Field(..., pattern="^(active|disabled)$")
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/status")
|
||||
async def set_user_status(user_id: int, payload: UserStatusReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
User.set_status(int(user_id), payload.status)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/accounts")
|
||||
async def list_user_accounts(user_id: int, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
memberships = UserAccountMembership.list_for_user(int(user_id))
|
||||
# 追加账号名称(便于前端展示)
|
||||
out = []
|
||||
for m in memberships or []:
|
||||
aid = int(m.get("account_id"))
|
||||
a = Account.get(aid) or {}
|
||||
out.append(
|
||||
{
|
||||
"user_id": int(m.get("user_id")),
|
||||
"account_id": aid,
|
||||
"role": m.get("role") or "viewer",
|
||||
"account_name": a.get("name") or "",
|
||||
"account_status": a.get("status") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class GrantReq(BaseModel):
|
||||
role: str = Field("viewer", pattern="^(owner|viewer)$")
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/accounts/{account_id}")
|
||||
async def grant_user_account(user_id: int, account_id: int, payload: GrantReq, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
u = User.get_by_id(int(user_id))
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
a = Account.get(int(account_id))
|
||||
if not a:
|
||||
raise HTTPException(status_code=404, detail="账号不存在")
|
||||
try:
|
||||
if payload.role == "owner":
|
||||
UserAccountMembership.clear_other_owners_for_account(int(account_id), int(user_id))
|
||||
UserAccountMembership.add(int(user_id), int(account_id), role=payload.role)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"关联账号失败: {str(e)}",
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}/accounts/{account_id}")
|
||||
async def revoke_user_account(user_id: int, account_id: int, _admin: Dict[str, Any] = Depends(get_admin_user)):
|
||||
UserAccountMembership.remove(int(user_id), int(account_id))
|
||||
return {"success": True}
|
||||
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
"""
|
||||
登录鉴权 API(JWT)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
|
||||
from database.models import User
|
||||
from api.auth_utils import verify_password, jwt_encode
|
||||
from api.auth_deps import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginReq(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class LoginResp(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: Dict[str, Any]
|
||||
|
||||
|
||||
def _auth_enabled() -> bool:
|
||||
v = (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower()
|
||||
return v not in {"0", "false", "no"}
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResp)
|
||||
async def login(payload: LoginReq):
|
||||
if not _auth_enabled():
|
||||
raise HTTPException(status_code=400, detail="当前环境未启用登录(ATS_AUTH_ENABLED=false)")
|
||||
|
||||
u = User.get_by_username(payload.username)
|
||||
if not u:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
if (u.get("status") or "active") != "active":
|
||||
raise HTTPException(status_code=403, detail="用户已被禁用")
|
||||
if not verify_password(payload.password, u.get("password_hash") or ""):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
token = jwt_encode({"sub": str(u["id"]), "role": u.get("role") or "user"}, exp_sec=24 * 3600)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"id": u["id"], "username": u["username"], "role": u.get("role") or "user", "status": u.get("status") or "active"},
|
||||
}
|
||||
|
||||
|
||||
class MeResp(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
role: str
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResp)
|
||||
async def me(user: Dict[str, Any] = Depends(get_current_user)):
|
||||
return {
|
||||
"id": int(user["id"]),
|
||||
"username": user.get("username") or "",
|
||||
"role": user.get("role") or "user",
|
||||
"status": user.get("status") or "active",
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,374 +0,0 @@
|
|||
"""
|
||||
数据管理:查询 DB 交易、从币安拉取订单/成交,供策略分析与导出。
|
||||
仅管理员可用。
|
||||
"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Query, Depends, HTTPException
|
||||
from typing import Optional
|
||||
|
||||
from api.auth_deps import get_admin_user
|
||||
from database.models import Trade, Account
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
router = APIRouter(prefix="/api/admin/data", tags=["数据管理"])
|
||||
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _get_timestamp_range(period: Optional[str], start_date: Optional[str], end_date: Optional[str]):
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
end_ts = int(now.timestamp())
|
||||
start_ts = None
|
||||
|
||||
if period:
|
||||
if period == "today":
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_ts = int(today.timestamp())
|
||||
elif period == "1d":
|
||||
start_ts = end_ts - 24 * 3600
|
||||
elif period == "7d":
|
||||
start_ts = end_ts - 7 * 24 * 3600
|
||||
elif period == "30d":
|
||||
start_ts = end_ts - 30 * 24 * 3600
|
||||
elif period == "week":
|
||||
days = now.weekday()
|
||||
week_start = (now - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_ts = int(week_start.timestamp())
|
||||
elif period == "month":
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
start_ts = int(month_start.timestamp())
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
s = start_date if len(start_date) > 10 else f"{start_date} 00:00:00"
|
||||
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=BEIJING_TZ)
|
||||
start_ts = int(dt.timestamp())
|
||||
except ValueError:
|
||||
pass
|
||||
if end_date:
|
||||
try:
|
||||
s = end_date if len(end_date) > 10 else f"{end_date} 23:59:59"
|
||||
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=BEIJING_TZ)
|
||||
end_ts = int(dt.timestamp())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if start_ts is None:
|
||||
start_ts = end_ts - 7 * 24 * 3600 # 默认 7 天
|
||||
return start_ts, end_ts
|
||||
|
||||
|
||||
def _compute_binance_stats(data: list, data_type: str) -> dict:
|
||||
"""计算用于策略分析的统计数据(成交/订单原始字段均已保留,导出 JSON 含全部)"""
|
||||
stats = {"count": len(data)}
|
||||
valid = [r for r in data if isinstance(r, dict) and "_error" not in r]
|
||||
if not valid:
|
||||
return stats
|
||||
|
||||
if data_type == "trades":
|
||||
pnls = []
|
||||
commissions = []
|
||||
quote_qtys = []
|
||||
by_symbol = {}
|
||||
wins, losses = 0, 0
|
||||
maker_count, taker_count = 0, 0
|
||||
for r in valid:
|
||||
sym = r.get("_symbol") or r.get("symbol") or "-"
|
||||
p = float(r.get("realizedPnl") or 0)
|
||||
c = float(r.get("commission") or 0)
|
||||
qq = float(r.get("quoteQty") or 0)
|
||||
pnls.append(p)
|
||||
commissions.append(c)
|
||||
if qq:
|
||||
quote_qtys.append(qq)
|
||||
if p > 0:
|
||||
wins += 1
|
||||
elif p < 0:
|
||||
losses += 1
|
||||
if r.get("maker"):
|
||||
maker_count += 1
|
||||
else:
|
||||
taker_count += 1
|
||||
by_symbol[sym] = by_symbol.get(sym, {"count": 0, "pnl": 0.0, "commission": 0.0, "quoteQty": 0.0})
|
||||
by_symbol[sym]["count"] += 1
|
||||
by_symbol[sym]["pnl"] += p
|
||||
by_symbol[sym]["commission"] += c
|
||||
by_symbol[sym]["quoteQty"] += qq
|
||||
|
||||
stats["total_realized_pnl"] = round(sum(pnls), 4)
|
||||
stats["total_commission"] = round(sum(commissions), 4)
|
||||
stats["net_pnl"] = round(stats["total_realized_pnl"] - stats["total_commission"], 4)
|
||||
stats["win_count"] = wins
|
||||
stats["loss_count"] = losses
|
||||
stats["win_rate"] = round(100 * wins / (wins + losses), 1) if (wins + losses) > 0 else 0
|
||||
stats["avg_pnl_per_trade"] = round(sum(pnls) / len(pnls), 4) if pnls else 0
|
||||
stats["total_quote_qty"] = round(sum(quote_qtys), 2)
|
||||
stats["maker_count"] = maker_count
|
||||
stats["taker_count"] = taker_count
|
||||
stats["by_symbol"] = {
|
||||
k: {
|
||||
"count": v["count"],
|
||||
"pnl": round(v["pnl"], 4),
|
||||
"commission": round(v["commission"], 4),
|
||||
"quoteQty": round(v["quoteQty"], 2),
|
||||
}
|
||||
for k, v in sorted(by_symbol.items())
|
||||
}
|
||||
|
||||
by_hour = {}
|
||||
by_weekday = {}
|
||||
weekday_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
for r in valid:
|
||||
t = r.get("time") or r.get("trade_time") or 0
|
||||
if t:
|
||||
dt = datetime.fromtimestamp(t / 1000, tz=BEIJING_TZ)
|
||||
h = dt.hour
|
||||
wd = dt.weekday()
|
||||
by_hour[h] = by_hour.get(h, {"count": 0, "pnl": 0.0})
|
||||
by_hour[h]["count"] += 1
|
||||
by_hour[h]["pnl"] += float(r.get("realizedPnl") or 0)
|
||||
by_weekday[wd] = by_weekday.get(wd, {"count": 0, "pnl": 0.0})
|
||||
by_weekday[wd]["count"] += 1
|
||||
by_weekday[wd]["pnl"] += float(r.get("realizedPnl") or 0)
|
||||
stats["by_hour"] = {str(k): {"count": v["count"], "pnl": round(v["pnl"], 4)} for k, v in sorted(by_hour.items())}
|
||||
stats["by_weekday"] = {weekday_names[k]: {"count": v["count"], "pnl": round(v["pnl"], 4)} for k, v in sorted(by_weekday.items())}
|
||||
else:
|
||||
by_status = {}
|
||||
by_type = {}
|
||||
by_symbol = {}
|
||||
filled_count = 0
|
||||
for r in valid:
|
||||
status = r.get("status") or "UNKNOWN"
|
||||
typ = r.get("type") or r.get("origType") or "UNKNOWN"
|
||||
sym = r.get("_symbol") or r.get("symbol") or "-"
|
||||
by_status[status] = by_status.get(status, 0) + 1
|
||||
by_type[typ] = by_type.get(typ, 0) + 1
|
||||
by_symbol[sym] = by_symbol.get(sym, 0) + 1
|
||||
if status == "FILLED":
|
||||
filled_count += 1
|
||||
stats["by_status"] = by_status
|
||||
stats["by_type"] = by_type
|
||||
stats["by_symbol"] = dict(sorted(by_symbol.items()))
|
||||
stats["filled_count"] = filled_count
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def _get_active_symbols_from_income(binance_client, start_ms: int, end_ms: int) -> list:
|
||||
"""
|
||||
通过收益历史 API 获取该时间段内有交易活动的交易对,避免全量遍历 250+ 交易对。
|
||||
一次 API 调用(weight 100)即可拿到有成交/盈亏的 symbol 列表,大幅减少后续 trades/orders 的请求数。
|
||||
"""
|
||||
try:
|
||||
symbols = set()
|
||||
current_end = end_ms
|
||||
for _ in range(10): # 最多分页 10 次(单次最多 1000 条)
|
||||
rows = await binance_client.futures_income_history(
|
||||
startTime=start_ms,
|
||||
endTime=current_end,
|
||||
limit=1000,
|
||||
recvWindow=20000,
|
||||
)
|
||||
if not rows:
|
||||
break
|
||||
for r in rows:
|
||||
sym = (r.get("symbol") or "").strip()
|
||||
if sym and sym.endswith("USDT"):
|
||||
symbols.add(sym)
|
||||
if len(rows) < 1000:
|
||||
break
|
||||
oldest = min(r.get("time", current_end) for r in rows)
|
||||
current_end = oldest - 1
|
||||
if current_end < start_ms:
|
||||
break
|
||||
await asyncio.sleep(0.15)
|
||||
return sorted(symbols)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/accounts")
|
||||
async def list_accounts(_admin=Depends(get_admin_user), active_only: bool = Query(False)):
|
||||
"""获取账号列表,供数据管理选择。active_only=true 时仅返回 status=active 的账号"""
|
||||
rows = Account.list_all()
|
||||
accounts = [{"id": r["id"], "name": r.get("name") or f"Account {r['id']}", "status": r.get("status") or "active"} for r in (rows or [])]
|
||||
if active_only:
|
||||
accounts = [a for a in accounts if (a.get("status") or "").lower() == "active"]
|
||||
return {"accounts": accounts}
|
||||
|
||||
|
||||
@router.get("/trades")
|
||||
async def query_db_trades(
|
||||
_admin=Depends(get_admin_user),
|
||||
account_id: int = Query(..., ge=1, description="账号 ID"),
|
||||
period: Optional[str] = Query(None, description="today/1d/7d/30d/week/month"),
|
||||
date: Optional[str] = Query(None, description="YYYY-MM-DD,指定日期(等同于 start_date=end_date)"),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
symbol: Optional[str] = Query(None),
|
||||
time_filter: str = Query("created", description="created/entry/exit"),
|
||||
reconciled_only: Optional[str] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=2000),
|
||||
):
|
||||
"""
|
||||
查询 DB 交易记录(管理员可指定任意账号)
|
||||
"""
|
||||
sd, ed = start_date, end_date
|
||||
if date:
|
||||
sd, ed = date, date
|
||||
_reconciled = str(reconciled_only or "").lower() in ("true", "1", "yes")
|
||||
start_ts, end_ts = _get_timestamp_range(period or "today", sd, ed)
|
||||
trades = Trade.get_all(
|
||||
start_timestamp=start_ts,
|
||||
end_timestamp=end_ts,
|
||||
symbol=symbol,
|
||||
status=None,
|
||||
account_id=account_id,
|
||||
time_filter=time_filter,
|
||||
limit=limit,
|
||||
reconciled_only=_reconciled,
|
||||
include_sync=True,
|
||||
)
|
||||
out = []
|
||||
for t in trades:
|
||||
row = dict(t)
|
||||
for k, v in row.items():
|
||||
if hasattr(v, "isoformat"):
|
||||
row[k] = v.isoformat()
|
||||
out.append(row)
|
||||
return {"total": len(out), "trades": out}
|
||||
|
||||
|
||||
def _enrich_trades_with_derived(trades: list) -> list:
|
||||
"""补充推算字段:入场价、交易小时、星期,便于策略分析"""
|
||||
result = []
|
||||
for r in trades:
|
||||
out = dict(r)
|
||||
t = r.get("time") or 0
|
||||
if t:
|
||||
dt = datetime.fromtimestamp(t / 1000, tz=BEIJING_TZ)
|
||||
out["_trade_hour"] = dt.hour
|
||||
out["_trade_weekday"] = dt.weekday()
|
||||
out["_trade_date"] = dt.strftime("%Y-%m-%d")
|
||||
pnl = float(r.get("realizedPnl") or 0)
|
||||
qty = float(r.get("qty") or 0)
|
||||
price = float(r.get("price") or 0)
|
||||
side = (r.get("side") or "").upper()
|
||||
if qty and pnl != 0 and side:
|
||||
if side == "SELL":
|
||||
out["_approx_entry_price"] = round(price - pnl / qty, 8)
|
||||
else:
|
||||
out["_approx_entry_price"] = round(price + pnl / qty, 8)
|
||||
else:
|
||||
out["_approx_entry_price"] = None
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
def _binance_row_to_api_format(row: dict, data_type: str) -> dict:
|
||||
"""将 DB 行转换为前端/导出期望的币安 API 格式"""
|
||||
if data_type == "trades":
|
||||
return {
|
||||
"id": row.get("trade_id"),
|
||||
"orderId": row.get("order_id"),
|
||||
"symbol": row.get("symbol"),
|
||||
"_symbol": row.get("symbol"),
|
||||
"side": row.get("side"),
|
||||
"positionSide": row.get("position_side"),
|
||||
"price": str(row.get("price") or ""),
|
||||
"qty": str(row.get("qty") or ""),
|
||||
"quoteQty": str(row.get("quote_qty") or ""),
|
||||
"realizedPnl": str(row.get("realized_pnl") or ""),
|
||||
"commission": str(row.get("commission") or ""),
|
||||
"commissionAsset": row.get("commission_asset"),
|
||||
"buyer": bool(row.get("buyer")),
|
||||
"maker": bool(row.get("maker")),
|
||||
"time": row.get("trade_time"),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"orderId": row.get("order_id"),
|
||||
"clientOrderId": row.get("client_order_id"),
|
||||
"symbol": row.get("symbol"),
|
||||
"_symbol": row.get("symbol"),
|
||||
"side": row.get("side"),
|
||||
"type": row.get("type"),
|
||||
"origType": row.get("orig_type"),
|
||||
"status": row.get("status"),
|
||||
"price": str(row.get("price") or ""),
|
||||
"avgPrice": str(row.get("avg_price") or ""),
|
||||
"origQty": str(row.get("orig_qty") or ""),
|
||||
"executedQty": str(row.get("executed_qty") or ""),
|
||||
"cumQty": str(row.get("cum_qty") or ""),
|
||||
"cumQuote": str(row.get("cum_quote") or ""),
|
||||
"stopPrice": str(row.get("stop_price") or "") if row.get("stop_price") else "",
|
||||
"reduceOnly": bool(row.get("reduce_only")),
|
||||
"positionSide": row.get("position_side"),
|
||||
"time": row.get("order_time"),
|
||||
"updateTime": row.get("update_time"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/binance-fetch")
|
||||
async def query_binance_data_from_db(
|
||||
_admin=Depends(get_admin_user),
|
||||
account_id: int = Query(..., ge=1),
|
||||
symbols: Optional[str] = Query(None, description="交易对,逗号分隔;留空则全部"),
|
||||
data_type: str = Query("trades", description="orders 或 trades"),
|
||||
days: int = Query(7, ge=0, le=7),
|
||||
):
|
||||
"""
|
||||
从 DB 查询已同步的币安订单/成交(由定时任务 scripts/sync_binance_orders.py 拉取入库)
|
||||
"""
|
||||
from database.connection import db
|
||||
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
end_ts = int(now.timestamp())
|
||||
if days == 0:
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_ts = int(today_start.timestamp())
|
||||
else:
|
||||
start_ts = end_ts - days * 24 * 3600
|
||||
start_ms = start_ts * 1000
|
||||
end_ms = end_ts * 1000
|
||||
|
||||
symbol_list = [s.strip().upper() for s in (symbols or "").split(",") if s.strip()]
|
||||
|
||||
try:
|
||||
if data_type == "trades":
|
||||
q = """SELECT * FROM binance_trades
|
||||
WHERE account_id = %s AND trade_time >= %s AND trade_time <= %s"""
|
||||
params = [account_id, start_ms, end_ms]
|
||||
if symbol_list:
|
||||
q += " AND symbol IN (" + ",".join(["%s"] * len(symbol_list)) + ")"
|
||||
params.extend(symbol_list)
|
||||
q += " ORDER BY trade_time DESC LIMIT 5000"
|
||||
else:
|
||||
q = """SELECT * FROM binance_orders
|
||||
WHERE account_id = %s AND order_time >= %s AND order_time <= %s"""
|
||||
params = [account_id, start_ms, end_ms]
|
||||
if symbol_list:
|
||||
q += " AND symbol IN (" + ",".join(["%s"] * len(symbol_list)) + ")"
|
||||
params.extend(symbol_list)
|
||||
q += " ORDER BY order_time DESC LIMIT 5000"
|
||||
|
||||
rows = db.execute_query(q, params)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"查询失败(请确认已执行 add_binance_sync_tables.sql 并运行过同步脚本): {e}")
|
||||
|
||||
all_data = [_binance_row_to_api_format(dict(r), data_type) for r in (rows or [])]
|
||||
if data_type == "trades":
|
||||
all_data = _enrich_trades_with_derived(all_data)
|
||||
symbols_queried = len(symbol_list) if symbol_list else len({(r or {}).get("symbol") for r in (rows or []) if (r or {}).get("symbol")})
|
||||
stats = _compute_binance_stats(all_data, data_type)
|
||||
|
||||
return {
|
||||
"total": len(all_data),
|
||||
"data_type": data_type,
|
||||
"symbols_queried": symbols_queried,
|
||||
"stats": stats,
|
||||
"data": all_data,
|
||||
"source": "db",
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
"""
|
||||
公开只读状态接口(非管理员也可访问)
|
||||
|
||||
用途:
|
||||
- 普通用户能看到:后端是否在线、启动时间、推荐是否在更新(snapshot 时间)
|
||||
- recommendations-viewer 也可复用该接口展示“服务状态”
|
||||
|
||||
安全原则:
|
||||
- 不返回任何敏感信息(不返回密钥、密码、完整 Redis URL 等)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
try:
|
||||
import redis.asyncio as redis_async
|
||||
except Exception: # pragma: no cover
|
||||
redis_async = None
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
_STARTED_AT_MS = int(time.time() * 1000)
|
||||
|
||||
REDIS_KEY_RECOMMENDATIONS_SNAPSHOT = "recommendations:snapshot"
|
||||
|
||||
|
||||
def _beijing_time_str(ts_ms: Optional[int] = None) -> str:
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
if ts_ms is None:
|
||||
return datetime.now(tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _mask_redis_url(redis_url: str) -> str:
|
||||
s = (redis_url or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
# 简单脱敏:去掉 username/password(如果有)
|
||||
# rediss://user:pass@host:6379/0 -> rediss://***@host:6379/0
|
||||
if "://" in s and "@" in s:
|
||||
scheme, rest = s.split("://", 1)
|
||||
creds_and_host = rest
|
||||
# 仅替换 @ 前面的内容
|
||||
idx = creds_and_host.rfind("@")
|
||||
if idx > 0:
|
||||
return f"{scheme}://***@{creds_and_host[idx+1:]}"
|
||||
return s
|
||||
|
||||
|
||||
def _redis_connection_kwargs() -> Tuple[str, Dict[str, Any]]:
|
||||
redis_url = (os.getenv("REDIS_URL", "") or "").strip() or "redis://localhost:6379"
|
||||
username = os.getenv("REDIS_USERNAME", None)
|
||||
password = os.getenv("REDIS_PASSWORD", None)
|
||||
ssl_cert_reqs = (os.getenv("REDIS_SSL_CERT_REQS", "required") or "required").strip()
|
||||
ssl_ca_certs = os.getenv("REDIS_SSL_CA_CERTS", None)
|
||||
|
||||
select = os.getenv("REDIS_SELECT", None)
|
||||
try:
|
||||
select_i = int(select) if select is not None else 0
|
||||
except Exception:
|
||||
select_i = 0
|
||||
|
||||
kwargs: Dict[str, Any] = {"decode_responses": True}
|
||||
if username:
|
||||
kwargs["username"] = username
|
||||
if password:
|
||||
kwargs["password"] = password
|
||||
kwargs["db"] = select_i
|
||||
|
||||
use_tls = redis_url.startswith("rediss://") or (os.getenv("REDIS_USE_TLS", "False").lower() == "true")
|
||||
if use_tls and not redis_url.startswith("rediss://"):
|
||||
if redis_url.startswith("redis://"):
|
||||
redis_url = redis_url.replace("redis://", "rediss://", 1)
|
||||
else:
|
||||
redis_url = f"rediss://{redis_url}"
|
||||
|
||||
if use_tls or redis_url.startswith("rediss://"):
|
||||
kwargs["ssl_cert_reqs"] = ssl_cert_reqs
|
||||
if ssl_ca_certs:
|
||||
kwargs["ssl_ca_certs"] = ssl_ca_certs
|
||||
kwargs["ssl_check_hostname"] = (ssl_cert_reqs == "required")
|
||||
|
||||
return redis_url, kwargs
|
||||
|
||||
|
||||
async def _get_redis():
|
||||
if redis_async is None:
|
||||
return None
|
||||
redis_url, kwargs = _redis_connection_kwargs()
|
||||
try:
|
||||
client = redis_async.from_url(redis_url, **kwargs)
|
||||
await client.ping()
|
||||
return client
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _get_cached_json(client, key: str) -> Optional[Any]:
|
||||
try:
|
||||
raw = await client.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def public_status():
|
||||
"""
|
||||
公共状态:
|
||||
- backend:在线/启动时间
|
||||
- redis:可用性(不暴露密码)
|
||||
- recommendations:snapshot 最新生成时间(若推荐进程在跑,会持续更新)
|
||||
"""
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
# Redis + 推荐快照
|
||||
redis_ok = False
|
||||
reco: Dict[str, Any] = {"snapshot_ok": False}
|
||||
redis_meta: Dict[str, Any] = {"ok": False, "db": int(os.getenv("REDIS_SELECT", "0") or 0), "url": _mask_redis_url(os.getenv("REDIS_URL", ""))}
|
||||
|
||||
rds = await _get_redis()
|
||||
if rds is not None:
|
||||
redis_ok = True
|
||||
redis_meta["ok"] = True
|
||||
try:
|
||||
snap = await _get_cached_json(rds, REDIS_KEY_RECOMMENDATIONS_SNAPSHOT)
|
||||
except Exception:
|
||||
snap = None
|
||||
|
||||
if isinstance(snap, dict):
|
||||
gen_ms = snap.get("generated_at_ms")
|
||||
try:
|
||||
gen_ms = int(gen_ms) if gen_ms is not None else None
|
||||
except Exception:
|
||||
gen_ms = None
|
||||
count = snap.get("count")
|
||||
try:
|
||||
count = int(count) if count is not None else None
|
||||
except Exception:
|
||||
count = None
|
||||
age_sec = None
|
||||
if gen_ms:
|
||||
age_sec = max(0, int((now_ms - gen_ms) / 1000))
|
||||
reco = {
|
||||
"snapshot_ok": True,
|
||||
"generated_at_ms": gen_ms,
|
||||
"generated_at": snap.get("generated_at"),
|
||||
"generated_at_beijing": _beijing_time_str(gen_ms) if gen_ms else None,
|
||||
"age_sec": age_sec,
|
||||
"count": count,
|
||||
"ttl_sec": snap.get("ttl_sec"),
|
||||
}
|
||||
|
||||
try:
|
||||
await rds.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"backend": {
|
||||
"running": True,
|
||||
"started_at_ms": _STARTED_AT_MS,
|
||||
"started_at": _beijing_time_str(_STARTED_AT_MS),
|
||||
"now_ms": now_ms,
|
||||
"now": _beijing_time_str(now_ms),
|
||||
},
|
||||
"redis": redis_meta,
|
||||
"recommendations": reco,
|
||||
"auth": {
|
||||
"enabled": (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower() not in {"0", "false", "no"},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -366,15 +366,7 @@ async def get_recommendations(
|
|||
|
||||
# 限制返回数量
|
||||
recommendations = recommendations[:limit]
|
||||
# 合约推荐为空时给出排查提示(与现货独立:现货来自定时扫描,合约来自策略/推荐服务)
|
||||
hint = None
|
||||
if len(recommendations) == 0:
|
||||
hint = (
|
||||
"合约推荐来自策略扫描:需信号强度≥5且方向明确才会写入。"
|
||||
"若长期为空,请检查:1) 推荐服务(recommendations_main)或主策略(main)是否在运行;"
|
||||
"2) 扫描日志中是否有「信号:N」≥5 的标的;3) 是否有推荐被时间/价格偏离过滤掉(见 meta.dropped)。"
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(recommendations),
|
||||
|
|
@ -392,7 +384,6 @@ async def get_recommendations(
|
|||
"price_drift": dropped_drift,
|
||||
"invalid": dropped_invalid,
|
||||
},
|
||||
"hint": hint,
|
||||
},
|
||||
"data": recommendations
|
||||
}
|
||||
|
|
@ -504,72 +495,6 @@ async def get_recommendations(
|
|||
raise HTTPException(status_code=500, detail=f"获取推荐列表失败: {str(e)}")
|
||||
|
||||
|
||||
REDIS_KEY_SPOT_SNAPSHOT = "recommendations:spot:snapshot"
|
||||
|
||||
|
||||
@router.get("/spot")
|
||||
async def get_spot_recommendations(
|
||||
limit: int = Query(50, ge=1, le=200, description="返回数量限制"),
|
||||
):
|
||||
"""
|
||||
获取现货推荐(只做多)。数据来自定时任务扫描并写入的 Redis 缓存。
|
||||
"""
|
||||
try:
|
||||
rds = await _get_redis()
|
||||
if rds is None:
|
||||
raise HTTPException(status_code=503, detail="Redis 不可用,无法读取现货推荐缓存")
|
||||
snapshot = await _get_cached_json(rds, REDIS_KEY_SPOT_SNAPSHOT)
|
||||
if not isinstance(snapshot, dict):
|
||||
return {
|
||||
"success": True,
|
||||
"count": 0,
|
||||
"type": "spot",
|
||||
"from_cache": False,
|
||||
"meta": {"generated_at": None, "message": "暂无现货推荐数据,请等待定时扫描更新"},
|
||||
"data": [],
|
||||
}
|
||||
items = snapshot.get("items") or []
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
items = items[:limit]
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(items),
|
||||
"type": "spot",
|
||||
"from_cache": True,
|
||||
"meta": {
|
||||
"generated_at": snapshot.get("generated_at"),
|
||||
"generated_at_ms": snapshot.get("generated_at_ms"),
|
||||
"ttl_sec": snapshot.get("ttl_sec"),
|
||||
},
|
||||
"data": items,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取现货推荐失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"获取现货推荐失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/spot/scan")
|
||||
async def trigger_spot_scan():
|
||||
"""
|
||||
手动触发一次现货扫描并更新 Redis 缓存(供定时任务或管理员调用)。
|
||||
"""
|
||||
try:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
backend_dir = Path(__file__).resolve().parent.parent.parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
from spot_scanner import run_spot_scan_and_cache
|
||||
count = await run_spot_scan_and_cache(ttl_sec=900)
|
||||
return {"success": True, "message": f"已扫描并缓存 {count} 条现货推荐", "count": count}
|
||||
except Exception as e:
|
||||
logger.error(f"现货扫描失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"现货扫描失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
async def get_active_recommendations():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
统计分析API
|
||||
"""
|
||||
from fastapi import APIRouter, Query, Header, Depends
|
||||
from fastapi import APIRouter, Query
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
|
@ -11,251 +11,23 @@ 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, Account, TradeStats
|
||||
from database.models import AccountSnapshot, Trade, MarketScan, TradingSignal
|
||||
from fastapi import HTTPException
|
||||
from api.auth_deps import get_account_id, get_admin_user
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/admin/dashboard")
|
||||
async def get_admin_dashboard_stats(user: Dict[str, Any] = Depends(get_admin_user)):
|
||||
"""获取管理员仪表板数据:总资产来自各账号快照汇总(不调币安),总盈亏为最近7天聚合已实现盈亏。"""
|
||||
try:
|
||||
accounts = Account.list_all()
|
||||
stats = []
|
||||
total_assets = 0.0
|
||||
active_accounts = 0
|
||||
for acc in accounts:
|
||||
aid = acc["id"]
|
||||
# 取最近 30 天内的快照,再取最新一条,避免“仅 1 天”导致无数据
|
||||
snapshots = AccountSnapshot.get_recent(30, account_id=aid)
|
||||
acc_stat = {
|
||||
"id": aid,
|
||||
"name": acc["name"],
|
||||
"status": acc["status"],
|
||||
"total_balance": 0,
|
||||
"total_pnl": 0,
|
||||
"open_positions": 0,
|
||||
}
|
||||
if snapshots:
|
||||
snap = snapshots[0]
|
||||
acc_stat["total_balance"] = snap.get("total_balance", 0)
|
||||
acc_stat["total_pnl"] = snap.get("total_pnl", 0)
|
||||
acc_stat["open_positions"] = snap.get("open_positions", 0)
|
||||
total_assets += float(acc_stat["total_balance"])
|
||||
if acc["status"] == "active":
|
||||
active_accounts += 1
|
||||
stats.append(acc_stat)
|
||||
total_pnl_7d = 0.0
|
||||
try:
|
||||
global_symbols = TradeStats.get_global_symbol_stats(days=7)
|
||||
for row in global_symbols:
|
||||
total_pnl_7d += float(row.get("net_pnl") or 0)
|
||||
except Exception as e:
|
||||
logger.debug(f"获取全局7天净盈亏失败: {e}")
|
||||
return {
|
||||
"summary": {
|
||||
"total_accounts": len(accounts),
|
||||
"active_accounts": active_accounts,
|
||||
"total_assets_usdt": round(total_assets, 2),
|
||||
"total_pnl_usdt": round(total_pnl_7d, 2),
|
||||
},
|
||||
"accounts": stats,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取管理员仪表板数据失败: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/admin/overall-trade-stats")
|
||||
async def get_admin_overall_trade_stats(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
user: Dict[str, Any] = Depends(get_admin_user),
|
||||
):
|
||||
"""管理员:全账号最近 N 天整体订单统计。"""
|
||||
try:
|
||||
by_symbol_raw = TradeStats.get_global_symbol_stats(days=days)
|
||||
by_hour_raw = TradeStats.get_global_hourly_stats(days=days)
|
||||
by_symbol = []
|
||||
for row in by_symbol_raw:
|
||||
tc = int(row.get("trade_count") or 0)
|
||||
win_count = int(row.get("win_count") or 0)
|
||||
loss_count = int(row.get("loss_count") or 0)
|
||||
net_pnl = float(row.get("net_pnl") or 0)
|
||||
win_rate = (100.0 * win_count / tc) if tc > 0 else 0.0
|
||||
by_symbol.append({
|
||||
"symbol": (row.get("symbol") or "").strip(),
|
||||
"trade_count": tc,
|
||||
"win_count": win_count,
|
||||
"loss_count": loss_count,
|
||||
"net_pnl": round(net_pnl, 4),
|
||||
"win_rate_pct": round(win_rate, 1),
|
||||
})
|
||||
by_symbol = [x for x in by_symbol if x["symbol"]]
|
||||
by_symbol.sort(key=lambda x: (-x["net_pnl"], -x["trade_count"]))
|
||||
hourly_agg = [{"hour": h, "trade_count": 0, "net_pnl": 0.0} for h in range(24)]
|
||||
for row in by_hour_raw:
|
||||
h = row.get("hour")
|
||||
if h is not None and 0 <= int(h) <= 23:
|
||||
hi = int(h)
|
||||
hourly_agg[hi]["trade_count"] = int(row.get("trade_count") or 0)
|
||||
hourly_agg[hi]["net_pnl"] = round(float(row.get("net_pnl") or 0), 4)
|
||||
total_trade_count = sum(x["trade_count"] for x in by_symbol)
|
||||
total_win = sum(x["win_count"] for x in by_symbol)
|
||||
total_loss = sum(x["loss_count"] for x in by_symbol)
|
||||
total_net_pnl = sum(x["net_pnl"] for x in by_symbol)
|
||||
suggestions = _build_suggestions(by_symbol)
|
||||
return {
|
||||
"days": days,
|
||||
"summary": {
|
||||
"trade_count": total_trade_count,
|
||||
"win_count": total_win,
|
||||
"loss_count": total_loss,
|
||||
"net_pnl": round(total_net_pnl, 4),
|
||||
},
|
||||
"by_symbol": by_symbol,
|
||||
"hourly_agg": hourly_agg,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("get_admin_overall_trade_stats 失败")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _aggregate_daily_by_symbol(daily: list) -> list:
|
||||
"""将 daily(按 date+symbol)聚合成按 symbol 的汇总。"""
|
||||
from collections import defaultdict
|
||||
agg = defaultdict(lambda: {"trade_count": 0, "win_count": 0, "loss_count": 0, "net_pnl": 0.0})
|
||||
for row in daily:
|
||||
sym = (row.get("symbol") or "").strip()
|
||||
if not sym:
|
||||
continue
|
||||
agg[sym]["trade_count"] += int(row.get("trade_count") or 0)
|
||||
agg[sym]["win_count"] += int(row.get("win_count") or 0)
|
||||
agg[sym]["loss_count"] += int(row.get("loss_count") or 0)
|
||||
try:
|
||||
agg[sym]["net_pnl"] += float(row.get("net_pnl") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
out = []
|
||||
for symbol, v in agg.items():
|
||||
tc = v["trade_count"]
|
||||
win_rate = (100.0 * v["win_count"] / tc) if tc > 0 else 0.0
|
||||
out.append({
|
||||
"symbol": symbol,
|
||||
"trade_count": tc,
|
||||
"win_count": v["win_count"],
|
||||
"loss_count": v["loss_count"],
|
||||
"net_pnl": round(v["net_pnl"], 4),
|
||||
"win_rate_pct": round(win_rate, 1),
|
||||
})
|
||||
return sorted(out, key=lambda x: (-x["net_pnl"], -x["trade_count"]))
|
||||
|
||||
|
||||
def _aggregate_hourly(by_hour: list) -> list:
|
||||
"""将 by_hour(按 date+hour)聚合成按 hour 0-23 的汇总。"""
|
||||
from collections import defaultdict
|
||||
agg = defaultdict(lambda: {"trade_count": 0, "net_pnl": 0.0})
|
||||
for row in by_hour:
|
||||
h = row.get("hour")
|
||||
if h is None:
|
||||
continue
|
||||
try:
|
||||
h = int(h)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 0 <= h <= 23:
|
||||
agg[h]["trade_count"] += int(row.get("trade_count") or 0)
|
||||
try:
|
||||
agg[h]["net_pnl"] += float(row.get("net_pnl") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return [{"hour": h, "trade_count": agg[h]["trade_count"], "net_pnl": round(agg[h]["net_pnl"], 4)} for h in range(24)]
|
||||
|
||||
|
||||
def _build_suggestions(by_symbol: list) -> dict:
|
||||
"""
|
||||
根据按交易对汇总生成白名单/黑名单建议(仅展示,不自动改策略)。
|
||||
- 黑名单:净亏且笔数多 → 建议降权或观察
|
||||
- 白名单:净盈且胜率较高、笔数足够 → 可优先考虑
|
||||
"""
|
||||
blacklist = []
|
||||
whitelist = []
|
||||
for row in by_symbol:
|
||||
sym = row.get("symbol", "")
|
||||
tc = int(row.get("trade_count") or 0)
|
||||
net_pnl = float(row.get("net_pnl") or 0)
|
||||
win_rate = float(row.get("win_rate_pct") or 0)
|
||||
if tc < 2:
|
||||
continue
|
||||
if net_pnl < 0:
|
||||
blacklist.append({
|
||||
"symbol": sym,
|
||||
"trade_count": tc,
|
||||
"net_pnl": round(net_pnl, 2),
|
||||
"win_rate_pct": round(win_rate, 1),
|
||||
"suggestion": "近期净亏且笔数较多,建议降权或观察后再开仓",
|
||||
})
|
||||
elif net_pnl > 0 and win_rate >= 50:
|
||||
whitelist.append({
|
||||
"symbol": sym,
|
||||
"trade_count": tc,
|
||||
"net_pnl": round(net_pnl, 2),
|
||||
"win_rate_pct": round(win_rate, 1),
|
||||
"suggestion": "近期净盈且胜率尚可,可优先考虑",
|
||||
})
|
||||
return {"blacklist": blacklist, "whitelist": whitelist}
|
||||
|
||||
|
||||
@router.get("/trade-stats")
|
||||
async def get_trade_stats(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
account_id: int = Depends(get_account_id),
|
||||
):
|
||||
"""获取交易统计:最近 N 天按交易对、按小时聚合(来自 trade_stats_daily / trade_stats_time_bucket)。
|
||||
返回原始 daily/by_hour、按交易对汇总 by_symbol、按小时汇总 hourly_agg、以及白名单/黑名单建议。"""
|
||||
try:
|
||||
daily = TradeStats.get_daily_stats(account_id=account_id, days=days)
|
||||
by_hour = TradeStats.get_hourly_stats(account_id=account_id, days=days)
|
||||
by_symbol = _aggregate_daily_by_symbol(daily)
|
||||
hourly_agg = _aggregate_hourly(by_hour)
|
||||
suggestions = _build_suggestions(by_symbol)
|
||||
return {
|
||||
"days": days,
|
||||
"daily": daily,
|
||||
"by_hour": by_hour,
|
||||
"by_symbol": by_symbol,
|
||||
"hourly_agg": hourly_agg,
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("get_trade_stats 失败")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/performance")
|
||||
async def get_performance_stats(
|
||||
days: int = Query(7, ge=1, le=365),
|
||||
account_id: int = Depends(get_account_id),
|
||||
):
|
||||
async def get_performance_stats(days: int = Query(7, ge=1, le=365)):
|
||||
"""获取性能统计"""
|
||||
try:
|
||||
# 账户快照
|
||||
snapshots = AccountSnapshot.get_recent(days, account_id=account_id)
|
||||
snapshots = AccountSnapshot.get_recent(days)
|
||||
|
||||
# 交易统计(时间范围 + limit 防内存暴增)
|
||||
start_ts = int((datetime.now() - timedelta(days=days)).timestamp())
|
||||
end_ts = int(datetime.now().timestamp())
|
||||
trades = Trade.get_all(
|
||||
start_timestamp=start_ts,
|
||||
end_timestamp=end_ts,
|
||||
account_id=account_id,
|
||||
time_filter="exit",
|
||||
limit=10000,
|
||||
)
|
||||
# 交易统计
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
trades = Trade.get_all(start_date=start_date)
|
||||
|
||||
return {
|
||||
"snapshots": snapshots,
|
||||
|
|
@ -267,32 +39,24 @@ async def get_performance_stats(
|
|||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_dashboard_data(account_id: int = Depends(get_account_id)):
|
||||
async def get_dashboard_data():
|
||||
"""获取仪表板数据"""
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"获取仪表板数据 - account_id={account_id}")
|
||||
logger.info("=" * 60)
|
||||
try:
|
||||
account_data = None
|
||||
account_error = None
|
||||
|
||||
# 优先请求币安实时余额;失败时(如 -1003 IP 封禁)再回退到数据库快照
|
||||
# 优先尝试获取实时账户数据
|
||||
try:
|
||||
from api.routes.account import get_realtime_account_data
|
||||
account_data = await get_realtime_account_data(account_id=account_id)
|
||||
if account_data and account_data.get('total_balance') is not None:
|
||||
logger.info("使用币安实时账户数据")
|
||||
else:
|
||||
account_data = None
|
||||
account_error = "实时余额返回为空"
|
||||
except Exception as live_err:
|
||||
account_error = str(live_err)
|
||||
logger.warning(f"获取实时账户数据失败 (account_id={account_id}),回退到数据库快照: {live_err}")
|
||||
|
||||
# 实时请求失败或无数据时,使用数据库快照
|
||||
if not account_data or account_data.get('total_balance') is None:
|
||||
account_data = await get_realtime_account_data()
|
||||
logger.info("成功获取实时账户数据")
|
||||
except HTTPException as e:
|
||||
# HTTPException 需要特殊处理,提取错误信息
|
||||
account_error = e.detail
|
||||
logger.warning(f"获取实时账户数据失败 (HTTP {e.status_code}): {account_error}")
|
||||
# 回退到数据库快照
|
||||
try:
|
||||
snapshots = AccountSnapshot.get_recent(1, account_id=account_id)
|
||||
snapshots = AccountSnapshot.get_recent(1)
|
||||
if snapshots:
|
||||
account_data = {
|
||||
"total_balance": snapshots[0].get('total_balance', 0),
|
||||
|
|
@ -303,68 +67,94 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)):
|
|||
}
|
||||
logger.info("使用数据库快照作为账户数据")
|
||||
else:
|
||||
if not account_data:
|
||||
account_data = {}
|
||||
account_data.setdefault("total_balance", 0)
|
||||
account_data.setdefault("available_balance", 0)
|
||||
account_data.setdefault("total_position_value", 0)
|
||||
account_data.setdefault("total_pnl", 0)
|
||||
account_data.setdefault("open_positions", 0)
|
||||
logger.warning("数据库中没有账户快照数据,仪表板显示 0;交易进程会定期写入快照")
|
||||
logger.warning("数据库中没有账户快照数据")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取账户快照失败: {db_error}")
|
||||
if not account_data:
|
||||
except Exception as e:
|
||||
account_error = str(e)
|
||||
logger.warning(f"获取实时账户数据失败: {account_error}", exc_info=True)
|
||||
# 回退到数据库快照
|
||||
try:
|
||||
snapshots = AccountSnapshot.get_recent(1)
|
||||
if snapshots:
|
||||
account_data = {
|
||||
"total_balance": 0,
|
||||
"available_balance": 0,
|
||||
"total_position_value": 0,
|
||||
"total_pnl": 0,
|
||||
"open_positions": 0
|
||||
"total_balance": snapshots[0].get('total_balance', 0),
|
||||
"available_balance": snapshots[0].get('available_balance', 0),
|
||||
"total_position_value": snapshots[0].get('total_position_value', 0),
|
||||
"total_pnl": snapshots[0].get('total_pnl', 0),
|
||||
"open_positions": snapshots[0].get('open_positions', 0)
|
||||
}
|
||||
|
||||
# 获取持仓数据:优先「币安实时持仓」(含本系统下的挂单),失败时回退到数据库列表
|
||||
logger.info("使用数据库快照作为账户数据")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取账户快照失败: {db_error}")
|
||||
|
||||
# 获取持仓数据(优先实时,回退到数据库)
|
||||
open_trades = []
|
||||
positions_error = None
|
||||
try:
|
||||
from api.routes.account import get_realtime_positions
|
||||
positions = await get_realtime_positions()
|
||||
# 转换为前端需要的格式
|
||||
open_trades = positions
|
||||
logger.info(f"成功获取实时持仓数据: {len(open_trades)} 个持仓")
|
||||
except HTTPException as e:
|
||||
positions_error = e.detail
|
||||
logger.warning(f"获取实时持仓失败 (HTTP {e.status_code}): {positions_error}")
|
||||
# 回退到数据库记录
|
||||
try:
|
||||
from api.routes.account import fetch_realtime_positions
|
||||
open_trades = await fetch_realtime_positions(account_id)
|
||||
except Exception as fetch_err:
|
||||
logger.warning(f"获取币安实时持仓失败,回退到数据库列表: {fetch_err}")
|
||||
db_trades = Trade.get_all(status='open')[:10]
|
||||
# 格式化数据库记录,添加 entry_value_usdt 字段
|
||||
open_trades = []
|
||||
if not open_trades:
|
||||
db_trades = Trade.get_all(status='open', account_id=account_id, limit=500)
|
||||
for trade in db_trades:
|
||||
entry_value_usdt = float(trade.get('quantity', 0)) * float(trade.get('entry_price', 0))
|
||||
leverage = float(trade.get('leverage', 1))
|
||||
pnl = float(trade.get('pnl', 0))
|
||||
|
||||
# 数据库中的pnl_percent是价格涨跌幅,需要转换为收益率
|
||||
# 收益率 = 盈亏 / 保证金
|
||||
margin = entry_value_usdt / leverage if leverage > 0 else entry_value_usdt
|
||||
pnl_percent = (pnl / margin * 100) if margin > 0 else 0
|
||||
open_trades.append({
|
||||
|
||||
formatted_trade = {
|
||||
**trade,
|
||||
'entry_value_usdt': entry_value_usdt,
|
||||
'mark_price': trade.get('entry_price', 0),
|
||||
'mark_price': trade.get('entry_price', 0), # 数据库中没有标记价,使用入场价
|
||||
'pnl': pnl,
|
||||
'pnl_percent': pnl_percent
|
||||
})
|
||||
try:
|
||||
from api.routes.account import fetch_live_positions_pnl
|
||||
live_list = await fetch_live_positions_pnl(account_id)
|
||||
by_symbol = {p["symbol"]: p for p in live_list}
|
||||
for t in open_trades:
|
||||
sym = t.get("symbol")
|
||||
if sym and sym in by_symbol:
|
||||
lp = by_symbol[sym]
|
||||
t["mark_price"] = lp.get("mark_price", t.get("entry_price"))
|
||||
t["pnl"] = lp.get("pnl", 0)
|
||||
t["pnl_percent"] = lp.get("pnl_percent", 0)
|
||||
except Exception as merge_err:
|
||||
logger.debug(f"合并实时持仓盈亏失败: {merge_err}")
|
||||
'pnl_percent': pnl_percent # 使用重新计算的收益率
|
||||
}
|
||||
open_trades.append(formatted_trade)
|
||||
logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓")
|
||||
else:
|
||||
logger.info(f"使用币安实时持仓作为列表: {len(open_trades)} 个持仓")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取持仓记录失败: {db_error}")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取持仓记录失败: {db_error}")
|
||||
except Exception as e:
|
||||
positions_error = str(e)
|
||||
logger.warning(f"获取实时持仓失败: {positions_error}", exc_info=True)
|
||||
# 回退到数据库记录
|
||||
try:
|
||||
db_trades = Trade.get_all(status='open')[:10]
|
||||
# 格式化数据库记录,添加 entry_value_usdt 字段
|
||||
open_trades = []
|
||||
for trade in db_trades:
|
||||
entry_value_usdt = float(trade.get('quantity', 0)) * float(trade.get('entry_price', 0))
|
||||
leverage = float(trade.get('leverage', 1))
|
||||
pnl = float(trade.get('pnl', 0))
|
||||
|
||||
# 数据库中的pnl_percent是价格涨跌幅,需要转换为收益率
|
||||
# 收益率 = 盈亏 / 保证金
|
||||
margin = entry_value_usdt / leverage if leverage > 0 else entry_value_usdt
|
||||
pnl_percent = (pnl / margin * 100) if margin > 0 else 0
|
||||
|
||||
formatted_trade = {
|
||||
**trade,
|
||||
'entry_value_usdt': entry_value_usdt,
|
||||
'mark_price': trade.get('entry_price', 0), # 数据库中没有标记价,使用入场价
|
||||
'pnl': pnl,
|
||||
'pnl_percent': pnl_percent # 使用重新计算的收益率
|
||||
}
|
||||
open_trades.append(formatted_trade)
|
||||
logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓")
|
||||
except Exception as db_error:
|
||||
logger.error(f"从数据库获取持仓记录失败: {db_error}")
|
||||
|
||||
# 最近的扫描记录
|
||||
recent_scans = []
|
||||
|
|
@ -386,7 +176,7 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)):
|
|||
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, account_id=account_id))
|
||||
max_total_position_percent = float(TradingConfig.get_value('MAX_TOTAL_POSITION_PERCENT', 0.30))
|
||||
|
||||
# 名义仓位(notional)与保证金占用(margin)是两个口径:
|
||||
# - 名义仓位可以 > 100%(高杠杆下非常正常)
|
||||
|
|
@ -447,7 +237,7 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)):
|
|||
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, account_id=account_id)
|
||||
config = TradingConfig.get(key)
|
||||
if config:
|
||||
trading_config[key] = {
|
||||
'value': TradingConfig._convert_value(config['config_value'], config['config_type']),
|
||||
|
|
@ -456,21 +246,13 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)):
|
|||
except Exception as e:
|
||||
logger.debug(f"获取交易配置失败: {e}")
|
||||
|
||||
# 本系统持仓数 = 数据库 status=open 条数,与下方「当前持仓」列表一致;币安持仓数 = 接口/快照中的 open_positions,可能与币安页面一致
|
||||
open_trades_count = len(open_trades)
|
||||
result = {
|
||||
"account": account_data,
|
||||
"open_trades": open_trades,
|
||||
"open_trades_count": open_trades_count, # 本系统持仓数,与列表条数一致
|
||||
"recent_scans": recent_scans,
|
||||
"recent_signals": recent_signals,
|
||||
"position_stats": position_stats,
|
||||
"trading_config": trading_config, # 添加交易配置
|
||||
"_debug": { # 添加调试信息
|
||||
"account_id": account_id,
|
||||
"account_data_total_balance": account_data.get('total_balance', 'N/A') if account_data else 'N/A',
|
||||
"open_trades_count": open_trades_count,
|
||||
}
|
||||
"trading_config": trading_config # 添加交易配置
|
||||
}
|
||||
|
||||
# 如果有错误,在响应中包含错误信息(但不影响返回)
|
||||
|
|
@ -481,14 +263,6 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)):
|
|||
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, Depends, BackgroundTasks
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
|
||||
|
|
@ -15,10 +15,6 @@ 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")
|
||||
|
||||
# 后端服务启动时间(用于前端展示“运行多久/是否已重启”)
|
||||
|
|
@ -179,11 +175,13 @@ def _beijing_time_str() -> str:
|
|||
|
||||
@router.post("/logs/test-write")
|
||||
async def logs_test_write(
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
|
||||
) -> 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 不可用,无法写入测试日志")
|
||||
|
|
@ -240,35 +238,6 @@ async def logs_test_write(
|
|||
}
|
||||
|
||||
|
||||
@router.post("/trading/trigger-scan")
|
||||
async def trigger_scan(
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
触发手动扫描:
|
||||
通过设置 Redis 信号(ats:trigger-scan),通知所有运行中的 strategy 进程立即执行扫描。
|
||||
"""
|
||||
client = _get_redis_client_for_logs()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="Redis 不可用,无法触发扫描")
|
||||
|
||||
try:
|
||||
# 设置触发信号(当前时间戳),让 strategy 检测到变化
|
||||
import time
|
||||
ts = int(time.time())
|
||||
# 使用 setex 设置 600秒过期,防止永久残留(虽然 strategy 只关心变化,但过期是个好习惯)
|
||||
# 注意:strategy 端比较的是时间戳大小,所以只要比上一次大即可
|
||||
client.setex("ats:trigger-scan", 600, str(ts))
|
||||
|
||||
return {
|
||||
"message": "已发送扫描触发信号",
|
||||
"timestamp": ts
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"触发扫描失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"触发扫描失败: {e}")
|
||||
|
||||
|
||||
def _get_redis_client_for_logs():
|
||||
"""
|
||||
获取 Redis 客户端(优先复用 config_manager 的连接;失败则自行创建)。
|
||||
|
|
@ -342,7 +311,7 @@ async def get_logs(
|
|||
start: int = 0,
|
||||
service: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
从 Redis List 读取最新日志(默认 group=error -> ats:logs:error)。
|
||||
|
|
@ -353,6 +322,8 @@ 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:
|
||||
|
|
@ -361,12 +332,6 @@ 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)})")
|
||||
|
|
@ -423,12 +388,6 @@ 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
|
||||
|
|
@ -455,7 +414,8 @@ async def get_logs(
|
|||
|
||||
|
||||
@router.get("/logs/overview")
|
||||
async def logs_overview(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
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)
|
||||
|
||||
client = _get_redis_client_for_logs()
|
||||
if client is None:
|
||||
|
|
@ -512,8 +472,10 @@ async def logs_overview(_admin: Dict[str, Any] = Depends(require_system_admin))
|
|||
@router.put("/logs/config")
|
||||
async def update_logs_config(
|
||||
payload: LogsConfigUpdate,
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
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)
|
||||
|
||||
client = _get_redis_client_for_logs()
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="Redis 不可用,无法更新日志配置")
|
||||
|
|
@ -563,10 +525,6 @@ 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()
|
||||
|
|
@ -609,12 +567,7 @@ 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()
|
||||
# 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:
|
||||
if res.returncode != 0:
|
||||
raise RuntimeError(combined or f"supervisorctl failed (exit={res.returncode})")
|
||||
return combined or out
|
||||
|
||||
|
|
@ -634,20 +587,6 @@ 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]
|
||||
|
|
@ -737,68 +676,17 @@ def _action_with_fallback(action: str, program: str) -> Tuple[str, Optional[str]
|
|||
return out, resolved, status_all
|
||||
|
||||
|
||||
|
||||
def _run_fix_script():
|
||||
"""Run the fix_trade_records.py script in a subprocess"""
|
||||
try:
|
||||
script_path = Path(__file__).parent.parent.parent.parent / "scripts" / "fix_trade_records.py"
|
||||
if not script_path.exists():
|
||||
logger.error(f"Fix script not found at {script_path}")
|
||||
return
|
||||
|
||||
logger.info(f"Starting trade record fix script: {script_path}")
|
||||
|
||||
# Ensure project root is in PYTHONPATH
|
||||
env = os.environ.copy()
|
||||
project_root = Path(__file__).parent.parent.parent.parent
|
||||
env["PYTHONPATH"] = f"{env.get('PYTHONPATH', '')}:{project_root}"
|
||||
|
||||
process = subprocess.Popen(
|
||||
["python3", str(script_path)],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
logger.info(f"Trade record fix completed successfully:\n{stdout}")
|
||||
else:
|
||||
logger.error(f"Trade record fix failed (exit code {process.returncode}):\n{stderr}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running trade record fix script: {e}")
|
||||
|
||||
@router.post("/fix-trade-records")
|
||||
async def fix_trade_records(
|
||||
background_tasks: BackgroundTasks,
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin)
|
||||
):
|
||||
"""
|
||||
Trigger the trade record fix script (time inversion & commission backfill).
|
||||
Runs in background.
|
||||
"""
|
||||
background_tasks.add_task(_run_fix_script)
|
||||
return {"message": "Trade fix task started in background"}
|
||||
|
||||
@router.post("/clear-cache")
|
||||
async def clear_cache(
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
x_account_id: Optional[int] = Header(default=None, alias="X-Account-Id"),
|
||||
) -> Dict[str, Any]:
|
||||
async def clear_cache(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
"""
|
||||
清理配置缓存(Redis Hash: trading_config),并从数据库回灌到 Redis。
|
||||
"""
|
||||
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
|
||||
|
||||
try:
|
||||
import config_manager
|
||||
|
||||
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)
|
||||
cm = getattr(config_manager, "config_manager", None)
|
||||
if cm is None:
|
||||
raise HTTPException(status_code=500, detail="config_manager 未初始化")
|
||||
|
||||
|
|
@ -822,16 +710,10 @@ async def clear_cache(
|
|||
|
||||
if redis_client is not None and redis_connected:
|
||||
try:
|
||||
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))
|
||||
redis_client.delete("trading_config")
|
||||
deleted_keys.append("trading_config")
|
||||
except Exception as e:
|
||||
logger.warning(f"删除 Redis key 失败: {e}")
|
||||
logger.warning(f"删除 Redis key trading_config 失败: {e}")
|
||||
|
||||
# 可选:实时推荐缓存(如果存在)
|
||||
try:
|
||||
|
|
@ -860,90 +742,9 @@ async def clear_cache(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/trading/services")
|
||||
async def list_trading_services(_admin: Dict[str, Any] = Depends(require_system_admin)):
|
||||
"""获取所有交易服务状态(包括所有账号)"""
|
||||
try:
|
||||
# 获取所有 supervisor 进程状态
|
||||
status_all = _run_supervisorctl(["status"])
|
||||
|
||||
services = []
|
||||
summary = {"total": 0, "running": 0, "stopped": 0, "unknown": 0}
|
||||
|
||||
# 解析每一行
|
||||
# 格式通常是: name state description
|
||||
for line in status_all.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split(None, 2)
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
name = parts[0]
|
||||
state = parts[1]
|
||||
desc = parts[2] if len(parts) > 2 else ""
|
||||
|
||||
# 只关注 auto_sys 开头的服务
|
||||
if name.startswith("auto_sys"):
|
||||
is_running = state == "RUNNING"
|
||||
pid = None
|
||||
if is_running:
|
||||
# Parse PID from desc: "pid 1234, uptime ..."
|
||||
m = re.search(r"pid\s+(\d+)", desc)
|
||||
if m:
|
||||
pid = int(m.group(1))
|
||||
|
||||
services.append({
|
||||
"program": name,
|
||||
"state": state,
|
||||
"running": is_running,
|
||||
"pid": pid,
|
||||
"description": desc
|
||||
})
|
||||
|
||||
summary["total"] += 1
|
||||
if is_running:
|
||||
summary["running"] += 1
|
||||
elif state in ["STOPPED", "EXITED", "FATAL"]:
|
||||
summary["stopped"] += 1
|
||||
else:
|
||||
summary["unknown"] += 1
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"services": services,
|
||||
"raw": status_all
|
||||
}
|
||||
except Exception as e:
|
||||
# supervisor 未安装/未运行时(如 unix socket 不存在)避免刷 ERROR,改为 WARNING 并返回友好说明
|
||||
err_msg = str(e).strip()
|
||||
if not err_msg:
|
||||
err_msg = repr(e)
|
||||
is_supervisor_unavailable = (
|
||||
"no such file" in err_msg.lower()
|
||||
or "connection refused" in err_msg.lower()
|
||||
or "sock" in err_msg.lower()
|
||||
or "unix://" in err_msg.lower()
|
||||
)
|
||||
if is_supervisor_unavailable:
|
||||
logger.warning(f"列出服务失败(supervisor 未运行或不可用): {err_msg}")
|
||||
return {
|
||||
"summary": {"total": 0, "running": 0, "stopped": 0, "unknown": 0},
|
||||
"services": [],
|
||||
"error": "supervisor 未安装或未运行,请检查 supervisord 或配置 SUPERVISOR_CONF"
|
||||
}
|
||||
logger.error(f"列出服务失败: {e}")
|
||||
return {
|
||||
"summary": {"total": 0, "running": 0, "stopped": 0, "unknown": 0},
|
||||
"services": [],
|
||||
"error": err_msg
|
||||
}
|
||||
|
||||
|
||||
@router.get("/trading/status")
|
||||
async def trading_status(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
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)
|
||||
|
||||
program = _get_program_name()
|
||||
try:
|
||||
|
|
@ -969,7 +770,8 @@ async def trading_status(_admin: Dict[str, Any] = Depends(require_system_admin))
|
|||
|
||||
|
||||
@router.post("/trading/start")
|
||||
async def trading_start(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
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)
|
||||
|
||||
program = _get_program_name()
|
||||
try:
|
||||
|
|
@ -995,7 +797,8 @@ async def trading_start(_admin: Dict[str, Any] = Depends(require_system_admin))
|
|||
|
||||
|
||||
@router.post("/trading/stop")
|
||||
async def trading_stop(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
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)
|
||||
|
||||
program = _get_program_name()
|
||||
try:
|
||||
|
|
@ -1021,7 +824,8 @@ async def trading_stop(_admin: Dict[str, Any] = Depends(require_system_admin)) -
|
|||
|
||||
|
||||
@router.post("/trading/restart")
|
||||
async def trading_restart(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
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)
|
||||
|
||||
program = _get_program_name()
|
||||
try:
|
||||
|
|
@ -1063,274 +867,8 @@ async def trading_restart(_admin: Dict[str, Any] = Depends(require_system_admin)
|
|||
raise HTTPException(status_code=500, detail=f"supervisorctl restart 失败: {e}")
|
||||
|
||||
|
||||
@router.post("/trading/stop-all")
|
||||
async def trading_stop_all(
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
prefix: str = "auto_sys_acc",
|
||||
include_default: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
一键停止所有账号交易进程(supervisor)。
|
||||
"""
|
||||
try:
|
||||
prefix = (prefix or "auto_sys_acc").strip()
|
||||
if not prefix:
|
||||
prefix = "auto_sys_acc"
|
||||
|
||||
# 先读取全量 status,拿到有哪些进程
|
||||
status_all = _run_supervisorctl(["status"])
|
||||
names = _list_supervisor_process_names(status_all)
|
||||
|
||||
targets: list[str] = []
|
||||
for n in names:
|
||||
if n.startswith(prefix):
|
||||
targets.append(n)
|
||||
|
||||
if include_default:
|
||||
default_prog = _get_program_name()
|
||||
if default_prog and default_prog not in targets and default_prog in names:
|
||||
targets.append(default_prog)
|
||||
|
||||
if not targets:
|
||||
return {
|
||||
"message": "未找到可停止的交易进程",
|
||||
"prefix": prefix,
|
||||
"include_default": include_default,
|
||||
"count": 0,
|
||||
"targets": [],
|
||||
"status_all": status_all,
|
||||
}
|
||||
|
||||
results: list[Dict[str, Any]] = []
|
||||
ok = 0
|
||||
failed = 0
|
||||
for prog in targets:
|
||||
try:
|
||||
out = _run_supervisorctl(["stop", prog])
|
||||
raw = _run_supervisorctl(["status", prog])
|
||||
running, pid, state = _parse_supervisor_status(raw)
|
||||
results.append(
|
||||
{
|
||||
"program": prog,
|
||||
"ok": True,
|
||||
"output": out,
|
||||
"status": {"running": running, "pid": pid, "state": state, "raw": raw},
|
||||
}
|
||||
)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
results.append({"program": prog, "ok": False, "error": str(e)})
|
||||
|
||||
return {
|
||||
"message": "已发起批量停止",
|
||||
"prefix": prefix,
|
||||
"include_default": include_default,
|
||||
"count": len(targets),
|
||||
"ok": ok,
|
||||
"failed": failed,
|
||||
"targets": targets,
|
||||
"results": results,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"批量停止失败: {e}")
|
||||
|
||||
|
||||
@router.post("/trading/restart-all")
|
||||
async def trading_restart_all(
|
||||
_admin: Dict[str, Any] = Depends(require_system_admin),
|
||||
prefix: str = "auto_sys_acc",
|
||||
include_default: bool = False,
|
||||
do_update: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
一键重启所有账号交易进程(supervisor)。
|
||||
|
||||
- 默认重启所有以 auto_sys_acc 开头的 program(例如 auto_sys_acc1/2/3...)
|
||||
- 可选 include_default=true:同时包含 SUPERVISOR_TRADING_PROGRAM(默认 auto_sys)
|
||||
- 可选 do_update=true:先执行 supervisorctl reread/update 再重启(确保新 ini 生效)
|
||||
"""
|
||||
try:
|
||||
prefix = (prefix or "auto_sys_acc").strip()
|
||||
if not prefix:
|
||||
prefix = "auto_sys_acc"
|
||||
|
||||
# 先读取全量 status,拿到有哪些进程
|
||||
status_all = _run_supervisorctl(["status"])
|
||||
names = _list_supervisor_process_names(status_all)
|
||||
|
||||
targets: list[str] = []
|
||||
skipped_disabled: list[Dict[str, Any]] = []
|
||||
for n in names:
|
||||
if n.startswith(prefix):
|
||||
# 若能解析出 account_id,则跳过 disabled 的账号
|
||||
try:
|
||||
m = re.match(rf"^{re.escape(prefix)}(\d+)$", n)
|
||||
if m:
|
||||
aid = int(m.group(1))
|
||||
row = Account.get(aid)
|
||||
st = (row.get("status") if isinstance(row, dict) else None) or "active"
|
||||
if str(st).strip().lower() != "active":
|
||||
skipped_disabled.append({"program": n, "account_id": aid, "status": st})
|
||||
continue
|
||||
except Exception:
|
||||
# 解析失败/查库失败:不影响批量重启流程
|
||||
pass
|
||||
targets.append(n)
|
||||
|
||||
if include_default:
|
||||
default_prog = _get_program_name()
|
||||
if default_prog and default_prog not in targets and default_prog in names:
|
||||
targets.append(default_prog)
|
||||
|
||||
if not targets:
|
||||
return {
|
||||
"message": "未找到可重启的交易进程",
|
||||
"prefix": prefix,
|
||||
"include_default": include_default,
|
||||
"count": 0,
|
||||
"targets": [],
|
||||
"status_all": status_all,
|
||||
"skipped_disabled": skipped_disabled,
|
||||
}
|
||||
|
||||
reread_out = ""
|
||||
update_out = ""
|
||||
if do_update:
|
||||
try:
|
||||
reread_out = _run_supervisorctl(["reread"])
|
||||
except Exception as e:
|
||||
reread_out = f"failed: {e}"
|
||||
try:
|
||||
update_out = _run_supervisorctl(["update"])
|
||||
except Exception as e:
|
||||
update_out = f"failed: {e}"
|
||||
|
||||
results: list[Dict[str, Any]] = []
|
||||
ok = 0
|
||||
failed = 0
|
||||
for prog in targets:
|
||||
try:
|
||||
out = _run_supervisorctl(["restart", prog])
|
||||
raw = _run_supervisorctl(["status", prog])
|
||||
running, pid, state = _parse_supervisor_status(raw)
|
||||
results.append(
|
||||
{
|
||||
"program": prog,
|
||||
"ok": True,
|
||||
"output": out,
|
||||
"status": {"running": running, "pid": pid, "state": state, "raw": raw},
|
||||
}
|
||||
)
|
||||
ok += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
results.append({"program": prog, "ok": False, "error": str(e)})
|
||||
|
||||
return {
|
||||
"message": "已发起批量重启",
|
||||
"prefix": prefix,
|
||||
"include_default": include_default,
|
||||
"do_update": do_update,
|
||||
"count": len(targets),
|
||||
"ok": ok,
|
||||
"failed": failed,
|
||||
"reread": reread_out,
|
||||
"update": update_out,
|
||||
"targets": targets,
|
||||
"results": results,
|
||||
"skipped_disabled": skipped_disabled,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"批量重启失败: {e}")
|
||||
|
||||
|
||||
@router.get("/market-overview")
|
||||
async def market_overview(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""
|
||||
市场行情概览:拉取 Binance 公开接口,展示与策略过滤对应的数据。
|
||||
供全局配置页展示,帮助用户确认当前策略方案是否匹配市场。
|
||||
"""
|
||||
try:
|
||||
from market_overview import get_market_overview
|
||||
except ImportError:
|
||||
try:
|
||||
from backend.market_overview import get_market_overview
|
||||
except ImportError:
|
||||
import sys
|
||||
backend_dir = Path(__file__).parent.parent.parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
from market_overview import get_market_overview
|
||||
|
||||
data = get_market_overview()
|
||||
|
||||
# 获取当前策略配置,用于对比
|
||||
beta_enabled = False
|
||||
beta_threshold = -0.005
|
||||
market_scheme = "normal"
|
||||
try:
|
||||
from config_manager import GlobalStrategyConfigManager
|
||||
mgr = GlobalStrategyConfigManager()
|
||||
beta_enabled = str(mgr.get("BETA_FILTER_ENABLED", "true")).lower() in ("true", "1", "yes")
|
||||
try:
|
||||
beta_threshold = float(mgr.get("BETA_FILTER_THRESHOLD", -0.005))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
market_scheme = str(mgr.get("MARKET_SCHEME", "normal")).strip().lower() or "normal"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 计算大盘共振是否触发(与 strategy._check_beta_filter 一致)
|
||||
threshold_pct = beta_threshold * 100
|
||||
triggered = False
|
||||
if beta_enabled:
|
||||
for key in ["btc_15m_change_pct", "btc_1h_change_pct", "eth_15m_change_pct", "eth_1h_change_pct"]:
|
||||
val = data.get(key)
|
||||
if val is not None and val < threshold_pct:
|
||||
triggered = True
|
||||
break
|
||||
|
||||
data["config"] = {
|
||||
"BETA_FILTER_ENABLED": beta_enabled,
|
||||
"BETA_FILTER_THRESHOLD": beta_threshold,
|
||||
"BETA_FILTER_THRESHOLD_PCT": round(threshold_pct, 2),
|
||||
"MARKET_SCHEME": market_scheme,
|
||||
}
|
||||
data["beta_filter_triggered"] = triggered
|
||||
|
||||
# 策略执行概览:当前执行方案与配置项执行情况(易读文字)
|
||||
get_strategy_execution_overview = None
|
||||
try:
|
||||
from market_overview import get_strategy_execution_overview
|
||||
except ImportError:
|
||||
try:
|
||||
from backend.market_overview import get_strategy_execution_overview
|
||||
except ImportError:
|
||||
pass
|
||||
if get_strategy_execution_overview is None:
|
||||
try:
|
||||
import sys
|
||||
backend_dir = Path(__file__).resolve().parent.parent.parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
from market_overview import get_strategy_execution_overview
|
||||
except ImportError:
|
||||
pass
|
||||
if get_strategy_execution_overview is not None:
|
||||
try:
|
||||
data["strategy_execution_overview"] = get_strategy_execution_overview()
|
||||
except Exception as e:
|
||||
data["strategy_execution_overview"] = {"sections": [{"title": "加载失败", "content": str(e)}]}
|
||||
else:
|
||||
data["strategy_execution_overview"] = {
|
||||
"sections": [{"title": "策略执行概览暂不可用", "content": "请确认后端已重启并已部署最新代码;若已重启仍无数据,请检查 backend/market_overview.py 与 config_manager 是否可正常导入。"}]
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/backend/status")
|
||||
async def backend_status(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
async def backend_status(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
"""
|
||||
查看后端服务状态(当前 uvicorn 进程)。
|
||||
|
||||
|
|
@ -1338,6 +876,7 @@ async def backend_status(_admin: Dict[str, Any] = Depends(require_system_admin))
|
|||
- 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,
|
||||
|
|
@ -1349,7 +888,7 @@ async def backend_status(_admin: Dict[str, Any] = Depends(require_system_admin))
|
|||
|
||||
|
||||
@router.post("/backend/restart")
|
||||
async def backend_restart(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
async def backend_restart(x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token")) -> Dict[str, Any]:
|
||||
"""
|
||||
重启后端服务(uvicorn)。
|
||||
|
||||
|
|
@ -1362,6 +901,8 @@ async def backend_restart(_admin: Dict[str, Any] = Depends(require_system_admin)
|
|||
注意:
|
||||
- 为了让接口能先返回,这里会延迟 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():
|
||||
|
|
@ -1403,159 +944,3 @@ async def backend_restart(_admin: Dict[str, Any] = Depends(require_system_admin)
|
|||
"note": "重启期间接口可能短暂不可用,页面可等待 3-5 秒后刷新状态。",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/backend/stop")
|
||||
async def backend_stop(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""
|
||||
停止后端服务(uvicorn)。
|
||||
警告:停止后 API 将不可用,必须手动登录服务器启动!
|
||||
"""
|
||||
backend_dir = Path(__file__).parent.parent.parent # backend/
|
||||
stop_script = backend_dir / "stop.sh"
|
||||
if not stop_script.exists():
|
||||
raise HTTPException(status_code=500, detail=f"找不到停止脚本: {stop_script}")
|
||||
|
||||
cur_pid = os.getpid()
|
||||
|
||||
# 后台执行:sleep 1 后再停止,保证当前请求可以返回
|
||||
cmd = ["bash", "-lc", f"sleep 1; '{stop_script}'"]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(backend_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"启动停止脚本失败: {e}")
|
||||
|
||||
return {
|
||||
"message": "已发起后端停止(1s 后执行)",
|
||||
"pid_before": cur_pid,
|
||||
"script": str(stop_script),
|
||||
"warning": "后端服务停止后,Web 界面将无法访问,请手动在服务器启动!",
|
||||
}
|
||||
|
||||
|
||||
def _recommendations_process_running() -> Tuple[bool, Optional[int]]:
|
||||
"""检查推荐服务进程是否运行,返回 (running, pid)。兼容 pgrep/ps 及 supervisor 等启动方式。"""
|
||||
# 1. 优先 pgrep(Linux/macOS 常见)
|
||||
for pattern in ["trading_system.recommendations_main", "recommendations_main1", "recommendations_main"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", pattern],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
pids = [x for x in result.stdout.strip().split() if x.isdigit()]
|
||||
if pids:
|
||||
return True, int(pids[0])
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
|
||||
break
|
||||
|
||||
# 2. 回退:ps + grep(pgrep 不可用或匹配失败时)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sh", "-c", "ps aux 2>/dev/null | grep -E 'recommendations_main1|recommendations_main|trading_system.recommendations' | grep -v grep | head -1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
parts = result.stdout.strip().split()
|
||||
if len(parts) >= 2 and parts[1].isdigit():
|
||||
return True, int(parts[1])
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
|
||||
pass
|
||||
return False, None
|
||||
|
||||
|
||||
@router.get("/recommendations/status")
|
||||
async def recommendations_status(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""查看推荐服务状态(recommendations_main 进程)"""
|
||||
running, pid = _recommendations_process_running()
|
||||
return {
|
||||
"running": running,
|
||||
"pid": pid,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recommendations/restart")
|
||||
async def recommendations_restart(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""重启推荐服务(recommendations_main)"""
|
||||
backend_dir = Path(__file__).parent.parent.parent
|
||||
restart_script = backend_dir / "restart_recommendations.sh"
|
||||
if not restart_script.exists():
|
||||
raise HTTPException(status_code=500, detail=f"找不到重启脚本: {restart_script}")
|
||||
running_before, pid_before = _recommendations_process_running()
|
||||
cmd = ["bash", "-lc", f"sleep 1; '{restart_script}'"]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(backend_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"启动重启脚本失败: {e}")
|
||||
return {
|
||||
"message": "已发起推荐服务重启(1s 后执行)",
|
||||
"pid_before": pid_before,
|
||||
"running_before": running_before,
|
||||
"script": str(restart_script),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recommendations/stop")
|
||||
async def recommendations_stop(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""停止推荐服务"""
|
||||
backend_dir = Path(__file__).parent.parent.parent
|
||||
stop_script = backend_dir / "stop_recommendations.sh"
|
||||
if not stop_script.exists():
|
||||
raise HTTPException(status_code=500, detail=f"找不到停止脚本: {stop_script}")
|
||||
running_before, pid_before = _recommendations_process_running()
|
||||
cmd = ["bash", "-lc", f"sleep 1; '{stop_script}'"]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(backend_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"启动停止脚本失败: {e}")
|
||||
return {
|
||||
"message": "已发起推荐服务停止",
|
||||
"pid_before": pid_before,
|
||||
"running_before": running_before,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recommendations/start")
|
||||
async def recommendations_start(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]:
|
||||
"""启动推荐服务(若已运行则跳过)"""
|
||||
running, pid = _recommendations_process_running()
|
||||
if running:
|
||||
return {"message": "推荐服务已在运行中", "pid": pid, "skipped": True}
|
||||
backend_dir = Path(__file__).parent.parent.parent
|
||||
start_script = backend_dir / "start_recommendations.sh"
|
||||
if not start_script.exists():
|
||||
raise HTTPException(status_code=500, detail=f"找不到启动脚本: {start_script}")
|
||||
cmd = ["bash", "-lc", f"sleep 1; '{start_script}'"]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(backend_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"启动脚本执行失败: {e}")
|
||||
return {"message": "已发起推荐服务启动(1s 后执行)", "script": str(start_script)}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,568 +0,0 @@
|
|||
"""
|
||||
Supervisor 多账号托管(宝塔插件兼容)
|
||||
|
||||
目标:
|
||||
- 根据 account_id 自动生成一个 supervisor program 配置文件(.ini)
|
||||
- 自动定位 supervisord.conf 的 include 目录(尽量不要求你手填路径)
|
||||
- 提供 supervisorctl 的常用调用封装(reread/update/status/start/stop/restart)
|
||||
|
||||
重要说明:
|
||||
- 本模块只写入“程序配置文件”,不包含任何 API Key/Secret
|
||||
- trading_system 进程通过 ATS_ACCOUNT_ID 选择自己的账号配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
|
||||
|
||||
DEFAULT_CANDIDATE_CONFS = [
|
||||
"/www/server/panel/plugin/supervisor/supervisord.conf",
|
||||
"/www/server/panel/plugin/supervisor/supervisor.conf",
|
||||
"/etc/supervisor/supervisord.conf",
|
||||
"/etc/supervisord.conf",
|
||||
]
|
||||
|
||||
# 常见 supervisorctl 路径候选
|
||||
DEFAULT_SUPERVISORCTL_CANDIDATES = [
|
||||
"/www/server/panel/pyenv/bin/supervisorctl",
|
||||
"/usr/bin/supervisorctl",
|
||||
"/usr/local/bin/supervisorctl",
|
||||
"/usr/local/python/bin/supervisorctl",
|
||||
]
|
||||
|
||||
def _detect_supervisorctl_path() -> str:
|
||||
"""
|
||||
探测 supervisorctl 可执行文件路径
|
||||
"""
|
||||
env_path = (os.getenv("SUPERVISORCTL_PATH") or "").strip()
|
||||
if env_path:
|
||||
return env_path
|
||||
|
||||
# 优先检查 PATH 中的 supervisorctl
|
||||
import shutil
|
||||
if shutil.which("supervisorctl"):
|
||||
return "supervisorctl"
|
||||
|
||||
# 检查常见绝对路径
|
||||
for p in DEFAULT_SUPERVISORCTL_CANDIDATES:
|
||||
try:
|
||||
if os.path.exists(p) and os.access(p, os.X_OK):
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return "supervisorctl" # 兜底
|
||||
|
||||
|
||||
# 常见 supervisord 主日志路径候选(不同发行版/面板插件差异很大)
|
||||
DEFAULT_SUPERVISORD_LOG_CANDIDATES = [
|
||||
# aaPanel / 宝塔 supervisor 插件常见
|
||||
"/www/server/panel/plugin/supervisor/log/supervisord.log",
|
||||
"/www/server/panel/plugin/supervisor/log/supervisor.log",
|
||||
"/www/server/panel/plugin/supervisor/supervisord.log",
|
||||
"/www/server/panel/plugin/supervisor/supervisor.log",
|
||||
# 系统 supervisor 常见
|
||||
"/var/log/supervisor/supervisord.log",
|
||||
"/var/log/supervisor/supervisor.log",
|
||||
"/var/log/supervisord.log",
|
||||
"/var/log/supervisord/supervisord.log",
|
||||
"/tmp/supervisord.log",
|
||||
]
|
||||
|
||||
|
||||
def _get_project_root() -> Path:
|
||||
# backend/api/supervisor_account.py -> api -> backend -> project_root
|
||||
# 期望得到:<project_root>(例如 /www/wwwroot/autosys_new)
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _detect_supervisor_conf_path() -> Optional[Path]:
|
||||
p = (os.getenv("SUPERVISOR_CONF") or "").strip()
|
||||
if p:
|
||||
pp = Path(p)
|
||||
return pp if pp.exists() else pp # 允许不存在时也返回,便于报错信息
|
||||
for cand in DEFAULT_CANDIDATE_CONFS:
|
||||
try:
|
||||
cp = Path(cand)
|
||||
if cp.exists():
|
||||
return cp
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_include_dir_from_conf(conf_path: Path) -> Optional[Path]:
|
||||
"""
|
||||
尝试解析 supervisord.conf 的 [include] files=... 目录。
|
||||
常见格式:
|
||||
[include]
|
||||
files = /path/to/conf.d/*.ini
|
||||
"""
|
||||
try:
|
||||
text = conf_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
in_include = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith(";") or line.startswith("#"):
|
||||
continue
|
||||
if re.match(r"^\[include\]\s*$", line, flags=re.I):
|
||||
in_include = True
|
||||
continue
|
||||
if in_include and line.startswith("[") and line.endswith("]"):
|
||||
break
|
||||
if not in_include:
|
||||
continue
|
||||
m = re.match(r"^files\s*=\s*(.+)$", line, flags=re.I)
|
||||
if not m:
|
||||
continue
|
||||
val = (m.group(1) or "").strip().strip('"').strip("'")
|
||||
if not val:
|
||||
continue
|
||||
# 只取第一个 pattern(即使写了多个用空格分隔)
|
||||
first = val.split()[0]
|
||||
p = Path(first)
|
||||
if not p.is_absolute():
|
||||
p = (conf_path.parent / p).resolve()
|
||||
return p.parent
|
||||
return None
|
||||
|
||||
|
||||
def get_supervisor_program_dir() -> Path:
|
||||
"""
|
||||
获取 supervisor program 配置目录(优先级):
|
||||
1) SUPERVISOR_PROGRAM_DIR
|
||||
2) 从 supervisord.conf 的 [include] files= 解析
|
||||
3) 兜底:/www/server/panel/plugin/supervisor(你当前看到的目录)
|
||||
"""
|
||||
env_dir = (os.getenv("SUPERVISOR_PROGRAM_DIR") or "").strip()
|
||||
if env_dir:
|
||||
return Path(env_dir)
|
||||
|
||||
conf = _detect_supervisor_conf_path()
|
||||
if conf and conf.exists():
|
||||
inc = _parse_include_dir_from_conf(conf)
|
||||
if inc:
|
||||
return inc
|
||||
|
||||
return Path("/www/server/panel/plugin/supervisor")
|
||||
|
||||
|
||||
def program_name_for_account(account_id: int) -> str:
|
||||
tmpl = (os.getenv("SUPERVISOR_TRADING_PROGRAM_TEMPLATE") or "auto_sys_acc{account_id}").strip()
|
||||
try:
|
||||
return tmpl.format(account_id=int(account_id))
|
||||
except Exception:
|
||||
return f"auto_sys_acc{int(account_id)}"
|
||||
|
||||
|
||||
def ini_filename_for_program(program_name: str) -> str:
|
||||
safe = re.sub(r"[^a-zA-Z0-9_\-:.]+", "_", program_name).strip("_") or "auto_sys"
|
||||
return f"{safe}.ini"
|
||||
|
||||
|
||||
def render_program_ini(account_id: int, program_name: str) -> str:
|
||||
project_root = _get_project_root()
|
||||
# Python 可执行文件:
|
||||
# - 优先使用 TRADING_PYTHON_BIN(线上可显式指定 trading_system 的 venv)
|
||||
# - 否则尝试多种候选路径(避免 backend venv 未安装交易依赖导致启动失败)
|
||||
python_bin_env = (os.getenv("TRADING_PYTHON_BIN") or "").strip()
|
||||
candidates = []
|
||||
if python_bin_env:
|
||||
candidates.append(python_bin_env)
|
||||
# 当前进程 python(backend venv)
|
||||
candidates.append(sys.executable)
|
||||
# 常见 venv 位置
|
||||
candidates += [
|
||||
str(project_root / "backend" / ".venv" / "bin" / "python"),
|
||||
str(project_root / ".venv" / "bin" / "python"),
|
||||
str(project_root / "trading_system" / ".venv" / "bin" / "python"),
|
||||
"/usr/bin/python3",
|
||||
"/usr/local/bin/python3",
|
||||
]
|
||||
python_bin = None
|
||||
for c in candidates:
|
||||
try:
|
||||
p = Path(c)
|
||||
if p.exists() and os.access(str(p), os.X_OK):
|
||||
python_bin = str(p)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not python_bin:
|
||||
# 最后兜底:写 sys.executable,让错误能在日志里体现
|
||||
python_bin = sys.executable
|
||||
|
||||
# 日志目录可通过环境变量覆盖
|
||||
log_dir, out_log, err_log = expected_trading_log_paths(project_root, int(account_id))
|
||||
# supervisor 在 reread/update 时会校验 logfile 目录是否存在;这里提前创建避免 CANT_REREAD
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
# 最后兜底到 /tmp,确保一定存在
|
||||
log_dir = Path("/tmp") / "autosys_logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_log = log_dir / f"trading_{int(account_id)}.out.log"
|
||||
err_log = log_dir / f"trading_{int(account_id)}.err.log"
|
||||
|
||||
# 默认不自动启动,避免“创建账号=立刻下单”
|
||||
autostart = (os.getenv("TRADING_AUTOSTART_DEFAULT", "false") or "false").lower() == "true"
|
||||
run_user = (os.getenv("SUPERVISOR_RUN_USER") or "").strip()
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
f"[program:{program_name}]",
|
||||
f"directory={project_root}",
|
||||
f"command={python_bin} -m trading_system.main",
|
||||
"autostart=" + ("true" if autostart else "false"),
|
||||
# 更合理:仅在“非0退出”时重启;0 退出视为“正常结束”,不进入 FATAL 反复拉起
|
||||
"autorestart=unexpected",
|
||||
# 兼容:0/2 都视为“预期退出”(例如配置不完整/前置检查失败时主动退出)
|
||||
"exitcodes=0,2",
|
||||
"startsecs=0",
|
||||
"stopasgroup=true",
|
||||
"killasgroup=true",
|
||||
"stopsignal=TERM",
|
||||
"",
|
||||
# 关键:PYTHONPATH 指向项目根,确保 -m trading_system.main 可导入
|
||||
f'environment=ATS_ACCOUNT_ID="{int(account_id)}",PYTHONUNBUFFERED="1",PYTHONPATH="{project_root}"',
|
||||
(f"user={run_user}" if run_user else "").rstrip(),
|
||||
"",
|
||||
f"stdout_logfile={out_log}",
|
||||
f"stderr_logfile={err_log}",
|
||||
"stdout_logfile_maxbytes=20MB",
|
||||
"stdout_logfile_backups=5",
|
||||
"stderr_logfile_maxbytes=20MB",
|
||||
"stderr_logfile_backups=5",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def write_program_ini(program_dir: Path, filename: str, content: str) -> Path:
|
||||
program_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = program_dir / filename
|
||||
tmp = program_dir / (filename + ".tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
os.replace(str(tmp), str(target))
|
||||
return target
|
||||
|
||||
|
||||
def _build_supervisorctl_cmd(args: list[str]) -> list[str]:
|
||||
supervisorctl_path = _detect_supervisorctl_path()
|
||||
supervisor_conf = (os.getenv("SUPERVISOR_CONF") or "").strip()
|
||||
use_sudo = (os.getenv("SUPERVISOR_USE_SUDO", "false") or "false").lower() == "true"
|
||||
|
||||
if not supervisor_conf:
|
||||
conf = _detect_supervisor_conf_path()
|
||||
supervisor_conf = str(conf) if conf else ""
|
||||
|
||||
cmd: list[str] = []
|
||||
if use_sudo:
|
||||
cmd += ["sudo", "-n"]
|
||||
cmd += [supervisorctl_path]
|
||||
if supervisor_conf:
|
||||
cmd += ["-c", supervisor_conf]
|
||||
cmd += args
|
||||
return cmd
|
||||
|
||||
|
||||
def run_supervisorctl(args: list[str], timeout_sec: int = 10) -> str:
|
||||
cmd = _build_supervisorctl_cmd(args)
|
||||
try:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=int(timeout_sec))
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("supervisorctl 超时")
|
||||
except FileNotFoundError:
|
||||
# 明确提示找不到命令,帮助排查路径问题
|
||||
cmd_str = " ".join(cmd)
|
||||
raise RuntimeError(f"Command not found: {cmd[0]} (Full cmd: {cmd_str})")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"supervisorctl execution failed: {str(e)}")
|
||||
|
||||
out = (res.stdout or "").strip()
|
||||
|
||||
err = (res.stderr or "").strip()
|
||||
combined = "\n".join([s for s in [out, err] if s]).strip()
|
||||
# supervisorctl: status 在存在 STOPPED 等进程时可能返回 exit=3,但输出仍然有效
|
||||
ok_rc = {0}
|
||||
if args and args[0] == "status":
|
||||
ok_rc.add(3)
|
||||
if res.returncode not in ok_rc:
|
||||
raise RuntimeError(combined or f"supervisorctl failed (exit={res.returncode})")
|
||||
return combined or out
|
||||
|
||||
|
||||
def parse_supervisor_status(raw: str) -> Tuple[bool, Optional[int], str]:
|
||||
if "RUNNING" in raw:
|
||||
m = re.search(r"\bpid\s+(\d+)\b", raw)
|
||||
pid = int(m.group(1)) if m else None
|
||||
return True, pid, "RUNNING"
|
||||
for state in ["STOPPED", "FATAL", "EXITED", "BACKOFF", "STARTING", "UNKNOWN"]:
|
||||
if state in raw:
|
||||
return False, None, state
|
||||
return False, None, "UNKNOWN"
|
||||
|
||||
|
||||
def tail_supervisor(program: str, stream: str = "stderr", lines: int = 120) -> str:
|
||||
"""
|
||||
读取 supervisor 进程最近日志(stdout/stderr)。
|
||||
⚠️ 修复:优先直接读取日志文件,避免 XML-RPC 编码错误。
|
||||
如果 supervisorctl tail 失败(编码错误),回退到直接读取文件。
|
||||
"""
|
||||
s = (stream or "stderr").strip().lower()
|
||||
if s not in {"stdout", "stderr"}:
|
||||
s = "stderr"
|
||||
n = int(lines or 120)
|
||||
if n < 20:
|
||||
n = 20
|
||||
if n > 500:
|
||||
n = 500
|
||||
|
||||
# 优先尝试通过 supervisorctl tail(正常情况)
|
||||
try:
|
||||
return run_supervisorctl(["tail", f"-{n}", str(program), s])
|
||||
except Exception as e:
|
||||
# 如果 supervisorctl tail 失败(可能是编码错误),尝试直接读取日志文件
|
||||
error_msg = str(e)
|
||||
if "UnicodeDecodeError" in error_msg or "utf-8" in error_msg.lower() or "codec" in error_msg.lower():
|
||||
# 尝试从程序名解析 account_id(例如 auto_sys_acc4 -> 4)
|
||||
try:
|
||||
m = re.match(r"^auto_sys_acc(\d+)$", program)
|
||||
if m:
|
||||
account_id = int(m.group(1))
|
||||
project_root = _get_project_root()
|
||||
log_dir, out_log, err_log = expected_trading_log_paths(project_root, account_id)
|
||||
# 根据 stream 选择对应的日志文件
|
||||
log_file = out_log if s == "stdout" else err_log
|
||||
if log_file.exists():
|
||||
# 直接读取文件,使用宽松的编码处理
|
||||
return _tail_text_file(log_file, lines=n)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果所有尝试都失败,返回错误信息(但不要抛出异常,避免影响主流程)
|
||||
return f"[读取日志失败: {error_msg}]"
|
||||
|
||||
|
||||
def _tail_text_file(path: Path, lines: int = 200, max_bytes: int = 64 * 1024) -> str:
|
||||
"""
|
||||
读取文本文件末尾(用于 supervisor spawn error 等场景,program stderr 可能为空)。
|
||||
尽量只读最后 max_bytes,避免大文件占用内存。
|
||||
⚠️ 修复:使用更宽松的编码处理,支持中文等多字节字符。
|
||||
"""
|
||||
try:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return ""
|
||||
size = p.stat().st_size
|
||||
read_size = min(int(max_bytes), int(size))
|
||||
with p.open("rb") as f:
|
||||
if size > read_size:
|
||||
f.seek(-read_size, os.SEEK_END)
|
||||
data = f.read()
|
||||
|
||||
# ⚠️ 修复:尝试多种编码,优先 UTF-8,失败则尝试常见中文编码
|
||||
text = None
|
||||
encodings = ["utf-8", "gbk", "gb2312", "gb18030", "latin1"]
|
||||
for enc in encodings:
|
||||
try:
|
||||
text = data.decode(enc, errors="strict")
|
||||
break
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
|
||||
# 如果所有编码都失败,使用 errors="ignore" 强制解码(会丢失部分字符但不会报错)
|
||||
if text is None:
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
|
||||
# 仅保留最后 N 行
|
||||
parts = text.splitlines()
|
||||
if not parts:
|
||||
return ""
|
||||
n = int(lines or 200)
|
||||
if n < 20:
|
||||
n = 20
|
||||
if n > 500:
|
||||
n = 500
|
||||
return "\n".join(parts[-n:]).strip()
|
||||
except Exception:
|
||||
# 兜底:若启用 sudo(通常 backend 自己无权读 root 日志),尝试 sudo tail
|
||||
try:
|
||||
use_sudo = (os.getenv("SUPERVISOR_USE_SUDO", "false") or "false").lower() == "true"
|
||||
if not use_sudo:
|
||||
return ""
|
||||
n = int(lines or 200)
|
||||
if n < 20:
|
||||
n = 20
|
||||
if n > 500:
|
||||
n = 500
|
||||
res = subprocess.run(
|
||||
["sudo", "-n", "tail", "-n", str(n), str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
out = (res.stdout or "").strip()
|
||||
err = (res.stderr or "").strip()
|
||||
# 不强行报错:宁可空,也不要影响主流程
|
||||
return (out or err or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_supervisord_logfile_from_conf(conf_path: Path) -> Optional[Path]:
|
||||
"""
|
||||
解析 supervisord.conf 中 [supervisord] 的 logfile= 路径。
|
||||
"""
|
||||
try:
|
||||
text = conf_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return None
|
||||
in_section = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith(";") or line.startswith("#"):
|
||||
continue
|
||||
if re.match(r"^\[supervisord\]\s*$", line, flags=re.I):
|
||||
in_section = True
|
||||
continue
|
||||
if in_section and line.startswith("[") and line.endswith("]"):
|
||||
break
|
||||
if not in_section:
|
||||
continue
|
||||
m = re.match(r"^logfile\s*=\s*(.+)$", line, flags=re.I)
|
||||
if not m:
|
||||
continue
|
||||
val = (m.group(1) or "").strip().strip('"').strip("'")
|
||||
if not val:
|
||||
continue
|
||||
p = Path(val)
|
||||
if not p.is_absolute():
|
||||
p = (conf_path.parent / p).resolve()
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def tail_supervisord_log(lines: int = 200) -> str:
|
||||
"""
|
||||
读取 supervisord 主日志尾部(spawn error 的根因经常在这里)。
|
||||
可通过环境变量 SUPERVISOR_LOGFILE 指定。
|
||||
"""
|
||||
env_p = (os.getenv("SUPERVISOR_LOGFILE") or "").strip()
|
||||
if env_p:
|
||||
return _tail_text_file(Path(env_p), lines=lines)
|
||||
conf = _detect_supervisor_conf_path()
|
||||
if conf and conf.exists():
|
||||
lp = _parse_supervisord_logfile_from_conf(conf)
|
||||
if lp:
|
||||
return _tail_text_file(lp, lines=lines)
|
||||
# 最后兜底:尝试常见路径
|
||||
for cand in DEFAULT_SUPERVISORD_LOG_CANDIDATES:
|
||||
try:
|
||||
p = Path(cand)
|
||||
if p.exists():
|
||||
text = _tail_text_file(p, lines=lines)
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def expected_trading_log_paths(project_root: Path, account_id: int) -> Tuple[Path, Path, Path]:
|
||||
"""
|
||||
计算 trading program 的 stdout/stderr logfile 路径(需与 render_program_ini 保持一致)。
|
||||
返回 (log_dir, out_log, err_log)
|
||||
"""
|
||||
log_dir = Path(os.getenv("TRADING_LOG_DIR", str(project_root / "logs"))).expanduser()
|
||||
out_log = log_dir / f"trading_{int(account_id)}.out.log"
|
||||
err_log = log_dir / f"trading_{int(account_id)}.err.log"
|
||||
return log_dir, out_log, err_log
|
||||
|
||||
|
||||
def tail_trading_log_files(account_id: int, lines: int = 200) -> Dict[str, Any]:
|
||||
"""
|
||||
直接读取该账号 trading 进程的 stdout/stderr logfile 尾部(不依赖 supervisorctl tail)。
|
||||
返回 {out_log, err_log, stdout_tail, stderr_tail}
|
||||
"""
|
||||
project_root = _get_project_root()
|
||||
log_dir, out_log, err_log = expected_trading_log_paths(project_root, int(account_id))
|
||||
return {
|
||||
"log_dir": str(log_dir),
|
||||
"out_log": str(out_log),
|
||||
"err_log": str(err_log),
|
||||
"stdout_tail_file": _tail_text_file(out_log, lines=lines),
|
||||
"stderr_tail_file": _tail_text_file(err_log, lines=lines),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnsureProgramResult:
|
||||
ok: bool
|
||||
program: str
|
||||
ini_path: str
|
||||
program_dir: str
|
||||
supervisor_conf: str
|
||||
reread: str = ""
|
||||
update: str = ""
|
||||
error: str = ""
|
||||
|
||||
|
||||
def ensure_account_program(account_id: int) -> EnsureProgramResult:
|
||||
aid = int(account_id)
|
||||
program = program_name_for_account(aid)
|
||||
program_dir = get_supervisor_program_dir()
|
||||
ini_name = ini_filename_for_program(program)
|
||||
ini_text = render_program_ini(aid, program)
|
||||
conf = _detect_supervisor_conf_path()
|
||||
conf_s = str(conf) if conf else (os.getenv("SUPERVISOR_CONF") or "")
|
||||
|
||||
try:
|
||||
path = write_program_ini(program_dir, ini_name, ini_text)
|
||||
reread_out = ""
|
||||
update_out = ""
|
||||
try:
|
||||
reread_out = run_supervisorctl(["reread"])
|
||||
update_out = run_supervisorctl(["update"])
|
||||
except Exception as e:
|
||||
# 写文件成功但 supervisorctl 失败也要给出可诊断信息
|
||||
return EnsureProgramResult(
|
||||
ok=False,
|
||||
program=program,
|
||||
ini_path=str(path),
|
||||
program_dir=str(program_dir),
|
||||
supervisor_conf=conf_s,
|
||||
reread=reread_out,
|
||||
update=update_out,
|
||||
error=f"写入配置成功,但执行 supervisorctl reread/update 失败: {e}",
|
||||
)
|
||||
|
||||
return EnsureProgramResult(
|
||||
ok=True,
|
||||
program=program,
|
||||
ini_path=str(path),
|
||||
program_dir=str(program_dir),
|
||||
supervisor_conf=conf_s,
|
||||
reread=reread_out,
|
||||
update=update_out,
|
||||
)
|
||||
except Exception as e:
|
||||
return EnsureProgramResult(
|
||||
ok=False,
|
||||
program=program,
|
||||
ini_path="",
|
||||
program_dir=str(program_dir),
|
||||
supervisor_conf=conf_s,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
# Add backend directory to sys.path
|
||||
backend_path = Path(__file__).parent
|
||||
sys.path.insert(0, str(backend_path))
|
||||
|
||||
try:
|
||||
from database.connection import db
|
||||
print("Database connection imported successfully.")
|
||||
except ImportError as e:
|
||||
print(f"Error importing database connection: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def is_ascii(s):
|
||||
return all(ord(c) < 128 for c in s)
|
||||
|
||||
def check_table(table_name, column_name):
|
||||
print(f"Checking table '{table_name}' column '{column_name}'...")
|
||||
try:
|
||||
query = f"SELECT DISTINCT {column_name} FROM {table_name}"
|
||||
rows = db.execute_query(query)
|
||||
|
||||
found_invalid = False
|
||||
for row in rows:
|
||||
symbol = row.get(column_name)
|
||||
if symbol and not is_ascii(symbol):
|
||||
print(f"!!! FOUND INVALID SYMBOL in {table_name}: '{symbol}'")
|
||||
found_invalid = True
|
||||
if symbol and "币安" in symbol:
|
||||
print(f"!!! FOUND '币安' in {table_name}: '{symbol}'")
|
||||
found_invalid = True
|
||||
|
||||
if not found_invalid:
|
||||
print(f"No invalid symbols found in {table_name}.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error querying {table_name}: {e}")
|
||||
|
||||
def check_config(table_name):
|
||||
print(f"Checking table '{table_name}' for '币安'...")
|
||||
try:
|
||||
query = f"SELECT config_key, config_value FROM {table_name}"
|
||||
rows = db.execute_query(query)
|
||||
|
||||
for row in rows:
|
||||
key = row.get('config_key')
|
||||
val = row.get('config_value')
|
||||
|
||||
if val and "币安" in str(val):
|
||||
# Ignore expected descriptions/comments if any (usually description is separate column)
|
||||
# But here we check config_value
|
||||
print(f"Found '币安' in {table_name} KEY='{key}': VALUE='{val}'")
|
||||
|
||||
if key and "币安" in str(key):
|
||||
print(f"Found '币安' in {table_name} KEY='{key}'")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error querying {table_name}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_table("trades", "symbol")
|
||||
check_table("trade_recommendations", "symbol")
|
||||
check_config("trading_config")
|
||||
check_config("global_strategy_config")
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 检查 backend 依赖是否完整安装
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== 检查 Backend 依赖 ==="
|
||||
echo ""
|
||||
|
||||
# 检查虚拟环境
|
||||
if [ -d "../.venv" ]; then
|
||||
echo "✓ 找到虚拟环境: ../.venv"
|
||||
source ../.venv/bin/activate
|
||||
elif [ -d ".venv" ]; then
|
||||
echo "✓ 找到虚拟环境: .venv"
|
||||
source .venv/bin/activate
|
||||
else
|
||||
echo "⚠ 未找到虚拟环境,使用系统 Python"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Python 路径: $(which python3)"
|
||||
echo "Python 版本: $(python3 --version)"
|
||||
echo ""
|
||||
|
||||
# 检查关键依赖
|
||||
echo "检查关键依赖..."
|
||||
python3 -c "import fastapi; print('✓ fastapi:', fastapi.__version__)" 2>&1 || echo "✗ fastapi 未安装"
|
||||
python3 -c "import uvicorn; print('✓ uvicorn:', uvicorn.__version__)" 2>&1 || echo "✗ uvicorn 未安装"
|
||||
python3 -c "from jose import jwt; print('✓ python-jose: 已安装')" 2>&1 || echo "✗ python-jose 未安装"
|
||||
python3 -c "import pymysql; print('✓ pymysql:', pymysql.__version__)" 2>&1 || echo "✗ pymysql 未安装"
|
||||
python3 -c "import redis; print('✓ redis:', redis.__version__)" 2>&1 || echo "✗ redis 未安装"
|
||||
python3 -c "from cryptography.fernet import Fernet; print('✓ cryptography: 已安装')" 2>&1 || echo "✗ cryptography 未安装"
|
||||
|
||||
echo ""
|
||||
echo "=== 尝试导入 api.main ==="
|
||||
python3 -c "import api.main; print('✓ api.main 导入成功')" 2>&1 || echo "✗ api.main 导入失败"
|
||||
|
||||
echo ""
|
||||
echo "=== 检查完成 ==="
|
||||
echo ""
|
||||
echo "如果缺少依赖,请运行:"
|
||||
echo " pip install -r backend/requirements.txt"
|
||||
echo ""
|
||||
echo "或者激活虚拟环境后运行:"
|
||||
echo " source .venv/bin/activate"
|
||||
echo " pip install -r backend/requirements.txt"
|
||||
|
|
@ -35,10 +35,9 @@ sys.path.insert(0, str(project_root))
|
|||
|
||||
# 延迟导入,避免在trading_system中导入时因为缺少依赖而失败
|
||||
try:
|
||||
from database.models import TradingConfig, Account
|
||||
from database.models import TradingConfig
|
||||
except ImportError as e:
|
||||
TradingConfig = None
|
||||
Account = None
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"无法导入TradingConfig: {e},配置管理器将无法使用数据库")
|
||||
|
|
@ -47,39 +46,6 @@ import logging
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 平台兜底:策略核心使用全局配置表(global_strategy_config),普通用户账号只允许调整“风险旋钮”
|
||||
# 执行策略合并顺序:普通用户(账号)配置优先,未设置时使用全局配置,允许用户简单控制自己的交易并只影响本人执行
|
||||
# - 风险旋钮:每个账号独立(仓位/频次等),账号有则用账号,无则用全局
|
||||
# - 其它策略参数:账号有则用账号,无则用全局(管理员可在全局配置设默认,用户可覆盖)
|
||||
# 注意:不再依赖account_id=1,全局配置存储在独立的global_strategy_config表中
|
||||
|
||||
_MISSING = object() # 用于区分“账号未设置”与“值为 None/0/False”
|
||||
|
||||
RISK_KNOBS_KEYS = {
|
||||
"MIN_MARGIN_USDT",
|
||||
"MIN_POSITION_PERCENT",
|
||||
"MAX_POSITION_PERCENT",
|
||||
"MAX_TOTAL_POSITION_PERCENT",
|
||||
"AUTO_TRADE_ENABLED",
|
||||
"MAX_OPEN_POSITIONS",
|
||||
"MAX_DAILY_ENTRIES",
|
||||
"SUNDAY_MAX_OPENS",
|
||||
"SUNDAY_MIN_SIGNAL_STRENGTH",
|
||||
"NIGHT_HOURS_NO_OPEN_ENABLED",
|
||||
"NIGHT_HOURS_START",
|
||||
"NIGHT_HOURS_END",
|
||||
# 2026-02-06 Added for Altcoin Strategy presets
|
||||
"TOP_N_SYMBOLS",
|
||||
"MIN_SIGNAL_STRENGTH",
|
||||
"MIN_VOLUME_24H",
|
||||
"MIN_VOLATILITY",
|
||||
"SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT",
|
||||
"EXCLUDE_MAJOR_COINS",
|
||||
# 2026-02-06 Added for User Customization
|
||||
"MAX_SCAN_SYMBOLS",
|
||||
"SCAN_INTERVAL",
|
||||
}
|
||||
|
||||
# 尝试导入同步Redis客户端(用于配置缓存)
|
||||
try:
|
||||
import redis
|
||||
|
|
@ -89,241 +55,15 @@ except ImportError:
|
|||
redis = None
|
||||
|
||||
|
||||
class GlobalStrategyConfigManager:
|
||||
"""全局策略配置管理器(独立于账户,管理员专用)"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if hasattr(self, '_initialized'):
|
||||
return
|
||||
self._initialized = True
|
||||
self._cache = {}
|
||||
self._redis_client: Optional[redis.Redis] = None
|
||||
self._redis_connected = False
|
||||
self._redis_hash_key = "global_strategy_config_v5" # 独立的Redis键 (v5: 强制刷新缓存 - 2025-02-14)
|
||||
self._init_redis()
|
||||
self._load_from_db()
|
||||
|
||||
def _init_redis(self):
|
||||
"""初始化Redis客户端(同步)"""
|
||||
if not REDIS_SYNC_AVAILABLE:
|
||||
logger.debug("redis-py未安装,全局配置缓存将不使用Redis")
|
||||
return
|
||||
|
||||
try:
|
||||
redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379')
|
||||
redis_use_tls = os.getenv('REDIS_USE_TLS', 'False').lower() == 'true'
|
||||
redis_username = os.getenv('REDIS_USERNAME', None)
|
||||
redis_password = os.getenv('REDIS_PASSWORD', None)
|
||||
|
||||
if not redis_url or not isinstance(redis_url, str):
|
||||
redis_url = 'redis://localhost:6379'
|
||||
|
||||
if redis_use_tls and not redis_url.startswith('rediss://'):
|
||||
if redis_url.startswith('redis://'):
|
||||
redis_url = redis_url.replace('redis://', 'rediss://', 1)
|
||||
|
||||
connection_kwargs = {
|
||||
'username': redis_username,
|
||||
'password': redis_password,
|
||||
'decode_responses': True
|
||||
}
|
||||
|
||||
if redis_url.startswith('rediss://') or redis_use_tls:
|
||||
ssl_cert_reqs = os.getenv('REDIS_SSL_CERT_REQS', 'required')
|
||||
ssl_ca_certs = os.getenv('REDIS_SSL_CA_CERTS', None)
|
||||
connection_kwargs['select'] = int(os.getenv('REDIS_SELECT', 0))
|
||||
connection_kwargs['ssl_cert_reqs'] = ssl_cert_reqs
|
||||
if ssl_ca_certs:
|
||||
connection_kwargs['ssl_ca_certs'] = ssl_ca_certs
|
||||
if ssl_cert_reqs == 'none':
|
||||
connection_kwargs['ssl_check_hostname'] = False
|
||||
elif ssl_cert_reqs == 'required':
|
||||
connection_kwargs['ssl_check_hostname'] = True
|
||||
else:
|
||||
connection_kwargs['ssl_check_hostname'] = False
|
||||
|
||||
self._redis_client = redis.from_url(redis_url, **connection_kwargs)
|
||||
self._redis_client.ping()
|
||||
self._redis_connected = True
|
||||
logger.info("✓ 全局策略配置Redis缓存连接成功")
|
||||
except Exception as e:
|
||||
logger.debug(f"全局策略配置Redis缓存连接失败: {e},将使用数据库缓存")
|
||||
self._redis_client = None
|
||||
self._redis_connected = False
|
||||
|
||||
def _get_from_redis(self, key: str) -> Optional[Any]:
|
||||
"""从Redis获取全局配置值"""
|
||||
if not self._redis_connected or not self._redis_client:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = self._redis_client.hget(self._redis_hash_key, key)
|
||||
if value is not None and value != '':
|
||||
return ConfigManager._coerce_redis_value(value)
|
||||
except Exception as e:
|
||||
logger.debug(f"从Redis获取全局配置失败 {key}: {e}")
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
self._redis_connected = True
|
||||
except:
|
||||
self._redis_connected = False
|
||||
|
||||
return None
|
||||
|
||||
def _set_to_redis(self, key: str, value: Any):
|
||||
"""设置全局配置到Redis"""
|
||||
if not self._redis_connected or not self._redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
if isinstance(value, (dict, list, bool, int, float)):
|
||||
value_str = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
self._redis_client.hset(self._redis_hash_key, key, value_str)
|
||||
self._redis_client.expire(self._redis_hash_key, 3600)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"设置全局配置到Redis失败 {key}: {e}")
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
self._redis_connected = True
|
||||
except:
|
||||
self._redis_connected = False
|
||||
return False
|
||||
|
||||
def _load_from_db(self):
|
||||
"""从数据库加载全局配置"""
|
||||
try:
|
||||
from database.models import GlobalStrategyConfig
|
||||
except ImportError:
|
||||
logger.warning("GlobalStrategyConfig未导入,无法从数据库加载全局配置")
|
||||
self._cache = {}
|
||||
return
|
||||
|
||||
try:
|
||||
# 先尝试从Redis加载
|
||||
if self._redis_connected and self._redis_client:
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
redis_configs = self._redis_client.hgetall(self._redis_hash_key)
|
||||
if redis_configs and len(redis_configs) > 0:
|
||||
for key, value_str in redis_configs.items():
|
||||
self._cache[key] = ConfigManager._coerce_redis_value(value_str)
|
||||
logger.info(f"从Redis加载了 {len(self._cache)} 个全局配置项")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"从Redis加载全局配置失败: {e},回退到数据库")
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
except:
|
||||
self._redis_connected = False
|
||||
|
||||
# 从数据库加载
|
||||
configs = GlobalStrategyConfig.get_all()
|
||||
for config in configs:
|
||||
key = config['config_key']
|
||||
# 使用TradingConfig的转换方法(GlobalStrategyConfig复用)
|
||||
from database.models import TradingConfig
|
||||
value = TradingConfig._convert_value(
|
||||
config['config_value'],
|
||||
config['config_type']
|
||||
)
|
||||
self._cache[key] = value
|
||||
self._set_to_redis(key, value)
|
||||
|
||||
logger.info(f"从数据库加载了 {len(self._cache)} 个全局配置项,已同步到Redis")
|
||||
except Exception as e:
|
||||
logger.warning(f"从数据库加载全局配置失败,使用默认配置: {e}")
|
||||
self._cache = {}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""获取全局配置值"""
|
||||
# 1. 优先从Redis缓存读取
|
||||
if self._redis_connected and self._redis_client:
|
||||
redis_value = self._get_from_redis(key)
|
||||
if redis_value is not None:
|
||||
self._cache[key] = redis_value
|
||||
return redis_value
|
||||
|
||||
# 2. 从本地缓存读取
|
||||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
|
||||
# 3. 从数据库读取
|
||||
try:
|
||||
from database.models import GlobalStrategyConfig
|
||||
db_value = GlobalStrategyConfig.get_value(key)
|
||||
if db_value is not None:
|
||||
self._cache[key] = db_value
|
||||
self._set_to_redis(key, db_value)
|
||||
return db_value
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. 从环境变量读取
|
||||
env_value = os.getenv(key)
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
|
||||
# 5. 返回默认值
|
||||
return default
|
||||
|
||||
def reload_from_redis(self):
|
||||
"""强制从Redis重新加载全局配置"""
|
||||
if not self._redis_connected or not self._redis_client:
|
||||
return
|
||||
|
||||
try:
|
||||
self._redis_client.ping()
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis连接不可用: {e},跳过从Redis重新加载")
|
||||
self._redis_connected = False
|
||||
return
|
||||
|
||||
try:
|
||||
redis_configs = self._redis_client.hgetall(self._redis_hash_key)
|
||||
if redis_configs and len(redis_configs) > 0:
|
||||
self._cache = {}
|
||||
for key, value_str in redis_configs.items():
|
||||
self._cache[key] = ConfigManager._coerce_redis_value(value_str)
|
||||
logger.debug(f"从Redis重新加载了 {len(self._cache)} 个全局配置项")
|
||||
except Exception as e:
|
||||
logger.debug(f"从Redis重新加载全局配置失败: {e},保持现有缓存")
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""配置管理器 - 优先从Redis缓存读取,其次从数据库读取,回退到环境变量和默认值"""
|
||||
|
||||
_instances = {}
|
||||
|
||||
def __init__(self, account_id: int = 1):
|
||||
self.account_id = int(account_id or 1)
|
||||
def __init__(self):
|
||||
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客户端(同步)"""
|
||||
|
|
@ -375,12 +115,6 @@ class ConfigManager:
|
|||
ssl_cert_reqs = os.getenv('REDIS_SSL_CERT_REQS', 'required')
|
||||
ssl_ca_certs = os.getenv('REDIS_SSL_CA_CERTS', None)
|
||||
|
||||
connection_kwargs['select'] = os.getenv('REDIS_SELECT', 0)
|
||||
if connection_kwargs['select'] is not None:
|
||||
connection_kwargs['select'] = int(connection_kwargs['select'])
|
||||
else:
|
||||
connection_kwargs['select'] = 0
|
||||
logger.info(f"使用 Redis 数据库: {connection_kwargs['select']}")
|
||||
# 设置SSL参数
|
||||
connection_kwargs['ssl_cert_reqs'] = ssl_cert_reqs
|
||||
if ssl_ca_certs:
|
||||
|
|
@ -417,10 +151,8 @@ class ConfigManager:
|
|||
return None
|
||||
|
||||
try:
|
||||
# 使用账号维度 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)
|
||||
# 使用Hash存储所有配置,键为 trading_config:{key}
|
||||
value = self._redis_client.hget('trading_config', key)
|
||||
if value is not None and value != '':
|
||||
return self._coerce_redis_value(value)
|
||||
except Exception as e:
|
||||
|
|
@ -485,22 +217,21 @@ class ConfigManager:
|
|||
return s
|
||||
|
||||
def _set_to_redis(self, key: str, value: Any):
|
||||
"""设置配置到Redis(账号维度 + legacy兼容)"""
|
||||
"""设置配置到Redis"""
|
||||
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(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)
|
||||
self._redis_client.hset('trading_config', key, value_str)
|
||||
# 设置整个Hash的过期时间为7天(配置不会频繁变化,但需要定期刷新)
|
||||
self._redis_client.expire('trading_config', 7 * 24 * 3600)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"设置配置到Redis失败 {key}: {e}")
|
||||
|
|
@ -513,11 +244,8 @@ class ConfigManager:
|
|||
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)
|
||||
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)
|
||||
self._redis_client.hset('trading_config', key, value_str)
|
||||
self._redis_client.expire('trading_config', 7 * 24 * 3600)
|
||||
return True
|
||||
except:
|
||||
self._redis_connected = False
|
||||
|
|
@ -529,23 +257,15 @@ 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(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.hset('trading_config', key, value_str)
|
||||
pipe.expire('trading_config', 7 * 24 * 3600)
|
||||
pipe.execute()
|
||||
logger.debug(f"已将 {len(self._cache)} 个配置项同步到Redis")
|
||||
except Exception as e:
|
||||
|
|
@ -564,9 +284,7 @@ class ConfigManager:
|
|||
try:
|
||||
# 测试连接是否真正可用
|
||||
self._redis_client.ping()
|
||||
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)
|
||||
redis_configs = self._redis_client.hgetall('trading_config')
|
||||
if redis_configs and len(redis_configs) > 0:
|
||||
# 解析Redis中的配置
|
||||
for key, value_str in redis_configs.items():
|
||||
|
|
@ -585,7 +303,7 @@ class ConfigManager:
|
|||
self._redis_connected = False
|
||||
|
||||
# 从数据库加载配置(仅在Redis不可用或Redis中没有数据时)
|
||||
configs = TradingConfig.get_all(account_id=self.account_id)
|
||||
configs = TradingConfig.get_all()
|
||||
for config in configs:
|
||||
key = config['config_key']
|
||||
value = TradingConfig._convert_value(
|
||||
|
|
@ -603,29 +321,6 @@ 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:
|
||||
|
|
@ -639,18 +334,7 @@ class ConfigManager:
|
|||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
|
||||
# 3. 从全局策略配置读取(如果账号未设置)
|
||||
# API密钥等敏感信息不走全局配置
|
||||
if key not in ("BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"):
|
||||
try:
|
||||
# GlobalStrategyConfigManager是单例,开销很小
|
||||
global_val = GlobalStrategyConfigManager().get(key)
|
||||
if global_val is not None:
|
||||
return global_val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. 从环境变量读取
|
||||
# 3. 从环境变量读取
|
||||
env_value = os.getenv(key)
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
|
|
@ -660,21 +344,6 @@ class ConfigManager:
|
|||
|
||||
def set(self, key, value, config_type='string', category='general', description=None):
|
||||
"""设置配置(同时更新数据库、Redis缓存和本地缓存)"""
|
||||
# 账号私有:API Key/Secret/Testnet 写入 accounts 表
|
||||
if key in ("BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET") and Account is not None:
|
||||
try:
|
||||
if key == "BINANCE_API_KEY":
|
||||
Account.update_credentials(self.account_id, api_key=str(value or ""))
|
||||
elif key == "BINANCE_API_SECRET":
|
||||
Account.update_credentials(self.account_id, api_secret=str(value or ""))
|
||||
else:
|
||||
Account.update_credentials(self.account_id, use_testnet=bool(value))
|
||||
self._cache[key] = value
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"更新账号API配置失败: {e}")
|
||||
raise
|
||||
|
||||
if TradingConfig is None:
|
||||
logger.warning("TradingConfig未导入,无法更新数据库配置")
|
||||
self._cache[key] = value
|
||||
|
|
@ -684,7 +353,7 @@ class ConfigManager:
|
|||
|
||||
try:
|
||||
# 1. 更新数据库
|
||||
TradingConfig.set(key, value, config_type, category, description, account_id=self.account_id)
|
||||
TradingConfig.set(key, value, config_type, category, description)
|
||||
|
||||
# 2. 更新本地缓存
|
||||
self._cache[key] = value
|
||||
|
|
@ -718,9 +387,7 @@ class ConfigManager:
|
|||
return
|
||||
|
||||
try:
|
||||
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)
|
||||
redis_configs = self._redis_client.hgetall('trading_config')
|
||||
if redis_configs and len(redis_configs) > 0:
|
||||
self._cache = {} # 清空缓存
|
||||
for key, value_str in redis_configs.items():
|
||||
|
|
@ -739,329 +406,77 @@ 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):
|
||||
"""
|
||||
执行策略合并:账号(用户)配置优先,未设置时使用全局配置,只影响该账号的交易执行。
|
||||
- API key/secret/testnet 仅账号,无全局兜底。
|
||||
- 其余项:先读账号,有则用;无则用全局,再无则用 default。
|
||||
"""
|
||||
value_from_account = False
|
||||
if key in ("BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"):
|
||||
value = self.get(key, default)
|
||||
value_from_account = True
|
||||
else:
|
||||
account_val = self.get(key, _MISSING)
|
||||
if account_val is not _MISSING:
|
||||
value = account_val
|
||||
value_from_account = True
|
||||
else:
|
||||
try:
|
||||
value = global_config_mgr.get(key, default)
|
||||
except Exception:
|
||||
value = default
|
||||
|
||||
# ⚠️ 临时兼容性处理:百分比配置值格式转换
|
||||
# 如果配置值是百分比形式(>1),转换为比例形式(除以100)
|
||||
# 兼容数据库中可能存在的旧数据(百分比形式,如30表示30%)
|
||||
# 数据迁移完成后,可以移除此逻辑
|
||||
# 统一格式:数据库、前端、后端都使用比例形式(0.30表示30%)
|
||||
if isinstance(value, (int, float)) and value is not None:
|
||||
# 需要转换的百分比配置项
|
||||
percent_keys = [
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'LOCK_PROFIT_STAGE1_TRIGGER_PCT',
|
||||
'LOCK_PROFIT_STAGE1_PCT',
|
||||
'LOCK_PROFIT_STAGE2_TRIGGER_PCT',
|
||||
'LOCK_PROFIT_STAGE2_PCT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'TAKE_PROFIT_1_PERCENT', # 分步止盈第一目标(默认15%)
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT',
|
||||
]
|
||||
|
||||
if key in percent_keys:
|
||||
# 如果值>1,认为是百分比形式(旧数据),转换为比例形式
|
||||
# 静默转换,不输出警告(用户已确认数据库应存储小数形式)
|
||||
if value > 1:
|
||||
value = value / 100.0
|
||||
# 静默更新缓存:值来自账号则写回账号,否则写回全局
|
||||
try:
|
||||
if value_from_account:
|
||||
self._set_to_redis(key, value)
|
||||
self._cache[key] = value
|
||||
else:
|
||||
global_config_mgr._set_to_redis(key, value)
|
||||
global_config_mgr._cache[key] = value
|
||||
except Exception as e:
|
||||
logger.debug(f"更新Redis缓存失败(不影响使用): {key} = {e}")
|
||||
|
||||
return value
|
||||
|
||||
# 交易预设:控制一组参数的“默认性格”
|
||||
profile = str(eff_get('TRADING_PROFILE', 'conservative') or 'conservative').lower()
|
||||
is_fast = profile in ('fast', 'fast_test', 'aggressive')
|
||||
|
||||
max_daily_default = 30 if is_fast else 8
|
||||
scan_interval_default = 900 if is_fast else 1800
|
||||
min_signal_default = 7 if is_fast else 8 # 2026-01-29优化:稳健模式从9降到8(平衡胜率和交易频率)
|
||||
cooldown_default = 900 if is_fast else 1800
|
||||
allow_neutral_default = True if is_fast else False
|
||||
short_filter_default = False if is_fast else True
|
||||
max_trend_move_default = 0.08 if is_fast else 0.05
|
||||
|
||||
result = {
|
||||
return {
|
||||
# 仓位控制
|
||||
'MAX_POSITION_PERCENT': eff_get('MAX_POSITION_PERCENT', 0.12), # 单笔最大保证金占比(12%,加大单笔盈利空间)
|
||||
'MAX_TOTAL_POSITION_PERCENT': eff_get('MAX_TOTAL_POSITION_PERCENT', 0.40), # 总保证金占比上限
|
||||
'MIN_POSITION_PERCENT': eff_get('MIN_POSITION_PERCENT', 0.02), # 最小保证金占比
|
||||
'MIN_MARGIN_USDT': eff_get('MIN_MARGIN_USDT', 2.0), # 最小保证金(USDT)
|
||||
|
||||
# 用户风险旋钮:自动交易开关/频次控制
|
||||
'AUTO_TRADE_ENABLED': eff_get('AUTO_TRADE_ENABLED', True),
|
||||
'MAX_OPEN_POSITIONS': eff_get('MAX_OPEN_POSITIONS', 3),
|
||||
'MAX_DAILY_ENTRIES': eff_get('MAX_DAILY_ENTRIES', max_daily_default),
|
||||
'SUNDAY_MAX_OPENS': eff_get('SUNDAY_MAX_OPENS', 3), # 周日开仓上限,0=不限制
|
||||
'SUNDAY_MIN_SIGNAL_STRENGTH': eff_get('SUNDAY_MIN_SIGNAL_STRENGTH', 8), # 周日最低信号强度,0=不提高
|
||||
'NIGHT_HOURS_NO_OPEN_ENABLED': eff_get('NIGHT_HOURS_NO_OPEN_ENABLED', True),
|
||||
'NIGHT_HOURS_START': eff_get('NIGHT_HOURS_START', 21),
|
||||
'NIGHT_HOURS_END': eff_get('NIGHT_HOURS_END', 6),
|
||||
'NIGHT_HOURS_ONLY_SUNDAY': eff_get('NIGHT_HOURS_ONLY_SUNDAY', True),
|
||||
'NO_OPEN_HOURS_BJ': (eff_get('NO_OPEN_HOURS_BJ', '') or '').strip(), # 禁止开仓小时(北京),逗号分隔如 "17,19,22",空则不限制
|
||||
|
||||
# 同步/系统单标识(全局配置,账号可覆盖)
|
||||
'ONLY_AUTO_TRADE_CREATES_RECORDS': eff_get('ONLY_AUTO_TRADE_CREATES_RECORDS', True), # True=不补建「仅币安有仓」;False 时配合 SYNC_RECOVER 可补建
|
||||
'SYNC_RECOVER_MISSING_POSITIONS': eff_get('SYNC_RECOVER_MISSING_POSITIONS', True),
|
||||
'SYNC_RECOVER_ONLY_WHEN_HAS_SLTP': eff_get('SYNC_RECOVER_ONLY_WHEN_HAS_SLTP', True),
|
||||
'SYSTEM_ORDER_ID_PREFIX': eff_get('SYSTEM_ORDER_ID_PREFIX', 'SYS') or '',
|
||||
'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美元
|
||||
|
||||
# 涨跌幅阈值
|
||||
'MIN_CHANGE_PERCENT': eff_get('MIN_CHANGE_PERCENT', 2.0),
|
||||
'MIN_CHANGE_PERCENT': self.get('MIN_CHANGE_PERCENT', 2.0),
|
||||
'TOP_N_SYMBOLS': self.get('TOP_N_SYMBOLS', 10),
|
||||
|
||||
# 风险控制
|
||||
# ⚠️ 2026-01-29优化:放宽止损,减少被正常波动扫出
|
||||
# - 提高ATR倍数(从1.5到2.0),给市场波动更多空间
|
||||
# - 提高最小价格变动百分比(从2%到2.5%),避免止损过紧
|
||||
'STOP_LOSS_PERCENT': eff_get('STOP_LOSS_PERCENT', 0.12), # 默认12%(保证金百分比)
|
||||
'TAKE_PROFIT_PERCENT': eff_get('TAKE_PROFIT_PERCENT', 0.30), # 默认30%(第二目标/单目标止盈)
|
||||
'TAKE_PROFIT_1_PERCENT': eff_get('TAKE_PROFIT_1_PERCENT', 0.20), # 默认20%(2026-02-12优化:拉高第一目标改善盈亏比)
|
||||
'MIN_STOP_LOSS_PRICE_PCT': eff_get('MIN_STOP_LOSS_PRICE_PCT', 0.025), # 默认2.5%(2026-01-29优化:从2%提高到2.5%,给波动更多空间)
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT': eff_get('MIN_TAKE_PROFIT_PRICE_PCT', 0.02), # 默认2%(防止ATR过小时计算出不切实际的微小止盈距离)
|
||||
'USE_ATR_STOP_LOSS': eff_get('USE_ATR_STOP_LOSS', True), # 是否使用ATR动态止损
|
||||
'ATR_STOP_LOSS_MULTIPLIER': eff_get('ATR_STOP_LOSS_MULTIPLIER', 3.0), # ATR止损倍数3.0(2026-02-12优化:减少噪音止损,配合止盈拉远)
|
||||
'ATR_TAKE_PROFIT_MULTIPLIER': eff_get('ATR_TAKE_PROFIT_MULTIPLIER', 2.0), # ATR止盈倍数2.0(2026-01-27优化:降低止盈目标,更容易触发)
|
||||
'RISK_REWARD_RATIO': eff_get('RISK_REWARD_RATIO', 3.0), # 盈亏比3:1(2026-01-27优化:降低,更容易触发,保证胜率)
|
||||
'ATR_PERIOD': eff_get('ATR_PERIOD', 14), # ATR计算周期
|
||||
'USE_DYNAMIC_ATR_MULTIPLIER': eff_get('USE_DYNAMIC_ATR_MULTIPLIER', False), # 是否根据波动率动态调整ATR倍数
|
||||
'ATR_MULTIPLIER_MIN': eff_get('ATR_MULTIPLIER_MIN', 1.5), # 动态ATR倍数最小值
|
||||
'ATR_MULTIPLIER_MAX': eff_get('ATR_MULTIPLIER_MAX', 2.5), # 动态ATR倍数最大值
|
||||
'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倍数最大值
|
||||
|
||||
# 固定风险百分比仓位计算(凯利公式)
|
||||
'USE_FIXED_RISK_SIZING': eff_get('USE_FIXED_RISK_SIZING', True), # 使用固定风险百分比计算仓位
|
||||
'FIXED_RISK_PERCENT': eff_get('FIXED_RISK_PERCENT', 0.02), # 每笔单子承受的风险(2%)
|
||||
# 仓位放大系数:1.0=正常,1.2=+20% 仓位,上限 2.0,仍受 MAX_POSITION_PERCENT 约束(盈利时适度放大用)
|
||||
'POSITION_SCALE_FACTOR': eff_get('POSITION_SCALE_FACTOR', 1.0),
|
||||
|
||||
# 市场扫描(30分钟主周期)
|
||||
'SCAN_INTERVAL': eff_get('SCAN_INTERVAL', scan_interval_default), # 30分钟(增加交易机会)
|
||||
'TOP_N_SYMBOLS': eff_get('TOP_N_SYMBOLS', 20), # 每次扫描后优先处理的交易对数量
|
||||
'SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT': eff_get('SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT', 15), # 智能补单:多返回的候选数量,冷却时仍可尝试后续交易对
|
||||
'MAX_SCAN_SYMBOLS': eff_get('MAX_SCAN_SYMBOLS', 500), # 扫描的最大交易对数量(增加到500)
|
||||
'EXCLUDE_MAJOR_COINS': eff_get('EXCLUDE_MAJOR_COINS', True), # 是否排除主流币(BTC、ETH、BNB等),专注于山寨币
|
||||
'KLINE_INTERVAL': eff_get('KLINE_INTERVAL', '1h'),
|
||||
'PRIMARY_INTERVAL': eff_get('PRIMARY_INTERVAL', '1h'),
|
||||
'CONFIRM_INTERVAL': eff_get('CONFIRM_INTERVAL', '4h'),
|
||||
'ENTRY_INTERVAL': eff_get('ENTRY_INTERVAL', '15m'),
|
||||
# 市场扫描(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'),
|
||||
|
||||
# 过滤条件
|
||||
'MIN_VOLUME_24H': eff_get('MIN_VOLUME_24H', 10000000),
|
||||
'MIN_VOLATILITY': eff_get('MIN_VOLATILITY', 0.02),
|
||||
'MIN_VOLUME_24H': self.get('MIN_VOLUME_24H', 10000000),
|
||||
'MIN_VOLATILITY': self.get('MIN_VOLATILITY', 0.02),
|
||||
|
||||
# 高胜率策略参数
|
||||
# ⚠️ 2026-01-29优化:提高信号强度门槛(稳健模式从9到8),减少低质量信号,提升胜率
|
||||
'MIN_SIGNAL_STRENGTH': eff_get('MIN_SIGNAL_STRENGTH', min_signal_default), # 默认值随 profile 调整(快速模式7,稳健模式8)
|
||||
'LEVERAGE': eff_get('LEVERAGE', 10),
|
||||
'USE_DYNAMIC_LEVERAGE': eff_get('USE_DYNAMIC_LEVERAGE', True),
|
||||
'MAX_LEVERAGE': eff_get('MAX_LEVERAGE', 20), # 动态杠杆上限 20,配合单笔仓位提高收益
|
||||
'MIN_LEVERAGE': eff_get('MIN_LEVERAGE', 8), # 动态杠杆下限,不低于此值(之前盈利阶段多为 8x,避免被压到 2–4x 导致单笔盈利过少)
|
||||
'MAX_LEVERAGE_SMALL_CAP': eff_get('MAX_LEVERAGE_SMALL_CAP', 8), # 高波动/小众币最大杠杆,默认 8 与之前盈利阶段一致
|
||||
# 盈利保护总开关与保本:关闭后不执行保本、不执行移动止损
|
||||
'PROFIT_PROTECTION_ENABLED': eff_get('PROFIT_PROTECTION_ENABLED', True), # True=启用保本+移动止损,False=全部关闭
|
||||
'LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT': eff_get('LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT', 0.03), # 盈利达保证金比例时移至保本(0.03=3%,0=关闭)
|
||||
'LOCK_PROFIT_STAGE1_TRIGGER_PCT': eff_get('LOCK_PROFIT_STAGE1_TRIGGER_PCT', 0.08), # 盈利达该比例后进入第一层锁盈
|
||||
'LOCK_PROFIT_STAGE1_PCT': eff_get('LOCK_PROFIT_STAGE1_PCT', 0.02), # 第一层锁住的利润比例
|
||||
'LOCK_PROFIT_STAGE2_TRIGGER_PCT': eff_get('LOCK_PROFIT_STAGE2_TRIGGER_PCT', 0.15), # 盈利达该比例后进入第二层锁盈
|
||||
'LOCK_PROFIT_STAGE2_PCT': eff_get('LOCK_PROFIT_STAGE2_PCT', 0.05), # 第二层锁住的利润比例
|
||||
# 移动止损
|
||||
'USE_TRAILING_STOP': eff_get('USE_TRAILING_STOP', True), # 默认启用(2026-01-27优化:启用移动止损,保护利润)
|
||||
'TRAILING_STOP_ACTIVATION': eff_get('TRAILING_STOP_ACTIVATION', 0.05), # 默认5%(2026-01-27优化:更早保护利润,避免回吐)
|
||||
'TRAILING_STOP_PROTECT': eff_get('TRAILING_STOP_PROTECT', 0.025), # 默认2.5%(2026-01-27优化:给回撤足够空间,避免被震荡扫出)
|
||||
|
||||
# 最小持仓时间锁(强制波段持仓纪律,避免分钟级平仓)
|
||||
'MIN_HOLD_TIME_SEC': eff_get('MIN_HOLD_TIME_SEC', 1800), # 默认30分钟(1800秒)
|
||||
'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%(保护更多利润)
|
||||
|
||||
# 自动交易过滤(用于提升胜率/控频)
|
||||
# 说明:这两个 key 需要出现在 TRADING_CONFIG 中,否则 trading_system 在每次 reload_from_redis 后会丢失它们,
|
||||
# 导致始终按默认值拦截自动交易(用户在配置页怎么开都没用)。
|
||||
'AUTO_TRADE_ONLY_TRENDING': eff_get('AUTO_TRADE_ONLY_TRENDING', True),
|
||||
'AUTO_TRADE_ALLOW_RANGING': eff_get('AUTO_TRADE_ALLOW_RANGING', False),
|
||||
'AUTO_TRADE_ALLOW_UNKNOWN': eff_get('AUTO_TRADE_ALLOW_UNKNOWN', False),
|
||||
'AUTO_TRADE_ALLOW_4H_NEUTRAL': eff_get('AUTO_TRADE_ALLOW_4H_NEUTRAL', allow_neutral_default),
|
||||
'RANGING_MARKET_SIGNAL_BOOST': eff_get('RANGING_MARKET_SIGNAL_BOOST', 2),
|
||||
# 自动交易白名单:非空则仅这些合约自动下单;空表示不限制(窄宇宙)
|
||||
'AUTO_TRADE_SYMBOL_WHITELIST': (eff_get('AUTO_TRADE_SYMBOL_WHITELIST', '') or '').strip(),
|
||||
'AUTO_TRADE_ONLY_TRENDING': self.get('AUTO_TRADE_ONLY_TRENDING', True),
|
||||
'AUTO_TRADE_ALLOW_4H_NEUTRAL': self.get('AUTO_TRADE_ALLOW_4H_NEUTRAL', False),
|
||||
|
||||
# 智能入场/限价偏移(部分逻辑会直接读取 TRADING_CONFIG)
|
||||
'LIMIT_ORDER_OFFSET_PCT': eff_get('LIMIT_ORDER_OFFSET_PCT', 0.5),
|
||||
'SMART_ENTRY_ENABLED': eff_get('SMART_ENTRY_ENABLED', False),
|
||||
'SMART_ENTRY_STRONG_SIGNAL': eff_get('SMART_ENTRY_STRONG_SIGNAL', min_signal_default),
|
||||
'ENTRY_SYMBOL_COOLDOWN_SEC': eff_get('ENTRY_SYMBOL_COOLDOWN_SEC', cooldown_default),
|
||||
'ENTRY_TIMEOUT_SEC': eff_get('ENTRY_TIMEOUT_SEC', 180),
|
||||
'ENTRY_STEP_WAIT_SEC': eff_get('ENTRY_STEP_WAIT_SEC', 15),
|
||||
'ENTRY_CHASE_MAX_STEPS': eff_get('ENTRY_CHASE_MAX_STEPS', 4),
|
||||
'ENTRY_MARKET_FALLBACK_AFTER_SEC': eff_get('ENTRY_MARKET_FALLBACK_AFTER_SEC', 45),
|
||||
'ENTRY_CONFIRM_TIMEOUT_SEC': eff_get('ENTRY_CONFIRM_TIMEOUT_SEC', 30),
|
||||
'ENTRY_MAX_DRIFT_PCT_TRENDING': eff_get('ENTRY_MAX_DRIFT_PCT_TRENDING', 0.006),
|
||||
'ENTRY_MAX_DRIFT_PCT_RANGING': eff_get('ENTRY_MAX_DRIFT_PCT_RANGING', 0.3),
|
||||
# Algo 条件单(止损/止盈)单次请求超时(秒),币安接口高负载时易超时,网络不稳可调大至 60
|
||||
'ALGO_ORDER_TIMEOUT_SEC': eff_get('ALGO_ORDER_TIMEOUT_SEC', 45),
|
||||
|
||||
# 持仓详细监控日志开关(用于排查问题时观察每次检查的当前价/目标价/ROE 等)
|
||||
'POSITION_DETAILED_LOG_ENABLED': eff_get('POSITION_DETAILED_LOG_ENABLED', False),
|
||||
'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),
|
||||
|
||||
# 动态过滤优化
|
||||
'BETA_FILTER_ENABLED': eff_get('BETA_FILTER_ENABLED', True), # 大盘共振过滤:BTC/ETH下跌时屏蔽多单
|
||||
'BETA_FILTER_THRESHOLD': eff_get('BETA_FILTER_THRESHOLD', -0.005), # -0.5%(2026-01-27优化:更敏感地过滤大盘风险,15分钟内跌幅超过0.5%即屏蔽多单)
|
||||
# RSI / 24h 涨跌幅过滤(避免追高杀跌)
|
||||
'MAX_RSI_FOR_LONG': eff_get('MAX_RSI_FOR_LONG', 65), # 做多时 RSI 超过此值则不开多(2026-02-12:65 避免追高)
|
||||
'MAX_CHANGE_PERCENT_FOR_LONG': eff_get('MAX_CHANGE_PERCENT_FOR_LONG', 25), # 做多时 24h 涨跌幅超过此值则不开多
|
||||
'MIN_RSI_FOR_SHORT': eff_get('MIN_RSI_FOR_SHORT', 30), # 做空时 RSI 低于此值则不做空
|
||||
'MAX_CHANGE_PERCENT_FOR_SHORT': eff_get('MAX_CHANGE_PERCENT_FOR_SHORT', 10), # 做空时 24h 涨跌幅超过此值则不做空
|
||||
# RSI 极限反转(与盈利期对齐:关闭可避免趋势里逆势止损)
|
||||
'RSI_EXTREME_REVERSE_ENABLED': eff_get('RSI_EXTREME_REVERSE_ENABLED', False),
|
||||
'RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H': eff_get('RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H', True),
|
||||
# 止盈/止损按保证金封顶(避免 TP 过远、SL 过宽扛单)
|
||||
'USE_MARGIN_CAP_FOR_TP': eff_get('USE_MARGIN_CAP_FOR_TP', True),
|
||||
'USE_MARGIN_CAP_FOR_SL': eff_get('USE_MARGIN_CAP_FOR_SL', True),
|
||||
|
||||
# 趋势尾部入场过滤 & 15m 短周期方向过滤开关(由 profile 控制默认值)
|
||||
'ENTRY_SHORT_INTERVAL': eff_get('ENTRY_SHORT_INTERVAL', '15m'),
|
||||
'ENTRY_SHORT_TREND_FILTER_ENABLED': eff_get('ENTRY_SHORT_TREND_FILTER_ENABLED', short_filter_default),
|
||||
'ENTRY_SHORT_TREND_MIN_PCT': eff_get('ENTRY_SHORT_TREND_MIN_PCT', 0.003),
|
||||
'ENTRY_SHORT_CONFIRM_CANDLES': eff_get('ENTRY_SHORT_CONFIRM_CANDLES', 3),
|
||||
'USE_TREND_ENTRY_FILTER': eff_get('USE_TREND_ENTRY_FILTER', True),
|
||||
# ⚠️ 2026-01-29优化:收紧趋势尾部过滤(稳健模式从0.05到0.04),更严格避免追高杀跌
|
||||
'MAX_TREND_MOVE_BEFORE_ENTRY': eff_get('MAX_TREND_MOVE_BEFORE_ENTRY', max_trend_move_default), # 快速模式0.08,稳健模式0.04
|
||||
'TREND_STATE_TTL_SEC': eff_get('TREND_STATE_TTL_SEC', 3600),
|
||||
'RECO_USE_TREND_ENTRY_FILTER': eff_get('RECO_USE_TREND_ENTRY_FILTER', True),
|
||||
'RECO_MAX_TREND_MOVE_BEFORE_ENTRY': eff_get('RECO_MAX_TREND_MOVE_BEFORE_ENTRY', 0.04),
|
||||
# 回撤/区间入场(前低前高)
|
||||
'ENTRY_PULLBACK_FILTER_ENABLED': eff_get('ENTRY_PULLBACK_FILTER_ENABLED', False),
|
||||
'ENTRY_PULLBACK_INTERVAL': eff_get('ENTRY_PULLBACK_INTERVAL', None),
|
||||
'ENTRY_PULLBACK_LOOKBACK_BARS': eff_get('ENTRY_PULLBACK_LOOKBACK_BARS', 24),
|
||||
'ENTRY_PULLBACK_MIN_BARS': eff_get('ENTRY_PULLBACK_MIN_BARS', 5),
|
||||
'ENTRY_PULLBACK_MAX_LONG_IN_RANGE': eff_get('ENTRY_PULLBACK_MAX_LONG_IN_RANGE', 0.62),
|
||||
'ENTRY_PULLBACK_MIN_SHORT_IN_RANGE': eff_get('ENTRY_PULLBACK_MIN_SHORT_IN_RANGE', 0.38),
|
||||
|
||||
# 影子模式半自动化
|
||||
'SHADOW_MODE_AUTO_APPLY': eff_get('SHADOW_MODE_AUTO_APPLY', False),
|
||||
'SHADOW_MODE_MIN_CONFIDENCE': eff_get('SHADOW_MODE_MIN_CONFIDENCE', 0.7),
|
||||
'SHADOW_MODE_SUGGESTIONS_PATH': eff_get('SHADOW_MODE_SUGGESTIONS_PATH', 'config/current_suggestions.json'),
|
||||
'SHADOW_MODE_TRACKING_PATH': eff_get('SHADOW_MODE_TRACKING_PATH', 'config/shadow_mode_tracking.json'),
|
||||
'SHADOW_MODE_INCREASE_LEVERAGE_MULT': eff_get('SHADOW_MODE_INCREASE_LEVERAGE_MULT', 1.5),
|
||||
'SHADOW_MODE_DECREASE_LEVERAGE_MULT': eff_get('SHADOW_MODE_DECREASE_LEVERAGE_MULT', 0.5),
|
||||
|
||||
# 当前交易预设(让 trading_system 能知道是哪种模式)
|
||||
'TRADING_PROFILE': profile,
|
||||
|
||||
# ⚠️ 2026-01-29新增:同一交易对连续亏损过滤(避免连续亏损后继续交易)
|
||||
'SYMBOL_LOSS_COOLDOWN_ENABLED': eff_get('SYMBOL_LOSS_COOLDOWN_ENABLED', True),
|
||||
'SYMBOL_MAX_CONSECUTIVE_LOSSES': eff_get('SYMBOL_MAX_CONSECUTIVE_LOSSES', 2),
|
||||
'SYMBOL_LOSS_COOLDOWN_SEC': eff_get('SYMBOL_LOSS_COOLDOWN_SEC', 3600),
|
||||
# 第一目标止盈最小盈亏比(相对止损距离)
|
||||
'MIN_RR_FOR_TP1': eff_get('MIN_RR_FOR_TP1', 1.5), # 2026-02-12:保证 TP1 至少 1.5 倍止损距离,改善盈亏比
|
||||
|
||||
# 市场状态方案(便于在不同行情间切换)
|
||||
'MARKET_SCHEME': str(eff_get('MARKET_SCHEME', 'normal') or 'normal').lower(),
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': eff_get('BLOCK_LONG_WHEN_4H_DOWN', False), # 4H 下跌时禁止开多(熊市/保守用)
|
||||
'BLOCK_SHORT_WHEN_4H_UP': eff_get('BLOCK_SHORT_WHEN_4H_UP', True), # 4H 上涨时禁止开空(默认 True,避免逆势做空)
|
||||
# 全局市场方案下禁空/禁多:牛市不推空单、熊市不推多单
|
||||
'BLOCK_SHORT_WHEN_BULL_MARKET': eff_get('BLOCK_SHORT_WHEN_BULL_MARKET', True), # 市场方案=牛市时禁止开空
|
||||
'BLOCK_LONG_WHEN_BEAR_MARKET': eff_get('BLOCK_LONG_WHEN_BEAR_MARKET', True), # 市场方案=熊市时禁止开多
|
||||
}
|
||||
|
||||
# 根据市场方案覆盖关键参数(便于快速切换熊市/牛市/保守等预设)
|
||||
_SCHEME_PRESETS = {
|
||||
'normal': {
|
||||
'MIN_STOP_LOSS_PRICE_PCT': 0.03,
|
||||
'MAX_POSITION_PERCENT': 0.12,
|
||||
'ATR_STOP_LOSS_MULTIPLIER': 2.5,
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': False,
|
||||
'BLOCK_SHORT_WHEN_4H_UP': True, # 4H 上涨不开空
|
||||
},
|
||||
'bear': {
|
||||
'MIN_STOP_LOSS_PRICE_PCT': 0.05, # 放宽止损约 -5%
|
||||
'MAX_POSITION_PERCENT': 0.08, # 单仓 ≤ 8%
|
||||
'ATR_STOP_LOSS_MULTIPLIER': 2.5,
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': True, # 4H 下跌不开多
|
||||
'BLOCK_SHORT_WHEN_4H_UP': True, # 4H 上涨不开空
|
||||
'BETA_FILTER_ENABLED': True,
|
||||
},
|
||||
'bull': {
|
||||
'MIN_STOP_LOSS_PRICE_PCT': 0.03,
|
||||
'MAX_POSITION_PERCENT': 0.12,
|
||||
'ATR_STOP_LOSS_MULTIPLIER': 2.0,
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': False,
|
||||
'BLOCK_SHORT_WHEN_4H_UP': True, # 4H 上涨不开空(牛市尤需)
|
||||
},
|
||||
'conservative': {
|
||||
'MIN_STOP_LOSS_PRICE_PCT': 0.06, # 最宽松止损
|
||||
'MAX_POSITION_PERCENT': 0.06, # 最小仓位
|
||||
'ATR_STOP_LOSS_MULTIPLIER': 2.5,
|
||||
'BLOCK_LONG_WHEN_4H_DOWN': True,
|
||||
'BLOCK_SHORT_WHEN_4H_UP': True,
|
||||
'BETA_FILTER_ENABLED': True,
|
||||
},
|
||||
}
|
||||
scheme = result.get('MARKET_SCHEME', 'normal') or 'normal'
|
||||
if scheme in _SCHEME_PRESETS:
|
||||
for k, v in _SCHEME_PRESETS[scheme].items():
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
def _sync_to_redis(self):
|
||||
"""将配置同步到Redis缓存(账号维度)"""
|
||||
if not self._redis_connected or not self._redis_client:
|
||||
return
|
||||
try:
|
||||
payload = {k: json.dumps(v) for k, v in self._cache.items()}
|
||||
self._redis_client.hset(self._redis_hash_key, mapping=payload)
|
||||
self._redis_client.expire(self._redis_hash_key, 3600)
|
||||
if self._legacy_hash_key:
|
||||
self._redis_client.hset(self._legacy_hash_key, mapping=payload)
|
||||
self._redis_client.expire(self._legacy_hash_key, 3600)
|
||||
except Exception as e:
|
||||
logger.debug(f"同步配置到Redis失败: {e}")
|
||||
|
||||
# 全局配置管理器实例(默认账号;trading_system 进程可通过 ATS_ACCOUNT_ID 指定)
|
||||
try:
|
||||
_default_account_id = int(os.getenv("ATS_ACCOUNT_ID") or os.getenv("ACCOUNT_ID") or 1)
|
||||
except Exception:
|
||||
_default_account_id = 1
|
||||
config_manager = ConfigManager.for_account(_default_account_id)
|
||||
# 全局配置管理器实例
|
||||
config_manager = ConfigManager()
|
||||
|
||||
# 兼容原有config.py的接口
|
||||
def get_config(key, default=None):
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
-- 登录与权限系统迁移脚本(在已有库上执行一次)
|
||||
-- 目标:
|
||||
-- 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='用户-交易账号授权';
|
||||
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
-- 币安订单/成交同步表,供定时任务拉取后存储,数据管理从 DB 查询分析
|
||||
-- 执行: mysql -u user -p db_name < add_binance_sync_tables.sql
|
||||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
-- 币安成交记录(userTrades)
|
||||
CREATE TABLE IF NOT EXISTS `binance_trades` (
|
||||
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
`account_id` INT UNSIGNED NOT NULL,
|
||||
`symbol` VARCHAR(32) NOT NULL,
|
||||
`trade_id` BIGINT UNSIGNED NOT NULL COMMENT '币安 trade id',
|
||||
`order_id` BIGINT UNSIGNED NOT NULL,
|
||||
`side` VARCHAR(10) NOT NULL,
|
||||
`position_side` VARCHAR(10) DEFAULT NULL,
|
||||
`price` DECIMAL(24, 8) NOT NULL,
|
||||
`qty` DECIMAL(24, 8) NOT NULL,
|
||||
`quote_qty` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`realized_pnl` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`commission` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`commission_asset` VARCHAR(20) DEFAULT NULL,
|
||||
`buyer` TINYINT(1) DEFAULT NULL,
|
||||
`maker` TINYINT(1) DEFAULT NULL,
|
||||
`trade_time` BIGINT UNSIGNED NOT NULL COMMENT '成交时间戳毫秒',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_account_trade` (`account_id`, `trade_id`),
|
||||
INDEX `idx_account_time` (`account_id`, `trade_time`),
|
||||
INDEX `idx_symbol_time` (`account_id`, `symbol`, `trade_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='币安成交记录(定时同步)';
|
||||
|
||||
-- 币安订单记录(allOrders)
|
||||
CREATE TABLE IF NOT EXISTS `binance_orders` (
|
||||
`id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
`account_id` INT UNSIGNED NOT NULL,
|
||||
`symbol` VARCHAR(32) NOT NULL,
|
||||
`order_id` BIGINT UNSIGNED NOT NULL,
|
||||
`client_order_id` VARCHAR(64) DEFAULT NULL,
|
||||
`side` VARCHAR(10) NOT NULL,
|
||||
`type` VARCHAR(32) DEFAULT NULL,
|
||||
`orig_type` VARCHAR(32) DEFAULT NULL,
|
||||
`status` VARCHAR(32) NOT NULL,
|
||||
`price` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`avg_price` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`orig_qty` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`executed_qty` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`cum_qty` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`cum_quote` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`stop_price` DECIMAL(24, 8) DEFAULT NULL,
|
||||
`reduce_only` TINYINT(1) DEFAULT NULL,
|
||||
`position_side` VARCHAR(10) DEFAULT NULL,
|
||||
`order_time` BIGINT UNSIGNED NOT NULL COMMENT '下单时间戳毫秒',
|
||||
`update_time` BIGINT UNSIGNED DEFAULT NULL COMMENT '更新时间戳毫秒',
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_account_order` (`account_id`, `order_id`),
|
||||
INDEX `idx_account_time` (`account_id`, `order_time`),
|
||||
INDEX `idx_symbol_time` (`account_id`, `symbol`, `order_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='币安订单记录(定时同步)';
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
-- 为 trades 表增加「自定义订单号」字段,用于存储币安 clientOrderId,便于在订单记录中核对系统单
|
||||
-- 若已存在该列可跳过本句
|
||||
ALTER TABLE trades ADD COLUMN client_order_id VARCHAR(64) NULL COMMENT '币安自定义订单号 clientOrderId(系统单格式: 前缀_时间戳_随机)' AFTER entry_order_id;
|
||||
-- 可选:为按自定义订单号查询建索引(若已存在可跳过)
|
||||
-- CREATE INDEX idx_client_order_id ON trades (client_order_id);
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
-- 为 trades 表增加 created_at(创建时间)字段(仅当不存在时)
|
||||
-- 用于持仓/订单展示「开仓时间」时至少有创建时间可显示;与 init.sql 中定义一致。
|
||||
|
||||
-- MySQL 5.7+:通过 procedure 判断后添加,避免重复执行报错
|
||||
DELIMITER //
|
||||
DROP PROCEDURE IF EXISTS add_created_at_to_trades_if_missing//
|
||||
CREATE PROCEDURE add_created_at_to_trades_if_missing()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'trades' AND COLUMN_NAME = 'created_at') = 0 THEN
|
||||
ALTER TABLE trades
|
||||
ADD COLUMN created_at INT UNSIGNED NULL COMMENT '创建时间(Unix时间戳秒数)' AFTER status;
|
||||
UPDATE trades SET created_at = COALESCE(entry_time, UNIX_TIMESTAMP()) WHERE created_at IS NULL;
|
||||
ALTER TABLE trades
|
||||
MODIFY COLUMN created_at INT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()) COMMENT '创建时间(Unix时间戳秒数)';
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
CALL add_created_at_to_trades_if_missing();
|
||||
DROP PROCEDURE IF EXISTS add_created_at_to_trades_if_missing;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
-- 为 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;
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
-- 创建全局策略配置表(独立于账户)
|
||||
-- 全局配置不依赖任何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;
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
-- 市场缓存表:存放较固定的交易所数据(交易对信息、资金费率规则等),减少 API 调用
|
||||
-- 执行: mysql -u root -p auto_trade_sys < add_market_cache.sql
|
||||
|
||||
USE `auto_trade_sys`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `market_cache` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`cache_key` VARCHAR(128) NOT NULL COMMENT '如 exchange_info, funding_info',
|
||||
`cache_value` LONGTEXT NOT NULL COMMENT 'JSON 内容',
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY `uk_cache_key` (`cache_key`),
|
||||
INDEX `idx_updated_at` (`updated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市场数据缓存(交易对/费率规则等)';
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
-- 多账号迁移脚本(在已有库上执行一次)
|
||||
-- 目标:
|
||||
-- 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);
|
||||
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
-- 可选:订单类型字段,便于统计与策略分析(开仓/平仓方式)
|
||||
-- 执行前请确认表已存在;若列已存在可跳过
|
||||
|
||||
-- 开仓订单类型:LIMIT / MARKET 等(来自币安订单 type)
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS entry_order_type VARCHAR(32) NULL COMMENT '开仓订单类型 LIMIT/MARKET' AFTER client_order_id;
|
||||
|
||||
-- 平仓订单类型:MARKET / STOP_MARKET / TAKE_PROFIT_MARKET 等(便于区分市价平、止损、止盈)
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS exit_order_type VARCHAR(32) NULL COMMENT '平仓订单类型' AFTER exit_order_id;
|
||||
|
||||
-- 来源口径:仅自动下单入 DB 时可固定为 auto_trade,预留便于扩展
|
||||
-- ALTER TABLE trades ADD COLUMN IF NOT EXISTS source VARCHAR(32) NULL DEFAULT 'auto_trade' COMMENT '记录来源 auto_trade' AFTER entry_reason;
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
-- 分步止盈状态细分:添加新的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%仓位)后,剩余仓位移动止损
|
||||
--
|
||||
-- 这些状态用于更准确地统计胜率和盈亏比:
|
||||
-- - 第一目标止盈后剩余仓位止损,应该算作"部分成功"(第一目标已达成)
|
||||
-- - 第一目标止盈后剩余仓位第二目标止盈,应该算作"完整成功"
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS realized_pnl DECIMAL(20, 8) DEFAULT NULL COMMENT '币安实际结算盈亏(包含资金费率等)';
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS commission DECIMAL(20, 8) DEFAULT NULL COMMENT '交易手续费(USDT计价)';
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS commission_asset VARCHAR(10) DEFAULT NULL COMMENT '手续费币种(BNB/USDT)';
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
-- 清理「非交易系统下单」的交易记录(无开仓订单号的记录)
|
||||
-- 本系统开仓会在成交后保存 entry_order_id;无该字段或为 0 的为同步补录/其它来源,可安全删除。
|
||||
-- 执行前请先备份数据库或至少备份 trades 表。
|
||||
-- 若表结构较旧、没有 entry_order_id 列,请先执行 add_order_ids.sql 或跳过本脚本。
|
||||
|
||||
-- 1) 查看将要删除的记录数(按账号)
|
||||
SELECT account_id, status, COUNT(*) AS cnt
|
||||
FROM trades
|
||||
WHERE entry_order_id IS NULL OR entry_order_id = 0
|
||||
GROUP BY account_id, status
|
||||
ORDER BY account_id, status;
|
||||
|
||||
-- 2) 查看将要删除的总数
|
||||
SELECT COUNT(*) AS will_delete FROM trades
|
||||
WHERE entry_order_id IS NULL OR entry_order_id = 0;
|
||||
|
||||
-- 3) 确认无误后执行删除(建议先备份:mysqldump -u user -p db_name trades > trades_backup.sql)
|
||||
-- DELETE FROM trades
|
||||
-- WHERE entry_order_id IS NULL OR entry_order_id = 0;
|
||||
|
||||
-- 若只清理指定账号,可加上条件,例如:
|
||||
-- DELETE FROM trades
|
||||
-- WHERE (entry_order_id IS NULL OR entry_order_id = 0) AND account_id = 1;
|
||||
|
|
@ -6,7 +6,6 @@ from contextlib import contextmanager
|
|||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine, pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -42,103 +41,46 @@ except Exception as e:
|
|||
|
||||
|
||||
class Database:
|
||||
"""数据库连接类(使用SQLAlchemy连接池)"""
|
||||
|
||||
_engine = None
|
||||
"""数据库连接类"""
|
||||
|
||||
def __init__(self):
|
||||
self.host = os.getenv('DB_HOST', 'localhost')
|
||||
self.port = int(os.getenv('DB_PORT', 3306))
|
||||
self.user = os.getenv('DB_USER', 'root')
|
||||
self.password = os.getenv('DB_PASSWORD', '')
|
||||
self.database = os.getenv('DB_NAME', 'auto_trade_sys_new')
|
||||
self.database = os.getenv('DB_NAME', 'auto_trade_sys')
|
||||
|
||||
# 记录配置信息(不显示密码)
|
||||
logger.debug(f"数据库配置: host={self.host}, port={self.port}, user={self.user}, database={self.database}")
|
||||
|
||||
# 初始化连接池
|
||||
self._init_engine()
|
||||
|
||||
def _init_engine(self):
|
||||
"""初始化SQLAlchemy引擎和连接池"""
|
||||
if Database._engine is None:
|
||||
# 构建数据库URL
|
||||
# 注意:密码中如果有特殊字符需要转义,这里简单处理
|
||||
from urllib.parse import quote_plus
|
||||
encoded_password = quote_plus(self.password)
|
||||
db_url = f"mysql+pymysql://{self.user}:{encoded_password}@{self.host}:{self.port}/{self.database}?charset=utf8mb4"
|
||||
|
||||
try:
|
||||
Database._engine = create_engine(
|
||||
db_url,
|
||||
pool_size=20, # 基础连接池大小
|
||||
max_overflow=30, # 最大溢出连接数
|
||||
pool_recycle=3600, # 连接回收时间(秒)
|
||||
pool_timeout=30, # 获取连接超时时间(秒)
|
||||
pool_pre_ping=True, # 预检测连接是否可用
|
||||
connect_args={
|
||||
# 'cursorclass': pymysql.cursors.DictCursor, # Removed to prevent KeyError: 0 in SQLAlchemy init
|
||||
'autocommit': False
|
||||
}
|
||||
)
|
||||
logger.info("数据库连接池初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接池初始化失败: {e}")
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def get_connection(self):
|
||||
"""获取数据库连接(从连接池)"""
|
||||
"""获取数据库连接(上下文管理器)"""
|
||||
conn = None
|
||||
try:
|
||||
# 获取原始pymysql连接
|
||||
conn = Database._engine.raw_connection()
|
||||
|
||||
# Explicitly set cursor class to DictCursor since we removed it from create_engine
|
||||
# We need to set it on the underlying DBAPI connection
|
||||
try:
|
||||
if hasattr(conn, 'driver_connection'):
|
||||
# SQLAlchemy 2.0+
|
||||
conn.driver_connection.cursorclass = pymysql.cursors.DictCursor
|
||||
elif hasattr(conn, 'connection'):
|
||||
# Older SQLAlchemy
|
||||
conn.connection.cursorclass = pymysql.cursors.DictCursor
|
||||
else:
|
||||
# Fallback
|
||||
conn.cursorclass = pymysql.cursors.DictCursor
|
||||
except Exception as e:
|
||||
logger.warning(f"设置DictCursor失败: {e}")
|
||||
|
||||
conn = pymysql.connect(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
database=self.database,
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
autocommit=False
|
||||
)
|
||||
# 设置时区为北京时间(UTC+8)
|
||||
# 注意:raw_connection可能不自动应用connect_args中的autocommit,需确认
|
||||
# SQLAlchemy的raw_connection通常返回DBAPI连接,autocommit行为取决于驱动
|
||||
# 这里显式关闭autocommit以保持兼容性
|
||||
try:
|
||||
conn.autocommit(False)
|
||||
except AttributeError:
|
||||
# 某些旧版本pymysql或wrapper可能不支持方法调用,尝试属性赋值
|
||||
pass
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SET time_zone = '+08:00'")
|
||||
# 注意:不在这里commit,除非是只读操作。调用者负责commit/rollback
|
||||
# 但原代码在yield前commit了时区设置?
|
||||
# 原代码:cursor.execute(...); conn.commit(); yield conn
|
||||
# SET time_zone 不需要 commit,但为了保险起见保留原行为
|
||||
conn.commit()
|
||||
|
||||
conn.commit()
|
||||
yield conn
|
||||
except Exception as e:
|
||||
if conn:
|
||||
try:
|
||||
conn.rollback()
|
||||
except:
|
||||
pass
|
||||
conn.rollback()
|
||||
logger.error(f"数据库连接错误: {e}")
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close() # 归还给连接池
|
||||
conn.close()
|
||||
|
||||
def execute_query(self, query, params=None):
|
||||
"""执行查询,返回所有结果"""
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
-- 按 entry_order_id + symbol 去重:同一开仓订单只保留一条(保留 id 最小的,即最早创建的)
|
||||
-- 使用前请先备份 trades 表;建议先执行「1. 查看重复」确认后再执行「2. 删除重复」
|
||||
-- 说明:仅处理 entry_order_id 非空的重复;无开仓订单号的重复记录(如 sync_recovered 脏数据)需人工按 symbol/时间判断后删除。
|
||||
|
||||
-- ========== 1. 查看重复(只读,不写库)==========
|
||||
-- 列出所有 (entry_order_id, symbol) 出现多于一次的组,以及每组中的记录
|
||||
SELECT
|
||||
t.entry_order_id,
|
||||
t.symbol,
|
||||
COUNT(*) AS cnt,
|
||||
GROUP_CONCAT(t.id ORDER BY t.id) AS ids,
|
||||
GROUP_CONCAT(CONCAT(t.id, '(', t.status, ',entry=', FROM_UNIXTIME(t.entry_time), ',exit=', IFNULL(FROM_UNIXTIME(t.exit_time), 'NULL'), ')') ORDER BY t.id SEPARATOR ' | ') AS detail
|
||||
FROM trades t
|
||||
WHERE t.entry_order_id IS NOT NULL
|
||||
GROUP BY t.entry_order_id, t.symbol
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 若有多账号,按 account_id 也分组查看(可选):
|
||||
-- SELECT account_id, entry_order_id, symbol, COUNT(*) AS cnt, GROUP_CONCAT(id ORDER BY id) AS ids
|
||||
-- FROM trades WHERE entry_order_id IS NOT NULL
|
||||
-- GROUP BY account_id, entry_order_id, symbol HAVING COUNT(*) > 1;
|
||||
|
||||
|
||||
-- ========== 2. 删除重复(保留每组 id 最小的那条,删除同组其余行)==========
|
||||
-- 执行前请确认上面查询结果符合预期;建议先备份: CREATE TABLE trades_backup_YYYYMMDD AS SELECT * FROM trades;
|
||||
|
||||
DELETE t
|
||||
FROM trades t
|
||||
INNER JOIN (
|
||||
SELECT entry_order_id, symbol, MIN(id) AS keep_id
|
||||
FROM trades
|
||||
WHERE entry_order_id IS NOT NULL
|
||||
GROUP BY entry_order_id, symbol
|
||||
HAVING COUNT(*) > 1
|
||||
) g ON t.entry_order_id = g.entry_order_id AND t.symbol = g.symbol AND t.id <> g.keep_id;
|
||||
|
||||
-- 若有多账号,按 account_id 去重(取消下面注释并注释掉上面的 DELETE):
|
||||
/*
|
||||
DELETE t
|
||||
FROM trades t
|
||||
INNER JOIN (
|
||||
SELECT account_id, entry_order_id, symbol, MIN(id) AS keep_id
|
||||
FROM trades
|
||||
WHERE entry_order_id IS NOT NULL
|
||||
GROUP BY account_id, entry_order_id, symbol
|
||||
HAVING COUNT(*) > 1
|
||||
) g ON t.account_id = g.account_id AND t.entry_order_id = g.entry_order_id AND t.symbol = g.symbol AND t.id <> g.keep_id;
|
||||
*/
|
||||
|
||||
|
||||
-- ========== 3. 再次检查(应无重复)==========
|
||||
SELECT entry_order_id, symbol, COUNT(*) AS cnt
|
||||
FROM trades
|
||||
WHERE entry_order_id IS NOT NULL
|
||||
GROUP BY entry_order_id, symbol
|
||||
HAVING COUNT(*) > 1;
|
||||
-- 期望结果:空
|
||||
|
|
@ -4,69 +4,22 @@ 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,
|
||||
`account_id` INT NOT NULL DEFAULT 1,
|
||||
`config_key` VARCHAR(100) NOT NULL,
|
||||
`config_key` VARCHAR(100) UNIQUE 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_account_id` (`account_id`),
|
||||
UNIQUE KEY `uk_account_config_key` (`account_id`, `config_key`)
|
||||
INDEX `idx_category` (`category`)
|
||||
) 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,
|
||||
|
|
@ -92,7 +45,6 @@ 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`),
|
||||
|
|
@ -105,14 +57,12 @@ 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='账户快照表';
|
||||
|
||||
|
|
@ -208,11 +158,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.02', 'number', 'risk', '最小止盈价格变动:2%(防止ATR过小时计算出不切实际的微小止盈距离)'),
|
||||
('MIN_TAKE_PROFIT_PRICE_PCT', '0.03', 'number', 'risk', '最小止盈价格变动:3%(防止止盈过紧)'),
|
||||
('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', '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_TAKE_PROFIT_MULTIPLIER', '3.0', 'number', 'risk', 'ATR止盈倍数(3倍ATR,对应3:1盈亏比)'),
|
||||
('RISK_REWARD_RATIO', '3.0', 'number', 'risk', '盈亏比(止损距离的倍数,用于计算止盈)'),
|
||||
('ATR_PERIOD', '14', 'number', 'risk', 'ATR计算周期(默认14)'),
|
||||
('USE_DYNAMIC_ATR_MULTIPLIER', 'false', 'boolean', 'risk', '是否根据波动率动态调整ATR倍数'),
|
||||
('ATR_MULTIPLIER_MIN', '1.5', 'number', 'risk', '动态ATR倍数最小值'),
|
||||
|
|
@ -234,8 +184,6 @@ INSERT INTO `trading_config` (`config_key`, `config_value`, `config_type`, `cate
|
|||
('LEVERAGE', '10', 'number', 'strategy', '基础杠杆倍数'),
|
||||
('USE_DYNAMIC_LEVERAGE', 'true', 'boolean', 'strategy', '是否启用动态杠杆(根据信号强度调整杠杆倍数)'),
|
||||
('MAX_LEVERAGE', '15', 'number', 'strategy', '最大杠杆倍数(动态杠杆上限,降低到15更保守)'),
|
||||
('PROFIT_PROTECTION_ENABLED', 'true', 'boolean', 'strategy', '盈利保护总开关:启用保本+移动止损'),
|
||||
('LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT', '0.03', 'number', 'strategy', '盈利达保证金比例时移至保本(0.03=3%,0=关闭)'),
|
||||
('USE_TRAILING_STOP', 'true', 'boolean', 'strategy', '是否使用移动止损'),
|
||||
('TRAILING_STOP_ACTIVATION', '0.10', 'number', 'strategy', '移动止损激活阈值(盈利10%后激活,给趋势更多空间)'),
|
||||
('TRAILING_STOP_PROTECT', '0.05', 'number', 'strategy', '移动止损保护利润(保护5%利润,更合理)'),
|
||||
|
|
@ -247,12 +195,6 @@ INSERT INTO `trading_config` (`config_key`, `config_value`, `config_type`, `cate
|
|||
-- API配置
|
||||
('BINANCE_API_KEY', '', 'string', 'api', '币安API密钥'),
|
||||
('BINANCE_API_SECRET', '', 'string', 'api', '币安API密钥'),
|
||||
('USE_TESTNET', 'false', 'boolean', 'api', '是否使用测试网'),
|
||||
|
||||
-- 与盈利期对齐(2026-02-15)
|
||||
('RSI_EXTREME_REVERSE_ENABLED', 'false', 'boolean', 'strategy', '关闭RSI极限反转,与盈利期一致'),
|
||||
('RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H', 'true', 'boolean', 'strategy', '若开启反向仅允许4H中性'),
|
||||
('USE_MARGIN_CAP_FOR_TP', 'true', 'boolean', 'risk', '止盈按保证金封顶,避免过远'),
|
||||
('USE_MARGIN_CAP_FOR_SL', 'true', 'boolean', 'risk', '止损按保证金封顶,避免扛单')
|
||||
('USE_TESTNET', 'false', 'boolean', 'api', '是否使用测试网')
|
||||
|
||||
ON DUPLICATE KEY UPDATE `config_value` = VALUES(`config_value`);
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
-- ============================================================
|
||||
-- 配置值格式统一迁移脚本
|
||||
-- 将百分比形式(>1)转换为比例形式(除以100)
|
||||
-- 执行时间:2026-01-26
|
||||
-- ============================================================
|
||||
|
||||
-- 说明:
|
||||
-- 此脚本将数据库中的百分比配置项从百分比形式(如30表示30%)
|
||||
-- 转换为比例形式(如0.30表示30%),以统一数据格式。
|
||||
|
||||
-- ⚠️ 重要:执行前请备份数据库!
|
||||
|
||||
-- ============================================================
|
||||
-- 1. 备份表(强烈推荐)
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS trading_config_backup_20260126 AS
|
||||
SELECT * FROM trading_config;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_strategy_config_backup_20260126 AS
|
||||
SELECT * FROM global_strategy_config;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. 迁移 trading_config 表
|
||||
-- ============================================================
|
||||
UPDATE trading_config
|
||||
SET config_value = CAST(config_value AS DECIMAL(10, 4)) / 100.0
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. 迁移 global_strategy_config 表
|
||||
-- ============================================================
|
||||
UPDATE global_strategy_config
|
||||
SET config_value = CAST(config_value AS DECIMAL(10, 4)) / 100.0
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. 验证迁移结果
|
||||
-- ============================================================
|
||||
-- 检查是否还有>1的百分比配置项(应该返回0行)
|
||||
SELECT 'trading_config' as table_name, config_key, config_value, account_id
|
||||
FROM trading_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1
|
||||
UNION ALL
|
||||
SELECT 'global_strategy_config' as table_name, config_key, config_value, NULL as account_id
|
||||
FROM global_strategy_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT',
|
||||
'MIN_STOP_LOSS_PRICE_PCT',
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT',
|
||||
'FIXED_RISK_PERCENT',
|
||||
'MAX_POSITION_PERCENT',
|
||||
'MAX_TOTAL_POSITION_PERCENT',
|
||||
'MIN_POSITION_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
AND CAST(config_value AS DECIMAL(10, 4)) > 1;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. 查看迁移结果(可选)
|
||||
-- ============================================================
|
||||
-- 查看迁移后的配置值
|
||||
SELECT config_key, config_value, account_id
|
||||
FROM trading_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
ORDER BY config_key, account_id;
|
||||
|
||||
SELECT config_key, config_value
|
||||
FROM global_strategy_config
|
||||
WHERE config_key IN (
|
||||
'TRAILING_STOP_ACTIVATION',
|
||||
'TRAILING_STOP_PROTECT',
|
||||
'MIN_VOLATILITY',
|
||||
'TAKE_PROFIT_PERCENT',
|
||||
'STOP_LOSS_PERCENT'
|
||||
)
|
||||
AND config_type = 'number'
|
||||
ORDER BY config_key;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,15 +0,0 @@
|
|||
-- 与盈利期对齐:RSI 关闭反向 + 止盈/止损封顶(2026-02-15)
|
||||
-- 【重要】只更新【全局配置】表 global_strategy_config(无 account_id,策略只读此表)。
|
||||
-- 不修改 trading_config(个人/账号配置);个人用不到,请用本脚本或前端「全局配置」页,不要改个人配置。
|
||||
|
||||
INSERT INTO `global_strategy_config` (`config_key`, `config_value`, `config_type`, `category`, `description`) VALUES
|
||||
('RSI_EXTREME_REVERSE_ENABLED', 'false', 'boolean', 'strategy', '关闭RSI极限反转,与盈利期一致'),
|
||||
('RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H', 'true', 'boolean', 'strategy', '若开启反向仅允许4H中性'),
|
||||
('USE_MARGIN_CAP_FOR_TP', 'true', 'boolean', 'risk', '止盈按保证金封顶,避免过远'),
|
||||
('USE_MARGIN_CAP_FOR_SL', 'true', 'boolean', 'risk', '止损按保证金封顶,避免扛单')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`config_value` = VALUES(`config_value`),
|
||||
`config_type` = VALUES(`config_type`),
|
||||
`category` = VALUES(`category`),
|
||||
`description` = VALUES(`description`),
|
||||
`updated_at` = CURRENT_TIMESTAMP;
|
||||
|
|
@ -1,347 +0,0 @@
|
|||
"""
|
||||
市场行情概览 - 用于全局配置页展示
|
||||
拉取 Binance 公开接口(无需 API Key),与策略过滤逻辑对应的数据。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINANCE_FUTURES_BASE = "https://fapi.binance.com"
|
||||
BINANCE_FUTURES_DATA = "https://fapi.binance.com/futures/data"
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
|
||||
def _http_get(url: str, params: Optional[dict] = None) -> Optional[Any]:
|
||||
"""发起 GET 请求,返回 JSON 或 None。"""
|
||||
if params:
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
url = f"{url}?{qs}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
ctx = ssl.create_default_context()
|
||||
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT, context=ctx) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
logger.debug("market_overview HTTP GET 失败 %s: %s", url[:80], e)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_klines(symbol: str, interval: str, limit: int) -> Optional[list]:
|
||||
"""获取 K 线数据。"""
|
||||
data = _http_get(
|
||||
f"{BINANCE_FUTURES_BASE}/fapi/v1/klines",
|
||||
{"symbol": symbol, "interval": interval, "limit": limit},
|
||||
)
|
||||
return data if isinstance(data, list) else None
|
||||
|
||||
|
||||
def _compute_change_from_klines(klines: list, periods: int) -> Optional[float]:
|
||||
"""根据 K 线计算最近 N 根的总涨跌幅(比例,如 -0.0167 表示 -1.67%)。"""
|
||||
if not klines or len(klines) < periods + 1:
|
||||
return None
|
||||
first_close = float(klines[0][4])
|
||||
last_close = float(klines[-1][4])
|
||||
return (last_close - first_close) / first_close if first_close else None
|
||||
|
||||
|
||||
def fetch_symbol_change_period(symbol: str, interval: str, periods: int) -> Optional[float]:
|
||||
"""获取指定交易对在指定周期内的涨跌幅(比例)。"""
|
||||
klines = _fetch_klines(symbol, interval, periods + 1)
|
||||
return _compute_change_from_klines(klines, periods) if klines else None
|
||||
|
||||
|
||||
def fetch_ticker_24h(symbol: str) -> Optional[Dict]:
|
||||
"""获取 24h ticker。"""
|
||||
data = _http_get(f"{BINANCE_FUTURES_BASE}/fapi/v1/ticker/24hr", {"symbol": symbol})
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def fetch_premium_index(symbol: str) -> Optional[Dict]:
|
||||
"""获取资金费率等。"""
|
||||
data = _http_get(f"{BINANCE_FUTURES_BASE}/fapi/v1/premiumIndex", {"symbol": symbol})
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def fetch_long_short_ratio(symbol: str = "BTCUSDT", period: str = "1d", limit: int = 1) -> Optional[float]:
|
||||
"""获取大户多空比。"""
|
||||
data = _http_get(
|
||||
f"{BINANCE_FUTURES_DATA}/topLongShortPositionRatio",
|
||||
{"symbol": symbol, "period": period, "limit": limit},
|
||||
)
|
||||
if not isinstance(data, list) or len(data) == 0:
|
||||
return None
|
||||
try:
|
||||
return float(data[-1].get("longShortRatio", 1))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def get_market_overview() -> Dict[str, Any]:
|
||||
"""
|
||||
获取市场行情概览,与策略过滤逻辑对应的数据。
|
||||
供全局配置页展示,帮助用户确认当前策略方案是否匹配市场。
|
||||
"""
|
||||
result = {
|
||||
"btc_24h_change_pct": None,
|
||||
"eth_24h_change_pct": None,
|
||||
"btc_15m_change_pct": None,
|
||||
"btc_1h_change_pct": None,
|
||||
"eth_15m_change_pct": None,
|
||||
"eth_1h_change_pct": None,
|
||||
"btc_funding_rate": None,
|
||||
"eth_funding_rate": None,
|
||||
"btc_long_short_ratio": None,
|
||||
"btc_trend_4h": None,
|
||||
"market_regime": None,
|
||||
"beta_filter_triggered": None,
|
||||
}
|
||||
|
||||
# 24h 涨跌幅
|
||||
btc_ticker = fetch_ticker_24h("BTCUSDT")
|
||||
if btc_ticker is not None:
|
||||
try:
|
||||
result["btc_24h_change_pct"] = round(float(btc_ticker.get("priceChangePercent", 0)), 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
eth_ticker = fetch_ticker_24h("ETHUSDT")
|
||||
if eth_ticker is not None:
|
||||
try:
|
||||
result["eth_24h_change_pct"] = round(float(eth_ticker.get("priceChangePercent", 0)), 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 15m / 1h 涨跌幅(大盘共振过滤用)
|
||||
btc_15m = fetch_symbol_change_period("BTCUSDT", "15m", 5)
|
||||
btc_1h = fetch_symbol_change_period("BTCUSDT", "1h", 3)
|
||||
eth_15m = fetch_symbol_change_period("ETHUSDT", "15m", 5)
|
||||
eth_1h = fetch_symbol_change_period("ETHUSDT", "1h", 3)
|
||||
if btc_15m is not None:
|
||||
result["btc_15m_change_pct"] = round(btc_15m * 100, 2)
|
||||
if btc_1h is not None:
|
||||
result["btc_1h_change_pct"] = round(btc_1h * 100, 2)
|
||||
if eth_15m is not None:
|
||||
result["eth_15m_change_pct"] = round(eth_15m * 100, 2)
|
||||
if eth_1h is not None:
|
||||
result["eth_1h_change_pct"] = round(eth_1h * 100, 2)
|
||||
|
||||
# 资金费率
|
||||
btc_prem = fetch_premium_index("BTCUSDT")
|
||||
if btc_prem is not None:
|
||||
try:
|
||||
result["btc_funding_rate"] = round(float(btc_prem.get("lastFundingRate", 0)), 6)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
eth_prem = fetch_premium_index("ETHUSDT")
|
||||
if eth_prem is not None:
|
||||
try:
|
||||
result["eth_funding_rate"] = round(float(eth_prem.get("lastFundingRate", 0)), 6)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 大户多空比
|
||||
lsr = fetch_long_short_ratio("BTCUSDT", "1d", 1)
|
||||
if lsr is not None:
|
||||
result["btc_long_short_ratio"] = round(lsr, 4)
|
||||
|
||||
# 4H 趋势
|
||||
klines_4h = _fetch_klines("BTCUSDT", "4h", 60)
|
||||
if klines_4h and len(klines_4h) >= 21:
|
||||
try:
|
||||
from trading_system.market_regime_detector import compute_trend_4h_from_klines
|
||||
result["btc_trend_4h"] = compute_trend_4h_from_klines(klines_4h)
|
||||
except Exception:
|
||||
result["btc_trend_4h"] = _simple_trend_4h(klines_4h)
|
||||
|
||||
# 市场状态(bull/bear/normal)
|
||||
try:
|
||||
from trading_system.market_regime_detector import detect_market_regime
|
||||
regime, details = detect_market_regime()
|
||||
result["market_regime"] = regime
|
||||
result["market_regime_details"] = details
|
||||
except Exception as e:
|
||||
logger.debug("market_overview 获取市场状态失败: %s", e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _simple_trend_4h(klines: list) -> str:
|
||||
"""简化 4H 趋势:价格 vs 最近一根 K 线前 20 根均价。"""
|
||||
if len(klines) < 21:
|
||||
return "neutral"
|
||||
closes = [float(k[4]) for k in klines]
|
||||
price = closes[-1]
|
||||
avg20 = sum(closes[-21:-1]) / 20
|
||||
if price > avg20 * 1.002:
|
||||
return "up"
|
||||
if price < avg20 * 0.998:
|
||||
return "down"
|
||||
return "neutral"
|
||||
|
||||
|
||||
def _g(key: str, default: Any, cfg: dict) -> Any:
|
||||
"""从配置字典取键,支持 bool/数字/字符串。"""
|
||||
v = cfg.get(key, default)
|
||||
if v is None:
|
||||
return default
|
||||
if isinstance(default, bool):
|
||||
return str(v).lower() in ("true", "1", "yes")
|
||||
return v
|
||||
|
||||
|
||||
def get_strategy_execution_overview() -> Dict[str, Any]:
|
||||
"""
|
||||
生成「策略执行概览」:当前执行方案、配置项执行情况,用易读文字描述整体策略执行标准与机制。
|
||||
供全局配置页「策略执行概览」展示。
|
||||
返回格式:{ "sections": [ { "title": "小节标题", "content": "正文" } ] }
|
||||
"""
|
||||
sections = []
|
||||
cfg = {}
|
||||
try:
|
||||
from config_manager import GlobalStrategyConfigManager
|
||||
mgr = GlobalStrategyConfigManager()
|
||||
mgr.reload_from_redis()
|
||||
for key in (
|
||||
"AUTO_TRADE_ENABLED", "AUTO_TRADE_ONLY_TRENDING", "AUTO_TRADE_ALLOW_4H_NEUTRAL",
|
||||
"AUTO_TRADE_SYMBOL_WHITELIST",
|
||||
"MIN_SIGNAL_STRENGTH", "LOW_VOLATILITY_MIN_SIGNAL_STRENGTH", "MARKET_REGIME_AUTO",
|
||||
"TOP_N_SYMBOLS", "SCAN_INTERVAL", "PRIMARY_INTERVAL", "CONFIRM_INTERVAL",
|
||||
"MAX_OPEN_POSITIONS", "MAX_DAILY_ENTRIES", "FIXED_RISK_PERCENT", "USE_FIXED_RISK_SIZING",
|
||||
"BETA_FILTER_ENABLED", "BETA_FILTER_THRESHOLD", "MARKET_SCHEME",
|
||||
"USE_ATR_STOP_LOSS", "ATR_STOP_LOSS_MULTIPLIER", "STOP_LOSS_PERCENT",
|
||||
"TAKE_PROFIT_1_PERCENT", "TAKE_PROFIT_PERCENT", "USE_TRAILING_STOP",
|
||||
"TRAILING_STOP_ACTIVATION", "TRAILING_STOP_PROTECT", "PROFIT_PROTECTION_ENABLED",
|
||||
"SMART_ENTRY_ENABLED", "USE_TREND_ENTRY_FILTER", "MAX_TREND_MOVE_BEFORE_ENTRY",
|
||||
"MAX_RSI_FOR_LONG", "MIN_RSI_FOR_SHORT", "MAX_CHANGE_PERCENT_FOR_LONG", "MAX_CHANGE_PERCENT_FOR_SHORT",
|
||||
"MIN_VOLUME_24H", "MIN_VOLATILITY", "MIN_HOLD_TIME_SEC",
|
||||
):
|
||||
cfg[key] = mgr.get(key)
|
||||
except Exception as e:
|
||||
logger.debug("get_strategy_execution_overview 加载配置失败: %s", e)
|
||||
|
||||
def pct(x):
|
||||
if x is None:
|
||||
return "—"
|
||||
try:
|
||||
f = float(x)
|
||||
if abs(f) < 1 and abs(f) > 0:
|
||||
return f"{f * 100:.2f}%"
|
||||
return f"{f}%"
|
||||
except (TypeError, ValueError):
|
||||
return str(x)
|
||||
|
||||
# ---------- 1. 总开关与自动交易条件 ----------
|
||||
auto_on = _g("AUTO_TRADE_ENABLED", True, cfg)
|
||||
only_trending = _g("AUTO_TRADE_ONLY_TRENDING", True, cfg)
|
||||
allow_4h_neutral = _g("AUTO_TRADE_ALLOW_4H_NEUTRAL", False, cfg)
|
||||
wl_raw = str(cfg.get("AUTO_TRADE_SYMBOL_WHITELIST") or "").strip()
|
||||
min_strength = _g("MIN_SIGNAL_STRENGTH", 8, cfg)
|
||||
low_vol_strength = _g("LOW_VOLATILITY_MIN_SIGNAL_STRENGTH", 9, cfg)
|
||||
regime_auto = _g("MARKET_REGIME_AUTO", True, cfg)
|
||||
|
||||
c1 = []
|
||||
c1.append("自动交易总开关:" + ("开启" if auto_on else "关闭"))
|
||||
if not auto_on:
|
||||
c1.append("关闭时仅生成推荐,不会自动下单。")
|
||||
else:
|
||||
c1.append("自动下单条件(需同时满足):")
|
||||
c1.append("• 信号强度 ≥ " + str(min_strength) + "(技术指标综合评分);低波动期自动提高至 " + str(low_vol_strength) + "(" + ("已开启" if regime_auto else "未开启") + "市场节奏识别)。")
|
||||
c1.append("• 市场状态:仅当「仅做趋势市」开启时,要求市场状态为 trending 才下单;ranging/unknown 只生成推荐、不自动下单。当前「仅做趋势市」=" + ("是" if only_trending else "否") + "。")
|
||||
c1.append("• 4H 趋势:允许 4H 中性时自动交易 = " + ("是" if allow_4h_neutral else "否") + ";为否时 4H 为中性会跳过自动下单。")
|
||||
if wl_raw:
|
||||
c1.append("• 自动交易白名单:已配置(仅名单内合约会自动下单;其余仍可出现在推荐中)。")
|
||||
else:
|
||||
c1.append("• 自动交易白名单:未启用(空表示不限制合约)。")
|
||||
sections.append({
|
||||
"title": "一、总开关与自动交易条件",
|
||||
"content": "\n".join(c1),
|
||||
})
|
||||
|
||||
# ---------- 2. 扫描与候选池 ----------
|
||||
top_n = _g("TOP_N_SYMBOLS", 30, cfg)
|
||||
scan_interval = _g("SCAN_INTERVAL", 900, cfg)
|
||||
primary = _g("PRIMARY_INTERVAL", "4h", cfg)
|
||||
confirm = _g("CONFIRM_INTERVAL", "1d", cfg)
|
||||
min_vol = _g("MIN_VOLUME_24H", 30000000, cfg)
|
||||
min_vol_str = f"{min_vol / 1e6:.0f} 万 USDT" if isinstance(min_vol, (int, float)) and min_vol >= 1e6 else str(min_vol)
|
||||
vol_pct = _g("MIN_VOLATILITY", 0.03, cfg)
|
||||
vol_pct_str = f"{float(vol_pct) * 100:.1f}%" if isinstance(vol_pct, (int, float)) else str(vol_pct)
|
||||
|
||||
c2 = []
|
||||
c2.append("每次扫描取涨跌幅最大的前 " + str(top_n) + " 个交易对进行详细分析;扫描间隔 " + str(scan_interval) + " 秒。")
|
||||
c2.append("主周期 " + str(primary) + ",确认周期 " + str(confirm) + ";24h 成交额 ≥ " + min_vol_str + ",最小波动率 " + vol_pct_str + "。")
|
||||
sections.append({
|
||||
"title": "二、扫描与候选池",
|
||||
"content": "\n".join(c2),
|
||||
})
|
||||
|
||||
# ---------- 3. 仓位与风控 ----------
|
||||
max_pos = _g("MAX_OPEN_POSITIONS", 4, cfg)
|
||||
max_daily = _g("MAX_DAILY_ENTRIES", 15, cfg)
|
||||
fixed_risk = _g("USE_FIXED_RISK_SIZING", True, cfg)
|
||||
risk_pct = _g("FIXED_RISK_PERCENT", 0.01, cfg)
|
||||
risk_pct_str = pct(risk_pct) if isinstance(risk_pct, (int, float)) and risk_pct <= 1 else f"{float(risk_pct)}%"
|
||||
|
||||
c3 = []
|
||||
c3.append("同时持仓上限 " + str(max_pos) + " 个,每日最多开仓 " + str(max_daily) + " 笔。")
|
||||
c3.append("固定风险 sizing:" + ("开启" if fixed_risk else "关闭") + ";每笔最大亏损 " + risk_pct_str + " 账户资金。")
|
||||
sections.append({
|
||||
"title": "三、仓位与风控",
|
||||
"content": "\n".join(c3),
|
||||
})
|
||||
|
||||
# ---------- 4. 大盘与市场方案 ----------
|
||||
beta_on = _g("BETA_FILTER_ENABLED", True, cfg)
|
||||
beta_th = _g("BETA_FILTER_THRESHOLD", -0.005, cfg)
|
||||
scheme = str(_g("MARKET_SCHEME", "normal", cfg) or "normal")
|
||||
|
||||
c4 = []
|
||||
c4.append("大盘共振过滤:" + ("开启" if beta_on else "关闭") + ";BTC/ETH 短周期跌逾 " + pct(beta_th) + " 时屏蔽多单。")
|
||||
c4.append("当前市场方案:" + scheme + "(用于参数预设)。")
|
||||
sections.append({
|
||||
"title": "四、大盘与市场方案",
|
||||
"content": "\n".join(c4),
|
||||
})
|
||||
|
||||
# ---------- 5. 止损止盈与保护 ----------
|
||||
use_atr = _g("USE_ATR_STOP_LOSS", True, cfg)
|
||||
atr_mult = _g("ATR_STOP_LOSS_MULTIPLIER", 2.0, cfg)
|
||||
sl_pct = _g("STOP_LOSS_PERCENT", 0.05, cfg)
|
||||
tp1 = _g("TAKE_PROFIT_1_PERCENT", 0.12, cfg)
|
||||
tp2 = _g("TAKE_PROFIT_PERCENT", 0.25, cfg)
|
||||
trail = _g("USE_TRAILING_STOP", True, cfg)
|
||||
trail_act = _g("TRAILING_STOP_ACTIVATION", 0.10, cfg)
|
||||
trail_prot = _g("TRAILING_STOP_PROTECT", 0.02, cfg)
|
||||
profit_prot = _g("PROFIT_PROTECTION_ENABLED", True, cfg)
|
||||
|
||||
c5 = []
|
||||
c5.append("止损:ATR 动态止损 " + ("开启" if use_atr else "关闭") + (",倍数 " + str(atr_mult) if use_atr else "") + ";固定止损 " + pct(sl_pct) + "。")
|
||||
c5.append("止盈:第一目标 " + pct(tp1) + ",第二目标 " + pct(tp2) + "。")
|
||||
c5.append("盈利保护总开关:" + ("开启" if profit_prot else "关闭") + ";移动止损 " + ("开启" if trail else "关闭") + (",盈利 " + pct(trail_act) + " 激活、保护 " + pct(trail_prot) + " 利润" if trail else "") + "。")
|
||||
sections.append({
|
||||
"title": "五、止损止盈与保护",
|
||||
"content": "\n".join(c5),
|
||||
})
|
||||
|
||||
# ---------- 6. 入场与过滤 ----------
|
||||
smart = _g("SMART_ENTRY_ENABLED", True, cfg)
|
||||
trend_filter = _g("USE_TREND_ENTRY_FILTER", True, cfg)
|
||||
max_trend = _g("MAX_TREND_MOVE_BEFORE_ENTRY", 0.04, cfg)
|
||||
max_rsi_long = _g("MAX_RSI_FOR_LONG", 65, cfg)
|
||||
min_rsi_short = _g("MIN_RSI_FOR_SHORT", 30, cfg)
|
||||
max_ch_long = _g("MAX_CHANGE_PERCENT_FOR_LONG", 25, cfg)
|
||||
max_ch_short = _g("MAX_CHANGE_PERCENT_FOR_SHORT", 10, cfg)
|
||||
|
||||
c6 = []
|
||||
c6.append("智能入场(限价+追价+市价兜底):" + ("开启" if smart else "关闭") + "。")
|
||||
c6.append("趋势入场过滤:" + ("开启" if trend_filter else "关闭") + ";信号方向已走超 " + pct(max_trend) + " 则不再入场。")
|
||||
c6.append("做多:RSI ≤ " + str(max_rsi_long) + ",24h 涨跌幅 ≤ " + str(max_ch_long) + "%。做空:RSI ≥ " + str(min_rsi_short) + ",24h 涨跌幅 ≤ " + str(max_ch_short) + "%。")
|
||||
sections.append({
|
||||
"title": "六、入场与过滤",
|
||||
"content": "\n".join(c6),
|
||||
})
|
||||
|
||||
return {"sections": sections}
|
||||
|
|
@ -24,9 +24,3 @@ aiohttp==3.9.1
|
|||
redis>=4.2.0
|
||||
# 保留aioredis作为备选(如果某些代码仍在使用aioredis接口)
|
||||
aioredis==2.0.1
|
||||
|
||||
# 安全:加密存储敏感字段(API KEY/SECRET)
|
||||
cryptography>=42.0.0
|
||||
|
||||
# 登录鉴权:JWT
|
||||
python-jose[cryptography]>=3.3.0
|
||||
|
|
|
|||
|
|
@ -3,13 +3,8 @@
|
|||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 查找运行中的uvicorn进程 (优先使用 lsof 查找端口占用)
|
||||
PID=$(lsof -t -i:8001)
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
# 回退到 ps 查找 (如果 lsof 没找到或不可用)
|
||||
PID=$(ps aux | grep "uvicorn api.main:app" | grep -v grep | awk '{print $2}')
|
||||
fi
|
||||
# 查找运行中的uvicorn进程
|
||||
PID=$(ps aux | grep "uvicorn api.main:app" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo "未找到运行中的后端服务"
|
||||
|
|
@ -21,8 +16,8 @@ else
|
|||
kill $PID
|
||||
sleep 2
|
||||
|
||||
# 检查是否成功停止 (使用 kill -0 检查进程是否存在,替代 ps -p)
|
||||
if kill -0 $PID > /dev/null 2>&1; then
|
||||
# 检查是否成功停止
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "强制停止服务..."
|
||||
kill -9 $PID
|
||||
sleep 1
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 重启推荐服务
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 查找 recommendations_main 进程
|
||||
PID=$(ps aux | grep "trading_system.recommendations_main" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo "未找到运行中的推荐服务,直接启动..."
|
||||
./start_recommendations.sh
|
||||
else
|
||||
echo "找到推荐服务,PID: $PID,正在重启..."
|
||||
kill $PID 2>/dev/null || true
|
||||
sleep 2
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
kill -9 $PID 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
echo "正在启动新服务..."
|
||||
./start_recommendations.sh
|
||||
fi
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
"""
|
||||
安全相关工具(加密/解密等)
|
||||
"""
|
||||
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
"""
|
||||
对称加密工具(用于存储 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")
|
||||
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
"""
|
||||
现货推荐扫描:拉取币安现货行情,仅做多信号,写入 Redis 供 /api/recommendations/spot 使用。
|
||||
使用公开 API,无需 API Key。定时任务调用 run_spot_scan_and_cache()。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
# 可选的 Redis 写入(与 recommendations 路由共用连接方式)
|
||||
try:
|
||||
import redis.asyncio as redis_async
|
||||
except Exception:
|
||||
redis_async = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BINANCE_SPOT_BASE = "https://api.binance.com"
|
||||
SPOT_KLINES_LIMIT = 60
|
||||
SPOT_TOP_N = 80
|
||||
SPOT_MIN_STRENGTH = 4
|
||||
SPOT_MAX_RECS = 30
|
||||
|
||||
|
||||
def _beijing_now_iso() -> str:
|
||||
from datetime import timedelta
|
||||
return datetime.now(tz=timezone(timedelta(hours=8))).isoformat()
|
||||
|
||||
|
||||
async def _http_get(session: aiohttp.ClientSession, url: str, params: Optional[Dict] = None) -> Optional[Any]:
|
||||
try:
|
||||
async with session.get(url, params=params or {}, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
return await resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("spot_scanner _http_get %s: %s", url[:60], e)
|
||||
return None
|
||||
|
||||
|
||||
def _technical_indicators():
|
||||
"""延迟导入 trading_system.indicators,避免 backend 强依赖 trading_system 路径。"""
|
||||
project_root = __import__("pathlib").Path(__file__).resolve().parent.parent
|
||||
trading_system = project_root / "trading_system"
|
||||
if str(trading_system) not in sys.path:
|
||||
sys.path.insert(0, str(trading_system))
|
||||
try:
|
||||
from indicators import TechnicalIndicators
|
||||
return TechnicalIndicators
|
||||
except ImportError:
|
||||
from trading_system.indicators import TechnicalIndicators
|
||||
return TechnicalIndicators
|
||||
|
||||
|
||||
async def _fetch_spot_symbols(session: aiohttp.ClientSession) -> List[str]:
|
||||
"""获取所有 USDT 现货交易对(status=TRADING)。"""
|
||||
data = await _http_get(session, f"{BINANCE_SPOT_BASE}/api/v3/exchangeInfo")
|
||||
if not data or "symbols" not in data:
|
||||
return []
|
||||
symbols = []
|
||||
for s in data["symbols"]:
|
||||
if s.get("status") != "TRADING":
|
||||
continue
|
||||
if s.get("quoteAsset") != "USDT":
|
||||
continue
|
||||
sym = s.get("symbol")
|
||||
if sym:
|
||||
symbols.append(sym)
|
||||
return symbols
|
||||
|
||||
|
||||
async def _fetch_spot_ticker_24h(session: aiohttp.ClientSession) -> List[Dict]:
|
||||
"""获取 24h ticker,返回 list of dict (symbol, lastPrice, priceChangePercent, volume, ...)。"""
|
||||
data = await _http_get(session, f"{BINANCE_SPOT_BASE}/api/v3/ticker/24hr")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return data
|
||||
|
||||
|
||||
async def _fetch_spot_klines(session: aiohttp.ClientSession, symbol: str, interval: str = "15m", limit: int = 60) -> Optional[List[List]]:
|
||||
"""现货 K 线,格式与合约一致 [open_time, o, h, l, c, volume, ...]。"""
|
||||
data = await _http_get(
|
||||
session,
|
||||
f"{BINANCE_SPOT_BASE}/api/v3/klines",
|
||||
{"symbol": symbol, "interval": interval, "limit": limit},
|
||||
)
|
||||
return data if isinstance(data, list) else None
|
||||
|
||||
|
||||
def _compute_spot_signal(klines: List[List], ticker: Dict, TechnicalIndicators) -> Optional[Dict]:
|
||||
"""
|
||||
基于 K 线计算只做多信号。返回 None 或 { direction: 'BUY', strength: int, ... }。
|
||||
"""
|
||||
if not klines or len(klines) < 50:
|
||||
return None
|
||||
closes = [float(k[4]) for k in klines]
|
||||
highs = [float(k[2]) for k in klines]
|
||||
lows = [float(k[3]) for k in klines]
|
||||
current_price = closes[-1]
|
||||
|
||||
rsi = TechnicalIndicators.calculate_rsi(closes, period=14)
|
||||
macd = TechnicalIndicators.calculate_macd(closes)
|
||||
bollinger = TechnicalIndicators.calculate_bollinger_bands(closes, period=20)
|
||||
ema20 = TechnicalIndicators.calculate_ema(closes, period=20)
|
||||
ema50 = TechnicalIndicators.calculate_ema(closes, period=50)
|
||||
|
||||
strength = 0
|
||||
# 只做多:RSI 超卖、价格在下轨附近、MACD 金叉、价格在均线上方等
|
||||
if rsi is not None and rsi < 35:
|
||||
strength += 3
|
||||
elif rsi is not None and rsi < 50:
|
||||
strength += 1
|
||||
if bollinger and current_price <= bollinger["lower"] * 1.002:
|
||||
strength += 3
|
||||
elif bollinger and current_price < bollinger["middle"]:
|
||||
strength += 1
|
||||
if macd and macd["histogram"] > 0 and macd["macd"] > macd["signal"]:
|
||||
strength += 2
|
||||
if ema20 and ema50 and current_price > ema20 > ema50:
|
||||
strength += 2
|
||||
elif ema20 and current_price > ema20:
|
||||
strength += 1
|
||||
|
||||
strength = max(0, min(strength, 10))
|
||||
if strength < SPOT_MIN_STRENGTH:
|
||||
return None
|
||||
return {
|
||||
"direction": "BUY",
|
||||
"strength": strength,
|
||||
"rsi": rsi,
|
||||
"current_price": current_price,
|
||||
}
|
||||
|
||||
|
||||
def _build_spot_recommendation(
|
||||
symbol: str,
|
||||
ticker: Dict,
|
||||
signal: Dict,
|
||||
) -> Dict[str, Any]:
|
||||
"""构造单条现货推荐(与合约推荐结构兼容,便于前端复用)。"""
|
||||
current_price = float(ticker.get("lastPrice") or signal.get("current_price") or 0)
|
||||
change_percent = float(ticker.get("priceChangePercent") or 0)
|
||||
ts = time.time()
|
||||
entry = current_price * 0.995
|
||||
stop_pct = 0.05
|
||||
tp1_pct = 0.08
|
||||
tp2_pct = 0.15
|
||||
if current_price <= 0:
|
||||
return None
|
||||
stop_loss = entry * (1 - stop_pct)
|
||||
tp1 = entry * (1 + tp1_pct)
|
||||
tp2 = entry * (1 + tp2_pct)
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"direction": "BUY",
|
||||
"market": "spot",
|
||||
"current_price": current_price,
|
||||
"signal_strength": signal.get("strength", 0),
|
||||
"change_percent": change_percent,
|
||||
"suggested_limit_price": entry,
|
||||
"planned_entry_price": entry,
|
||||
"suggested_stop_loss": stop_loss,
|
||||
"suggested_take_profit_1": tp1,
|
||||
"suggested_take_profit_2": tp2,
|
||||
"suggested_position_percent": 0.05,
|
||||
"recommendation_time": _beijing_now_iso(),
|
||||
"timestamp": ts,
|
||||
"recommendation_reason": "现货做多信号(RSI/布林带/MACD/均线)",
|
||||
"user_guide": f"现货建议在 {entry:.4f} USDT 附近买入,止损 {stop_loss:.4f},目标1 {tp1:.4f},目标2 {tp2:.4f}。仅供参考,请自行判断。",
|
||||
}
|
||||
|
||||
|
||||
async def run_spot_scan() -> List[Dict[str, Any]]:
|
||||
"""执行一次现货扫描,返回推荐列表(不写 Redis)。"""
|
||||
TechnicalIndicators = _technical_indicators()
|
||||
recommendations = []
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
symbols = await _fetch_spot_symbols(session)
|
||||
if not symbols:
|
||||
logger.warning("spot_scanner: 未获取到现货交易对")
|
||||
return []
|
||||
|
||||
tickers = await _fetch_spot_ticker_24h(session)
|
||||
ticker_map = {t["symbol"]: t for t in tickers if isinstance(t.get("symbol"), str)}
|
||||
|
||||
# 按 24h 成交量排序,取前 SPOT_TOP_N 再按涨跌幅取部分
|
||||
def volume_key(t):
|
||||
try:
|
||||
return float(t.get("volume") or 0) * float(t.get("lastPrice") or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
sorted_tickers = sorted(
|
||||
[t for t in tickers if t.get("symbol") in symbols],
|
||||
key=volume_key,
|
||||
reverse=True,
|
||||
)[: SPOT_TOP_N * 2]
|
||||
|
||||
# 按涨跌幅取前 N 个(偏强势或超跌反弹)
|
||||
with_change = [(t, float(t.get("priceChangePercent") or 0)) for t in sorted_tickers]
|
||||
with_change.sort(key=lambda x: -abs(x[1]))
|
||||
to_scan = [t[0]["symbol"] for t in with_change[: SPOT_TOP_N]]
|
||||
|
||||
for symbol in to_scan:
|
||||
try:
|
||||
klines = await _fetch_spot_klines(session, symbol, "15m", SPOT_KLINES_LIMIT)
|
||||
ticker = ticker_map.get(symbol, {})
|
||||
if not klines or not ticker:
|
||||
continue
|
||||
signal = _compute_spot_signal(klines, ticker, TechnicalIndicators)
|
||||
if not signal:
|
||||
continue
|
||||
rec = _build_spot_recommendation(symbol, ticker, signal)
|
||||
if rec:
|
||||
recommendations.append(rec)
|
||||
if len(recommendations) >= SPOT_MAX_RECS:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("spot_scanner %s: %s", symbol, e)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
recommendations.sort(key=lambda x: x.get("signal_strength", 0), reverse=True)
|
||||
return recommendations[: SPOT_MAX_RECS]
|
||||
|
||||
|
||||
def _redis_connection_kwargs():
|
||||
redis_url = (os.getenv("REDIS_URL", "") or "").strip() or "redis://localhost:6379"
|
||||
kwargs = {"decode_responses": True}
|
||||
if os.getenv("REDIS_USERNAME"):
|
||||
kwargs["username"] = os.getenv("REDIS_USERNAME")
|
||||
if os.getenv("REDIS_PASSWORD"):
|
||||
kwargs["password"] = os.getenv("REDIS_PASSWORD")
|
||||
if redis_url.startswith("rediss://") or os.getenv("REDIS_USE_TLS", "").lower() == "true":
|
||||
if redis_url.startswith("redis://"):
|
||||
redis_url = redis_url.replace("redis://", "rediss://", 1)
|
||||
kwargs.setdefault("ssl_cert_reqs", os.getenv("REDIS_SSL_CERT_REQS", "required"))
|
||||
if os.getenv("REDIS_SSL_CA_CERTS"):
|
||||
kwargs["ssl_ca_certs"] = os.getenv("REDIS_SSL_CA_CERTS")
|
||||
return redis_url, kwargs
|
||||
|
||||
|
||||
async def run_spot_scan_and_cache(ttl_sec: int = 900) -> int:
|
||||
"""
|
||||
执行现货扫描并写入 Redis。返回写入的推荐数量。
|
||||
Redis key: recommendations:spot:snapshot
|
||||
"""
|
||||
items = await run_spot_scan()
|
||||
now_ms = int(time.time() * 1000)
|
||||
payload = {
|
||||
"items": items,
|
||||
"generated_at": _beijing_now_iso(),
|
||||
"generated_at_ms": now_ms,
|
||||
"ttl_sec": ttl_sec,
|
||||
"count": len(items),
|
||||
}
|
||||
|
||||
if redis_async is None:
|
||||
logger.warning("spot_scanner: redis 不可用,跳过写入")
|
||||
return len(items)
|
||||
|
||||
redis_url, kwargs = _redis_connection_kwargs()
|
||||
try:
|
||||
client = redis_async.from_url(redis_url, **kwargs)
|
||||
await client.ping()
|
||||
key = "recommendations:spot:snapshot"
|
||||
await client.setex(key, ttl_sec, json.dumps(payload, ensure_ascii=False))
|
||||
logger.info("spot_scanner: 已写入 %d 条现货推荐到 %s", len(items), key)
|
||||
await client.aclose()
|
||||
return len(items)
|
||||
except Exception as e:
|
||||
logger.warning("spot_scanner: Redis 写入失败 %s", e)
|
||||
return len(items)
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 启动推荐服务(recommendations_main)
|
||||
# 优先使用 trading_system/.venv(与服务器实际部署一致),其次 backend/.venv
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
BACKEND_DIR="$(pwd)"
|
||||
PROJECT_ROOT="$(cd .. && pwd)"
|
||||
TRADING_VENV="${PROJECT_ROOT}/trading_system/.venv"
|
||||
|
||||
# 激活虚拟环境:优先 trading_system/.venv,其次 backend 下
|
||||
if [ -d "${TRADING_VENV}" ]; then
|
||||
source "${TRADING_VENV}/bin/activate"
|
||||
elif [ -d ".venv" ]; then
|
||||
source .venv/bin/activate
|
||||
elif [ -d "../.venv" ]; then
|
||||
source ../.venv/bin/activate
|
||||
else
|
||||
echo "错误: 找不到虚拟环境(trading_system/.venv、.venv 或 ../.venv)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 设置环境变量
|
||||
export PYTHONPATH="${PROJECT_ROOT}"
|
||||
export DB_HOST=${DB_HOST:-localhost}
|
||||
export DB_PORT=${DB_PORT:-3306}
|
||||
export DB_USER=${DB_USER:-autosys}
|
||||
export DB_PASSWORD=${DB_PASSWORD:-}
|
||||
export DB_NAME=${DB_NAME:-auto_trade_sys}
|
||||
export LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
|
||||
# 创建日志目录
|
||||
mkdir -p "${PROJECT_ROOT}/logs"
|
||||
|
||||
# 启动推荐服务(后台运行)
|
||||
cd "${PROJECT_ROOT}"
|
||||
nohup python -m trading_system.recommendations_main > logs/recommendations.log 2>&1 &
|
||||
PID=$!
|
||||
echo "推荐服务已启动,PID: $PID"
|
||||
echo "日志: tail -f ${PROJECT_ROOT}/logs/recommendations.log"
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 停止后端服务脚本
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 查找运行中的uvicorn进程
|
||||
PID=$(ps aux | grep "uvicorn api.main:app" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo "未找到运行中的后端服务"
|
||||
else
|
||||
echo "找到运行中的后端服务,PID: $PID"
|
||||
echo "正在停止服务..."
|
||||
kill $PID
|
||||
sleep 1
|
||||
|
||||
# 检查是否成功停止
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "停止失败,尝试强制停止..."
|
||||
kill -9 $PID
|
||||
fi
|
||||
echo "后端服务已停止"
|
||||
fi
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 停止推荐服务(recommendations_main)
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 查找 recommendations_main 进程
|
||||
PID=$(ps aux | grep "trading_system.recommendations_main" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo "未找到运行中的推荐服务"
|
||||
else
|
||||
echo "找到推荐服务,PID: $PID"
|
||||
echo "正在停止..."
|
||||
kill $PID 2>/dev/null || true
|
||||
sleep 2
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "尝试强制停止..."
|
||||
kill -9 $PID 2>/dev/null || true
|
||||
fi
|
||||
echo "推荐服务已停止"
|
||||
fi
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
将“缺省全局配置项”同步到数据库 global_strategy_config 表。
|
||||
|
||||
- 已在 UI 保存过的项不会覆盖(只插入缺失的 key)。
|
||||
- 用于新上线配置项(如 MAX_RSI_FOR_LONG、MIN_RSI_FOR_SHORT 等)一次性写入默认值,
|
||||
便于在数据库中可见、可备份,且不依赖“先在页面改一次再保存”。
|
||||
|
||||
使用方式(在项目根目录):
|
||||
cd backend && python sync_global_config_defaults.py
|
||||
或
|
||||
python backend/sync_global_config_defaults.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保 backend 在路径中
|
||||
backend_dir = Path(__file__).resolve().parent
|
||||
if str(backend_dir) not in sys.path:
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
# 需要同步的缺省项(仅插入数据库中不存在的 key)
|
||||
DEFAULTS_TO_SYNC = [
|
||||
{"config_key": "MAX_RSI_FOR_LONG", "config_value": "65", "config_type": "number", "category": "strategy",
|
||||
"description": "做多时 RSI 超过此值则不开多(2026-02-12:65 避免追高)。"},
|
||||
{"config_key": "MAX_CHANGE_PERCENT_FOR_LONG", "config_value": "25", "config_type": "number", "category": "strategy",
|
||||
"description": "做多时 24h 涨跌幅超过此值则不开多(避免追大涨)。单位:百分比数值,如 25 表示 25%。2026-01-31新增。"},
|
||||
{"config_key": "MIN_RSI_FOR_SHORT", "config_value": "30", "config_type": "number", "category": "strategy",
|
||||
"description": "做空时 RSI 低于此值则不做空(避免深超卖反弹)。2026-01-31新增。"},
|
||||
{"config_key": "MAX_CHANGE_PERCENT_FOR_SHORT", "config_value": "10", "config_type": "number", "category": "strategy",
|
||||
"description": "做空时 24h 涨跌幅超过此值则不做空(24h 仍大涨时不做空)。单位:百分比数值。2026-01-31新增。"},
|
||||
{"config_key": "TAKE_PROFIT_1_PERCENT", "config_value": "0.3", "config_type": "number", "category": "strategy",
|
||||
"description": "分步止盈第一目标(保证金百分比,如 0.2=20%)。2026-02-12 提高以改善盈亏比。"},
|
||||
{"config_key": "MIN_RR_FOR_TP1", "config_value": "1.5", "config_type": "number", "category": "strategy",
|
||||
"description": "第一目标止盈相对止损的最小盈亏比(TP1 至少为止损距离的 1.5 倍)。2026-02-12 新增。"},
|
||||
{"config_key": "SCAN_EXTRA_SYMBOLS_FOR_SUPPLEMENT", "config_value": "8", "config_type": "number", "category": "scan",
|
||||
"description": "智能补单:多返回的候选数量。当前 TOP_N 中部分因冷却等被跳过时,仍会尝试这批额外候选,避免无单可下。"},
|
||||
{"config_key": "BETA_FILTER_ENABLED", "config_value": "true", "config_type": "boolean", "category": "strategy",
|
||||
"description": "大盘共振过滤:BTC/ETH 下跌时屏蔽多单。"},
|
||||
{"config_key": "BETA_FILTER_THRESHOLD", "config_value": "-0.005", "config_type": "number", "category": "strategy",
|
||||
"description": "大盘共振阈值(比例,如 -0.005 表示 -0.5%)。"},
|
||||
{"config_key": "POSITION_SCALE_FACTOR", "config_value": "1.0", "config_type": "number", "category": "risk",
|
||||
"description": "仓位放大系数:1.0=正常,1.2=+20%,上限2.0。盈利时适度调高可扩大收益。"},
|
||||
{"config_key": "USE_FIXED_RISK_SIZING", "config_value": "true", "config_type": "boolean", "category": "risk",
|
||||
"description": "是否启用固定风险仓位计算(推荐)。若启用,则忽略 MAX_POSITION_PERCENT,改用 FIXED_RISK_PERCENT 计算仓位。"},
|
||||
{"config_key": "FIXED_RISK_PERCENT", "config_value": "0.03", "config_type": "number", "category": "risk",
|
||||
"description": "每笔交易风险占总账户的百分比(如 0.025=2.5%)。配合止损距离计算仓位,风险可控。"},
|
||||
{"config_key": "MIN_MARGIN_USDT", "config_value": "10.0", "config_type": "number", "category": "risk",
|
||||
"description": "最小保证金(USDT)。2026-02-13 提高到 10.0 USDT 以避免无效小单。"},
|
||||
# 盈利期对齐(2026-02-15):仅当 key 不存在时插入,不覆盖已有值
|
||||
{"config_key": "RSI_EXTREME_REVERSE_ENABLED", "config_value": "false", "config_type": "boolean", "category": "strategy",
|
||||
"description": "关闭RSI极限反转,与盈利期一致"},
|
||||
{"config_key": "RSI_EXTREME_REVERSE_ONLY_NEUTRAL_4H", "config_value": "true", "config_type": "boolean", "category": "strategy",
|
||||
"description": "若开启反向仅允许4H中性"},
|
||||
{"config_key": "USE_MARGIN_CAP_FOR_TP", "config_value": "true", "config_type": "boolean", "category": "risk",
|
||||
"description": "止盈按保证金封顶,避免过远"},
|
||||
{"config_key": "USE_MARGIN_CAP_FOR_SL", "config_value": "true", "config_type": "boolean", "category": "risk",
|
||||
"description": "止损按保证金封顶,避免扛单"},
|
||||
# 市场状态方案(2026-02 三项优化 + 方案切换)
|
||||
{"config_key": "MARKET_SCHEME", "config_value": "normal", "config_type": "string", "category": "strategy",
|
||||
"description": "市场方案:normal / bear / bull / conservative。切换后自动覆盖止损、仓位、趋势过滤等参数。"},
|
||||
{"config_key": "BLOCK_LONG_WHEN_4H_DOWN", "config_value": "false", "config_type": "boolean", "category": "strategy",
|
||||
"description": "4H 趋势下跌时禁止开多。bear / conservative 方案下自动为 true。"},
|
||||
{"config_key": "BLOCK_SHORT_WHEN_4H_UP", "config_value": "true", "config_type": "boolean", "category": "strategy",
|
||||
"description": "4H 趋势上涨时禁止开空。默认 true,避免逆势做空导致止损。"},
|
||||
{"config_key": "AUTO_MARKET_SCHEME_ENABLED", "config_value": "false", "config_type": "boolean", "category": "strategy",
|
||||
"description": "开启后,crontab 定时运行 scripts/update_market_scheme.py --apply 时自动更新 MARKET_SCHEME(根据 BTC 行情识别牛/熊/正常)。"},
|
||||
# 回撤区间入场(2026-03)
|
||||
{"config_key": "ENTRY_PULLBACK_FILTER_ENABLED", "config_value": "false", "config_type": "boolean", "category": "strategy",
|
||||
"description": "是否启用回撤区间入场过滤(近N根K线前低前高,避免买在区间顶部)。"},
|
||||
{"config_key": "ENTRY_PULLBACK_INTERVAL", "config_value": "", "config_type": "string", "category": "strategy",
|
||||
"description": "留空则使用 ENTRY_INTERVAL;可填 15m、1h 等。"},
|
||||
{"config_key": "ENTRY_PULLBACK_LOOKBACK_BARS", "config_value": "24", "config_type": "number", "category": "strategy",
|
||||
"description": "统计区间用的K线根数。"},
|
||||
{"config_key": "ENTRY_PULLBACK_MIN_BARS", "config_value": "5", "config_type": "number", "category": "strategy",
|
||||
"description": "至少几根K才启用回撤过滤。"},
|
||||
{"config_key": "ENTRY_PULLBACK_MAX_LONG_IN_RANGE", "config_value": "0.62", "config_type": "number", "category": "strategy",
|
||||
"description": "做多:区间相对位置上限制0~1,默认0.62。"},
|
||||
{"config_key": "ENTRY_PULLBACK_MIN_SHORT_IN_RANGE", "config_value": "0.38", "config_type": "number", "category": "strategy",
|
||||
"description": "做空:区间相对位置下限制0~1,默认0.38。"},
|
||||
{"config_key": "SHADOW_MODE_AUTO_APPLY", "config_value": "false", "config_type": "boolean", "category": "strategy",
|
||||
"description": "影子模式:按 current_suggestions.json 自动应用黑名单/差时段/杠杆调整。"},
|
||||
{"config_key": "SHADOW_MODE_MIN_CONFIDENCE", "config_value": "0.7", "config_type": "number", "category": "strategy",
|
||||
"description": "影子跟踪置信度下限。"},
|
||||
{"config_key": "SHADOW_MODE_SUGGESTIONS_PATH", "config_value": "config/current_suggestions.json", "config_type": "string", "category": "strategy",
|
||||
"description": "优化建议 JSON 路径。"},
|
||||
{"config_key": "SHADOW_MODE_TRACKING_PATH", "config_value": "config/shadow_mode_tracking.json", "config_type": "string", "category": "strategy",
|
||||
"description": "影子跟踪 JSON 路径。"},
|
||||
{"config_key": "SHADOW_MODE_INCREASE_LEVERAGE_MULT", "config_value": "1.5", "config_type": "number", "category": "strategy",
|
||||
"description": "加仓名单杠杆乘数。"},
|
||||
{"config_key": "SHADOW_MODE_DECREASE_LEVERAGE_MULT", "config_value": "0.5", "config_type": "number", "category": "strategy",
|
||||
"description": "减仓名单杠杆乘数。"},
|
||||
]
|
||||
|
||||
|
||||
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()
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 查看同步订单日志的便捷脚本
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== 同步订单日志查看工具 ==="
|
||||
echo ""
|
||||
|
||||
# 检查日志文件是否存在
|
||||
if [ ! -f "logs/api.log" ]; then
|
||||
echo "⚠️ 日志文件不存在: logs/api.log"
|
||||
echo " 请先启动 backend 服务"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "日志文件位置:"
|
||||
echo " - Python 应用日志: backend/logs/api.log"
|
||||
echo " - Uvicorn 服务器日志: backend/logs/uvicorn.log"
|
||||
echo ""
|
||||
|
||||
# 显示最近的同步日志
|
||||
echo "=== 最近的同步订单日志(最后 50 行)==="
|
||||
echo ""
|
||||
tail -50 logs/api.log | grep -i "同步\|sync\|订单\|order" --color=always || echo "未找到同步相关日志"
|
||||
|
||||
echo ""
|
||||
echo "=== 使用说明 ==="
|
||||
echo ""
|
||||
echo "实时查看同步日志:"
|
||||
echo " tail -f logs/api.log | grep -i '同步\|sync'"
|
||||
echo ""
|
||||
echo "查看最近的同步日志:"
|
||||
echo " tail -100 logs/api.log | grep -i '同步\|sync'"
|
||||
echo ""
|
||||
echo "查看特定时间的同步日志:"
|
||||
echo " grep '2026-02-17 23:' logs/api.log | grep -i '同步\|sync'"
|
||||
echo ""
|
||||
echo "查看所有同步相关日志(包括详细信息):"
|
||||
echo " grep -i '同步\|sync\|订单\|order' logs/api.log | tail -100"
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 检查交易服务内存问题
|
||||
|
||||
echo "=== 交易服务内存问题诊断 ==="
|
||||
echo ""
|
||||
|
||||
# 1. 查看交易服务进程的详细内存信息
|
||||
echo "📊 交易服务进程内存详情:"
|
||||
TRADING_PID=$(ps aux | grep "trading_system.main" | grep -v grep | awk '{print $2}')
|
||||
if [ -z "$TRADING_PID" ]; then
|
||||
echo " ⚠️ 未找到交易服务进程"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "进程 PID: $TRADING_PID"
|
||||
ps -p $TRADING_PID -o pid,vsz,rss,%mem,cmd
|
||||
echo ""
|
||||
|
||||
# 2. 查看进程的内存映射(找出占用大的区域)
|
||||
echo "📈 进程内存映射(前 20 行,按大小排序):"
|
||||
if [ -f "/proc/$TRADING_PID/smaps" ]; then
|
||||
cat /proc/$TRADING_PID/smaps 2>/dev/null | awk '/^Size:/ {size=$2} /^Rss:/ {rss=$2} /^Pss:/ {pss=$2} /^Name:/ {if (rss > 1024) print size" KB (RSS: "rss" KB) - " $2}' | sort -rn | head -20 || echo " 无法读取内存映射(需要 root 权限)"
|
||||
else
|
||||
echo " 无法访问 /proc/$TRADING_PID/smaps"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 3. 查看交易服务日志中的内存相关错误
|
||||
echo "🔍 检查交易服务日志:"
|
||||
LOG_DIRS=(
|
||||
"../trading_system/logs"
|
||||
"logs"
|
||||
"/www/wwwroot/autosys_new/trading_system/logs"
|
||||
)
|
||||
|
||||
for LOG_DIR in "${LOG_DIRS[@]}"; do
|
||||
if [ -d "$LOG_DIR" ]; then
|
||||
echo "检查目录: $LOG_DIR"
|
||||
# 查找内存相关错误
|
||||
find "$LOG_DIR" -name "*.log" -type f -mtime -1 2>/dev/null | while read logfile; do
|
||||
echo " 文件: $logfile"
|
||||
# 查找内存错误
|
||||
grep -i "memory\|oom\|out of memory\|memoryerror\|memory leak" "$logfile" 2>/dev/null | tail -5 || echo " 未找到内存相关错误"
|
||||
# 查找最近的错误
|
||||
tail -50 "$logfile" 2>/dev/null | grep -i "error\|exception\|failed" | tail -5 || echo " 未找到错误"
|
||||
done
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# 4. 查看系统内存压力
|
||||
echo "💾 系统内存压力:"
|
||||
free -h
|
||||
echo ""
|
||||
echo "内存使用率:"
|
||||
free | awk 'NR==2{printf "已用: %.1f%%\n", $3*100/$2}'
|
||||
echo ""
|
||||
|
||||
# 5. 检查是否有 swap 使用(如果有说明内存不足)
|
||||
echo "🔄 Swap 使用情况:"
|
||||
free | awk 'NR==3{if ($3 > 0) print "⚠️ Swap 正在使用: " $3 " KB (内存不足)"; else print "✓ Swap 未使用"}'
|
||||
echo ""
|
||||
|
||||
# 6. 查看最近的交易服务输出
|
||||
echo "📝 最近的交易服务输出(最后 30 行):"
|
||||
for LOG_DIR in "${LOG_DIRS[@]}"; do
|
||||
if [ -d "$LOG_DIR" ]; then
|
||||
find "$LOG_DIR" -name "trading_*.log" -o -name "*.out.log" -type f 2>/dev/null | head -1 | while read logfile; do
|
||||
if [ -f "$logfile" ]; then
|
||||
tail -30 "$logfile" 2>/dev/null
|
||||
break
|
||||
fi
|
||||
done
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
echo "=== 诊断完成 ==="
|
||||
echo ""
|
||||
echo "💡 可能的原因:"
|
||||
echo " 1. K线数据缓存过大(market_scanner 加载了太多历史K线)"
|
||||
echo " 2. 持仓数据或订单数据在内存中累积"
|
||||
echo " 3. WebSocket 连接或消息队列占用过多内存"
|
||||
echo " 4. 数据库查询结果集太大(未使用 LIMIT)"
|
||||
echo " 5. 内存泄漏(某个数据结构不断增长)"
|
||||
echo ""
|
||||
echo "💡 临时解决方案:"
|
||||
echo " 1. 重启交易服务(释放内存)"
|
||||
echo " 2. 检查配置中的缓存大小限制"
|
||||
echo " 3. 减少扫描的交易对数量"
|
||||
echo " 4. 检查是否有大量未关闭的数据库连接"
|
||||
109
backend/诊断负载.sh
109
backend/诊断负载.sh
|
|
@ -1,109 +0,0 @@
|
|||
#!/bin/bash
|
||||
# 快速诊断系统负载问题
|
||||
|
||||
echo "=== 系统负载诊断工具 ==="
|
||||
echo ""
|
||||
|
||||
# 1. 当前负载
|
||||
echo "📊 当前负载情况:"
|
||||
uptime
|
||||
echo ""
|
||||
|
||||
# 2. CPU 和内存使用
|
||||
echo "💻 CPU 和内存使用:"
|
||||
top -bn1 | head -5
|
||||
echo ""
|
||||
|
||||
# 3. 查看占用 CPU 最高的进程
|
||||
echo "🔥 CPU 占用最高的进程(前 10):"
|
||||
ps aux --sort=-%cpu | head -11 | awk '{printf "%-8s %-6s %-6s %-6s %s\n", $1, $2, $3"%", $4"%", $11}'
|
||||
echo ""
|
||||
|
||||
# 4. 查看 Python 进程(交易服务)
|
||||
echo "🐍 Python 进程(交易服务):"
|
||||
PYTHON_PROCS=$(ps aux | grep -E "python.*trading|python.*main|uvicorn" | grep -v grep)
|
||||
if [ -z "$PYTHON_PROCS" ]; then
|
||||
echo " ⚠️ 未发现交易服务进程(服务可能未运行)"
|
||||
else
|
||||
echo "$PYTHON_PROCS" | awk '{printf "PID: %-6s CPU: %-5s MEM: %-5s CMD: %s\n", $2, $3"%", $4"%", $11" "$12" "$13" "$14}'
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 5. 检查是否有同步操作在运行
|
||||
echo "🔄 检查同步操作:"
|
||||
if [ -f "logs/api.log" ]; then
|
||||
SYNC_LOGS=$(tail -100 logs/api.log | grep -i "同步\|sync.*binance\|sync_trades" | tail -10)
|
||||
if [ -z "$SYNC_LOGS" ]; then
|
||||
echo " 未找到同步日志(可能未执行同步操作)"
|
||||
else
|
||||
echo "最近的同步日志(最后 10 行):"
|
||||
echo "$SYNC_LOGS"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ 日志文件不存在(backend 服务可能未运行)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 6. 检查数据库连接数
|
||||
echo "🗄️ 数据库连接数:"
|
||||
if command -v mysql >/dev/null 2>&1; then
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_USER="${DB_USER:-root}"
|
||||
DB_PASS="${DB_PASS:-}"
|
||||
DB_NAME="${DB_NAME:-auto_trade_sys}"
|
||||
|
||||
if [ -n "$DB_PASS" ]; then
|
||||
mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" -e "SHOW PROCESSLIST;" 2>/dev/null | head -20 || echo " 无法连接数据库"
|
||||
else
|
||||
mysql -h"$DB_HOST" -u"$DB_USER" -e "SHOW PROCESSLIST;" 2>/dev/null | head -20 || echo " 无法连接数据库(需要配置 DB_PASS)"
|
||||
fi
|
||||
else
|
||||
echo " mysql 客户端未安装"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 7. 检查内存使用详情
|
||||
echo "💾 内存使用详情:"
|
||||
free -h
|
||||
echo ""
|
||||
|
||||
# 8. 检查是否有大量 I/O 等待
|
||||
echo "📈 I/O 和系统状态(5秒采样):"
|
||||
vmstat 1 5
|
||||
echo ""
|
||||
|
||||
# 9. 检查交易服务日志中的错误
|
||||
echo "⚠️ 最近的错误日志(最后 5 条):"
|
||||
if [ -f "logs/api.log" ]; then
|
||||
tail -200 logs/api.log | grep -i "error\|exception\|failed\|timeout" | tail -5 || echo " 未找到错误日志"
|
||||
fi
|
||||
if [ -f "../trading_system/logs/trading_*.log" ] 2>/dev/null; then
|
||||
tail -200 ../trading_system/logs/trading_*.log 2>/dev/null | grep -i "error\|exception\|failed" | tail -5 || echo ""
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== 诊断完成 ==="
|
||||
echo ""
|
||||
echo "💡 说明:"
|
||||
echo " - 此脚本可以在交易服务未运行时使用,用于检查系统整体负载"
|
||||
echo " - 如果交易服务正在运行,会显示更详细的进程和日志信息"
|
||||
echo ""
|
||||
echo "💡 如果负载高,可能原因:"
|
||||
echo " 1. Python 进程(交易服务)占用高:"
|
||||
echo " - 市场扫描正在运行(计算技术指标)"
|
||||
echo " - 订单同步正在运行(从币安拉取大量订单)"
|
||||
echo " - 数据库查询慢(检查慢查询日志)"
|
||||
echo ""
|
||||
echo " 2. 其他进程占用高:"
|
||||
echo " - 检查 top/htop 查看具体是哪个进程"
|
||||
echo " - 可能是系统更新、备份等后台任务"
|
||||
echo ""
|
||||
echo " 3. 内存占用高:检查是否有内存泄漏"
|
||||
echo ""
|
||||
echo " 4. I/O 等待高:可能是数据库查询慢或磁盘慢"
|
||||
echo ""
|
||||
echo "💡 临时降负载方法:"
|
||||
echo " - 暂停市场扫描(在配置中设置 SCAN_ENABLED=False)"
|
||||
echo " - 等待同步操作完成(不要手动取消)"
|
||||
echo " - 重启交易服务(如果进程异常)"
|
||||
echo " - 降低扫描并发(设置 SCAN_CONCURRENT_SYMBOLS=1)"
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"blacklist": [],
|
||||
"increase_position": [],
|
||||
"decrease_position": [],
|
||||
"worst_hours": []
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
[
|
||||
{"key":"AUTO_TRADE_ONLY_TRENDING","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"BETA_FILTER_ENABLED","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"BETA_FILTER_THRESHOLD","value":-0.012,"type":"number","category":"strategy"},
|
||||
|
||||
{"key":"MIN_SIGNAL_STRENGTH","value":7,"type":"number","category":"strategy"},
|
||||
{"key":"RANGING_MARKET_SIGNAL_BOOST","value":1,"type":"number","category":"strategy"},
|
||||
|
||||
{"key":"NO_OPEN_HOURS_BJ","value":"2,3,4,5","type":"string","category":"risk"},
|
||||
|
||||
{"key":"MANUAL_BLOCKED_SYMBOLS","value":"VVVUSDT","type":"string","category":"strategy"},
|
||||
{"key":"MANUAL_REDUCED_SYMBOLS","value":"TAOUSDT,VVVUSDT,CUSDT,FETUSDT,GUNUSDT,ONUSDT","type":"string","category":"strategy"},
|
||||
{"key":"MANUAL_REDUCED_SYMBOL_POSITION_FACTOR","value":0.6,"type":"number","category":"strategy"},
|
||||
{"key":"MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST","value":1,"type":"number","category":"strategy"},
|
||||
|
||||
{"key":"ENTRY_PULLBACK_FILTER_ENABLED","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_INTERVAL","value":"","type":"string","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_LOOKBACK_BARS","value":20,"type":"number","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_MIN_BARS","value":5,"type":"number","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_MAX_LONG_IN_RANGE","value":0.62,"type":"number","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_MIN_SHORT_IN_RANGE","value":0.38,"type":"number","category":"strategy"},
|
||||
|
||||
{"key":"SHADOW_MODE_AUTO_APPLY","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_MIN_CONFIDENCE","value":0.65,"type":"number","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_INCREASE_LEVERAGE_MULT","value":1.35,"type":"number","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_DECREASE_LEVERAGE_MULT","value":0.6,"type":"number","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_SUGGESTIONS_PATH","value":"config/current_suggestions.json","type":"string","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_TRACKING_PATH","value":"config/shadow_mode_tracking.json","type":"string","category":"strategy"}
|
||||
]
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
[
|
||||
{"key":"AUTO_TRADE_ONLY_TRENDING","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"BETA_FILTER_ENABLED","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"BETA_FILTER_THRESHOLD","value":-0.01,"type":"number","category":"strategy"},
|
||||
|
||||
{"key":"MIN_SIGNAL_STRENGTH","value":8,"type":"number","category":"strategy"},
|
||||
{"key":"RANGING_MARKET_SIGNAL_BOOST","value":2,"type":"number","category":"strategy"},
|
||||
|
||||
{"key":"NO_OPEN_HOURS_BJ","value":"1,2,3,4,5,6,7,13,22","type":"string","category":"risk"},
|
||||
|
||||
{"key":"MANUAL_BLOCKED_SYMBOLS","value":"TAOUSDT,VVVUSDT","type":"string","category":"strategy"},
|
||||
{"key":"MANUAL_REDUCED_SYMBOLS","value":"TAOUSDT,VVVUSDT,CUSDT,FETUSDT,GUNUSDT,LIGHTUSDT,MUSDT,ONUSDT","type":"string","category":"strategy"},
|
||||
{"key":"MANUAL_REDUCED_SYMBOL_POSITION_FACTOR","value":0.4,"type":"number","category":"strategy"},
|
||||
{"key":"MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST","value":2,"type":"number","category":"strategy"},
|
||||
|
||||
{"key":"ENTRY_PULLBACK_FILTER_ENABLED","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_INTERVAL","value":"","type":"string","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_LOOKBACK_BARS","value":24,"type":"number","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_MIN_BARS","value":5,"type":"number","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_MAX_LONG_IN_RANGE","value":0.55,"type":"number","category":"strategy"},
|
||||
{"key":"ENTRY_PULLBACK_MIN_SHORT_IN_RANGE","value":0.45,"type":"number","category":"strategy"},
|
||||
|
||||
{"key":"SHADOW_MODE_AUTO_APPLY","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_MIN_CONFIDENCE","value":0.7,"type":"number","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_INCREASE_LEVERAGE_MULT","value":1.2,"type":"number","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_DECREASE_LEVERAGE_MULT","value":0.5,"type":"number","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_SUGGESTIONS_PATH","value":"config/current_suggestions.json","type":"string","category":"strategy"},
|
||||
{"key":"SHADOW_MODE_TRACKING_PATH","value":"config/shadow_mode_tracking.json","type":"string","category":"strategy"}
|
||||
]
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
[
|
||||
{
|
||||
"key": "AUTO_TRADE_SYMBOL_WHITELIST",
|
||||
"value": "BTCUSDT,ETHUSDT,BNBUSDT,SOLUSDT,LINKUSDT,DOGEUSDT,AVAXUSDT,XRPUSDT",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "混合档:4主流 + 4高流动山寨(LINK/DOGE/AVAX/XRP);仅名单内自动下单"
|
||||
},
|
||||
{
|
||||
"key": "MANUAL_REDUCED_SYMBOLS",
|
||||
"value": "LINKUSDT,DOGEUSDT,AVAXUSDT,XRPUSDT",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "山寨部分:减仓观察(仓位×0.5)且信号门槛+1"
|
||||
},
|
||||
{
|
||||
"key": "MANUAL_REDUCED_SYMBOL_POSITION_FACTOR",
|
||||
"value": 0.5,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "减仓名单仓位系数"
|
||||
},
|
||||
{
|
||||
"key": "MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST",
|
||||
"value": 1,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "减仓名单额外信号门槛+1"
|
||||
},
|
||||
{
|
||||
"key": "MANUAL_BLOCKED_SYMBOLS",
|
||||
"value": "",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "混合档:清空旧山寨黑名单,按需再手动加"
|
||||
},
|
||||
{
|
||||
"key": "AUTO_TRADE_ALLOW_RANGING",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "允许震荡开仓;ranging 叠加 RANGING_MARKET_SIGNAL_BOOST"
|
||||
},
|
||||
{
|
||||
"key": "AUTO_TRADE_ONLY_TRENDING",
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "非仅 trending,与 ALLOW_RANGING 配合"
|
||||
},
|
||||
{
|
||||
"key": "MIN_SIGNAL_STRENGTH",
|
||||
"value": 7,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "主流基础门槛7;ranging+2;减仓山寨再+1"
|
||||
},
|
||||
{
|
||||
"key": "RANGING_MARKET_SIGNAL_BOOST",
|
||||
"value": 2,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "震荡市额外+2信号分"
|
||||
},
|
||||
{
|
||||
"key": "MIN_CHANGE_PERCENT",
|
||||
"value": 1.0,
|
||||
"type": "number",
|
||||
"category": "scan",
|
||||
"description": "24h涨跌幅绝对值≥1%进入候选"
|
||||
},
|
||||
{
|
||||
"key": "BETA_FILTER_ENABLED",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "保留大盘共振:BTC/ETH急跌时禁多"
|
||||
},
|
||||
{
|
||||
"key": "BETA_FILTER_THRESHOLD",
|
||||
"value": -0.008,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "15m/1h跌超0.8%触发禁多(与major_coins一致)"
|
||||
},
|
||||
{
|
||||
"key": "NO_OPEN_HOURS_BJ",
|
||||
"value": "",
|
||||
"type": "string",
|
||||
"category": "risk",
|
||||
"description": "清空离散禁开小时"
|
||||
},
|
||||
{
|
||||
"key": "NIGHT_HOURS_NO_OPEN_ENABLED",
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "risk",
|
||||
"description": "关闭晚间禁开窗口"
|
||||
},
|
||||
{
|
||||
"key": "ENTRY_SYMBOL_COOLDOWN_SEC",
|
||||
"value": 1200,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "同币冷却20分钟"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
[
|
||||
{
|
||||
"key": "AUTO_TRADE_ALLOW_RANGING",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "主流币机会档:允许震荡(ranging)自动开仓;仍保留白名单与 RANGING_MARKET_SIGNAL_BOOST 抬高门槛"
|
||||
},
|
||||
{
|
||||
"key": "AUTO_TRADE_ONLY_TRENDING",
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "与 ALLOW_RANGING 配合:非仅 trending;ranging 需信号更强才开"
|
||||
},
|
||||
{
|
||||
"key": "MIN_SIGNAL_STRENGTH",
|
||||
"value": 7,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "基础信号门槛 7;ranging 时叠加 RANGING_MARKET_SIGNAL_BOOST(+2) 实际需≥9"
|
||||
},
|
||||
{
|
||||
"key": "RANGING_MARKET_SIGNAL_BOOST",
|
||||
"value": 2,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "震荡市额外 +2 信号分,避免 ranging 乱开"
|
||||
},
|
||||
{
|
||||
"key": "NO_OPEN_HOURS_BJ",
|
||||
"value": "",
|
||||
"type": "string",
|
||||
"category": "risk",
|
||||
"description": "主流币模式:清空山寨时代离散禁开小时"
|
||||
},
|
||||
{
|
||||
"key": "NIGHT_HOURS_NO_OPEN_ENABLED",
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "risk",
|
||||
"description": "主流币模式:关闭晚间连续禁开窗口"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
[
|
||||
{"key":"AUTO_TRADE_SYMBOL_WHITELIST","value":"BTCUSDT,ETHUSDT,BNBUSDT,SOLUSDT","type":"string","category":"strategy"},
|
||||
{"key":"EXCLUDE_MAJOR_COINS","value":false,"type":"boolean","category":"scan"},
|
||||
{"key":"AUTO_TRADE_ONLY_TRENDING","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"AUTO_TRADE_ALLOW_4H_NEUTRAL","value":false,"type":"boolean","category":"strategy"},
|
||||
{"key":"MIN_SIGNAL_STRENGTH","value":8,"type":"number","category":"strategy"},
|
||||
{"key":"RANGING_MARKET_SIGNAL_BOOST","value":2,"type":"number","category":"strategy"},
|
||||
{"key":"TOP_N_SYMBOLS","value":8,"type":"number","category":"scan"},
|
||||
{"key":"MAX_SCAN_SYMBOLS","value":120,"type":"number","category":"scan"},
|
||||
{"key":"MIN_VOLUME_24H","value":80000000,"type":"number","category":"scan"},
|
||||
{"key":"MIN_VOLUME_24H_STRICT","value":120000000,"type":"number","category":"scan"},
|
||||
{"key":"MAX_OPEN_POSITIONS","value":2,"type":"number","category":"risk"},
|
||||
{"key":"MAX_DAILY_ENTRIES","value":6,"type":"number","category":"risk"},
|
||||
{"key":"USE_FIXED_RISK_SIZING","value":true,"type":"boolean","category":"risk"},
|
||||
{"key":"FIXED_RISK_PERCENT","value":0.005,"type":"number","category":"risk"},
|
||||
{"key":"LEVERAGE","value":2,"type":"number","category":"risk"},
|
||||
{"key":"MIN_LEVERAGE","value":2,"type":"number","category":"risk"},
|
||||
{"key":"MAX_LEVERAGE","value":5,"type":"number","category":"risk"},
|
||||
{"key":"MAX_LEVERAGE_SMALL_CAP","value":3,"type":"number","category":"risk"},
|
||||
{"key":"BETA_FILTER_ENABLED","value":true,"type":"boolean","category":"strategy"},
|
||||
{"key":"BETA_FILTER_THRESHOLD","value":-0.008,"type":"number","category":"strategy"},
|
||||
{"key":"NO_OPEN_HOURS_BJ","value":"2,3,4,5","type":"string","category":"risk"}
|
||||
]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
[
|
||||
{
|
||||
"key": "MIN_CHANGE_PERCENT",
|
||||
"value": 1.0,
|
||||
"type": "number",
|
||||
"category": "scan",
|
||||
"description": "稳妥增效一档:24h涨跌幅绝对值门槛从2%降至1%,主流币更易进入候选池"
|
||||
},
|
||||
{
|
||||
"key": "MIN_SIGNAL_STRENGTH",
|
||||
"value": 7,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "稳妥增效一档:最低信号强度 8→7,略增机会"
|
||||
},
|
||||
{
|
||||
"key": "ENTRY_SYMBOL_COOLDOWN_SEC",
|
||||
"value": 1200,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "稳妥增效一档:同币种开仓冷却 1800s→1200s(20分钟)"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
[
|
||||
{
|
||||
"key": "VOLATILITY_PULLBACK_ENABLED",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "启用波动回调策略:4H+1H同向,1H区间极值,15m波动触发"
|
||||
},
|
||||
{
|
||||
"key": "VOLATILITY_PULLBACK_AUTO_ONLY",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "自动交易仅使用VP信号,不fallback MACD趋势"
|
||||
},
|
||||
{
|
||||
"key": "VOLATILITY_PULLBACK_SKIP_REGIME_GATE",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "VP信号跳过 ranging/trending 门禁"
|
||||
},
|
||||
{
|
||||
"key": "VP_INSTANT_FLUSH_ENABLED",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "即时模式:进行中1H快速波动+前结构低/高点入场"
|
||||
},
|
||||
{
|
||||
"key": "VP_PRIOR_STRUCTURE_BARS",
|
||||
"value": 12,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "前结构位:已完成1H K线根数(不含当前进行中一根)"
|
||||
},
|
||||
{
|
||||
"key": "VP_1H_FLUSH_MIN_PCT",
|
||||
"value": 0.005,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "进行中1H从开盘下冲/上冲至少0.5%"
|
||||
},
|
||||
{
|
||||
"key": "VP_NEAR_PRIOR_LOW_PCT",
|
||||
"value": 0.008,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "做多:现价在前低上方0.8%内"
|
||||
},
|
||||
{
|
||||
"key": "VP_MAX_BELOW_PRIOR_LOW_PCT",
|
||||
"value": 0.004,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "做多:允许刺破前低0.4%仍算入场区"
|
||||
},
|
||||
{
|
||||
"key": "VP_NEAR_PRIOR_HIGH_PCT",
|
||||
"value": 0.008,
|
||||
"type": "number",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "VP_MAX_ABOVE_PRIOR_HIGH_PCT",
|
||||
"value": 0.004,
|
||||
"type": "number",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "VP_VOL_LOOKBACK_BARS",
|
||||
"value": 3,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "15m波动:近3根(约45分钟)"
|
||||
},
|
||||
{
|
||||
"key": "VP_LONG_MIN_DROP_PCT",
|
||||
"value": 0.006,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "15m合计跌0.6%可作为辅助触发"
|
||||
},
|
||||
{
|
||||
"key": "VP_SHORT_MIN_RISE_PCT",
|
||||
"value": 0.006,
|
||||
"type": "number",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "VP_SCAN_INTERVAL_SEC",
|
||||
"value": 180,
|
||||
"type": "number",
|
||||
"category": "scan",
|
||||
"description": "VP启用时扫描间隔180秒(3分钟)"
|
||||
},
|
||||
{
|
||||
"key": "SCAN_INTERVAL",
|
||||
"value": 180,
|
||||
"type": "number",
|
||||
"category": "scan",
|
||||
"description": "与VP_SCAN一致,提高抓取频率"
|
||||
},
|
||||
{
|
||||
"key": "VP_REQUIRE_1H_4H_ALIGN",
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "即时模式下仅4H定方向(急跌抄底不要求1H同向)"
|
||||
},
|
||||
{
|
||||
"key": "VP_RANGE_INTERVAL",
|
||||
"value": "1h",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "区间锚定周期"
|
||||
},
|
||||
{
|
||||
"key": "VP_RANGE_LOOKBACK_BARS",
|
||||
"value": 24,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "1H区间回看根数"
|
||||
},
|
||||
{
|
||||
"key": "VP_MAX_LONG_IN_RANGE",
|
||||
"value": 0.42,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "做多:1H区间位置上限(靠近前低)"
|
||||
},
|
||||
{
|
||||
"key": "VP_MIN_SHORT_IN_RANGE",
|
||||
"value": 0.58,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "做空:1H区间位置下限(靠近前高)"
|
||||
},
|
||||
{
|
||||
"key": "VP_VOL_INTERVAL",
|
||||
"value": "15m",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "瞬时波动观察周期"
|
||||
},
|
||||
{
|
||||
"key": "VP_SIGNAL_STRENGTH",
|
||||
"value": 8,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "VP信号强度(用于杠杆/日志)"
|
||||
},
|
||||
{
|
||||
"key": "VP_ATR_STOP_MULTIPLIER",
|
||||
"value": 1.2,
|
||||
"type": "number",
|
||||
"category": "risk",
|
||||
"description": "VP止损:ATR倍数(相对1H ATR)"
|
||||
},
|
||||
{
|
||||
"key": "VP_STRUCTURE_STOP_BUFFER_PCT",
|
||||
"value": 0.003,
|
||||
"type": "number",
|
||||
"category": "risk",
|
||||
"description": "结构止损:1H前低/前高外缓冲0.3%"
|
||||
},
|
||||
{
|
||||
"key": "AUTO_TRADE_SYMBOL_WHITELIST",
|
||||
"value": "BTCUSDT,ETHUSDT,BNBUSDT,SOLUSDT,LINKUSDT,DOGEUSDT,AVAXUSDT,XRPUSDT",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "混合白名单"
|
||||
},
|
||||
{
|
||||
"key": "MANUAL_REDUCED_SYMBOLS",
|
||||
"value": "LINKUSDT,DOGEUSDT,AVAXUSDT,XRPUSDT",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "山寨减仓名单"
|
||||
},
|
||||
{
|
||||
"key": "MANUAL_REDUCED_SYMBOL_POSITION_FACTOR",
|
||||
"value": 0.5,
|
||||
"type": "number",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST",
|
||||
"value": 1,
|
||||
"type": "number",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "AUTO_TRADE_ONLY_TRENDING",
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "AUTO_TRADE_ALLOW_RANGING",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "BETA_FILTER_ENABLED",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "USE_TREND_ENTRY_FILTER",
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "VP模式下关闭趋势追价过滤"
|
||||
},
|
||||
{
|
||||
"key": "NO_OPEN_HOURS_BJ",
|
||||
"value": "",
|
||||
"type": "string",
|
||||
"category": "risk"
|
||||
},
|
||||
{
|
||||
"key": "NIGHT_HOURS_NO_OPEN_ENABLED",
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "risk"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
[
|
||||
{
|
||||
"key": "VP_ALLOW_4H_NEUTRAL",
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "增机会:4H中性时按1H方向+波动触发"
|
||||
},
|
||||
{
|
||||
"key": "VP_1H_FLUSH_MIN_PCT",
|
||||
"value": 0.003,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "1H进行中波动门槛 0.5%→0.3%"
|
||||
},
|
||||
{
|
||||
"key": "VP_NEAR_PRIOR_LOW_PCT",
|
||||
"value": 0.012,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "入场区放宽:前低上方1.2%内"
|
||||
},
|
||||
{
|
||||
"key": "VP_MAX_BELOW_PRIOR_LOW_PCT",
|
||||
"value": 0.006,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "允许刺破前低0.6%"
|
||||
},
|
||||
{
|
||||
"key": "VP_NEAR_PRIOR_HIGH_PCT",
|
||||
"value": 0.012,
|
||||
"type": "number",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "VP_MAX_ABOVE_PRIOR_HIGH_PCT",
|
||||
"value": 0.006,
|
||||
"type": "number",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "VP_LONG_MIN_DROP_PCT",
|
||||
"value": 0.004,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "15m辅助触发 0.6%→0.4%"
|
||||
},
|
||||
{
|
||||
"key": "VP_SHORT_MIN_RISE_PCT",
|
||||
"value": 0.004,
|
||||
"type": "number",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "VP_VOL_LOOKBACK_BARS",
|
||||
"value": 3,
|
||||
"type": "number",
|
||||
"category": "strategy"
|
||||
},
|
||||
{
|
||||
"key": "VP_PRIOR_STRUCTURE_BARS",
|
||||
"value": 18,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "结构位回看略放宽"
|
||||
},
|
||||
{
|
||||
"key": "VP_SCAN_INTERVAL_SEC",
|
||||
"value": 120,
|
||||
"type": "number",
|
||||
"category": "scan",
|
||||
"description": "扫描 3min→2min"
|
||||
},
|
||||
{
|
||||
"key": "SCAN_INTERVAL",
|
||||
"value": 120,
|
||||
"type": "number",
|
||||
"category": "scan"
|
||||
},
|
||||
{
|
||||
"key": "BETA_FILTER_THRESHOLD",
|
||||
"value": -0.012,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "大盘禁多略放宽:15m/1h跌1.2%才拦"
|
||||
},
|
||||
{
|
||||
"key": "ENTRY_SYMBOL_COOLDOWN_SEC",
|
||||
"value": 600,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "同币冷却 20min→10min"
|
||||
},
|
||||
{
|
||||
"key": "MAX_OPEN_POSITIONS",
|
||||
"value": 3,
|
||||
"type": "number",
|
||||
"category": "risk"
|
||||
},
|
||||
{
|
||||
"key": "TOP_N_SYMBOLS",
|
||||
"value": 12,
|
||||
"type": "number",
|
||||
"category": "scan",
|
||||
"description": "每轮多扫几个候选"
|
||||
},
|
||||
{
|
||||
"key": "MIN_CHANGE_PERCENT",
|
||||
"value": 0.8,
|
||||
"type": "number",
|
||||
"category": "scan",
|
||||
"description": "前置涨跌幅 1%→0.8%"
|
||||
},
|
||||
{
|
||||
"key": "MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST",
|
||||
"value": 0,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "山寨不再额外+1信号门槛"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"accuracy": 1.0,
|
||||
"rolling_accuracy": 1.0,
|
||||
"notes": "由 shadow_mode_analyzer / 人工更新;低于 SHADOW_MODE_MIN_CONFIDENCE 时不自动应用建议"
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
{
|
||||
"AUTO_TRADE_ONLY_TRENDING": {
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "仅趋势市自动下单;震荡/unknown 只出推荐"
|
||||
},
|
||||
"AUTO_TRADE_ALLOW_4H_NEUTRAL": {
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "4H 中性时不自动下单,减少震荡扫损"
|
||||
},
|
||||
"AUTO_TRADE_ALLOW_RANGING": {
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "与 ONLY_TRENDING 配合:不在横盘自动开仓"
|
||||
},
|
||||
"AUTO_TRADE_ALLOW_UNKNOWN": {
|
||||
"value": false,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "市场状态未判定时不自动开仓"
|
||||
},
|
||||
"MIN_SIGNAL_STRENGTH": {
|
||||
"value": 9,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "提高入场门槛,减少边缘单"
|
||||
},
|
||||
"RANGING_MARKET_SIGNAL_BOOST": {
|
||||
"value": 3,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "震荡市在基础门槛上再加 3"
|
||||
},
|
||||
"TOP_N_SYMBOLS": {
|
||||
"value": 12,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "每次扫描深入分析的前 N 个标的,略缩小候选池"
|
||||
},
|
||||
"AUTO_TRADE_SYMBOL_WHITELIST": {
|
||||
"value": "",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "核心:填逗号分隔合约如 SOLUSDT,LINKUSDT 则仅这些自动下单;留空则不限(仅启用下面黑名单/降仓)。"
|
||||
},
|
||||
"MANUAL_BLOCKED_SYMBOLS": {
|
||||
"value": "MOVRUSDT",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "曾出现单日大亏的标的可拉黑"
|
||||
},
|
||||
"MANUAL_REDUCED_SYMBOLS": {
|
||||
"value": "HYPEUSDT,XANUSDT,WIFUSDT,ANKRUSDT,RIVERUSDT,POLYXUSDT,PIPPINUSDT,AINUSDT,ANIMEUSDT,COSUSDT,GUNUSDT,HYPERUSDT,WETUSDT,MONUSDT,ONUSDT,MYXUSDT,TRADOORUSDT",
|
||||
"type": "string",
|
||||
"category": "strategy",
|
||||
"description": "弱势/反复试错币:降仓并抬高信号门槛"
|
||||
},
|
||||
"MANUAL_REDUCED_SYMBOL_POSITION_FACTOR": {
|
||||
"value": 0.35,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "降仓名单仓位系数"
|
||||
},
|
||||
"MANUAL_REDUCED_SYMBOL_SIGNAL_BOOST": {
|
||||
"value": 1,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "降仓名单额外 +1 信号门槛"
|
||||
},
|
||||
"ENTRY_PULLBACK_FILTER_ENABLED": {
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "回踩/区间入场过滤"
|
||||
},
|
||||
"ENTRY_PULLBACK_MIN_BARS": {
|
||||
"value": 6,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "略严一档,减少追高"
|
||||
},
|
||||
"ENTRY_PULLBACK_MAX_LONG_IN_RANGE": {
|
||||
"value": 0.56,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "做多要求在区间偏低位置"
|
||||
},
|
||||
"ENTRY_PULLBACK_MIN_SHORT_IN_RANGE": {
|
||||
"value": 0.44,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "做空要求在区间偏高位置"
|
||||
},
|
||||
"BETA_FILTER_ENABLED": {
|
||||
"value": true,
|
||||
"type": "boolean",
|
||||
"category": "strategy",
|
||||
"description": "大盘共振过滤"
|
||||
},
|
||||
"BETA_FILTER_THRESHOLD": {
|
||||
"value": -0.01,
|
||||
"type": "number",
|
||||
"category": "strategy",
|
||||
"description": "与推荐脚本默认一致"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
# DB 与币安订单对账说明
|
||||
|
||||
## 一、查询系统今日落入 DB 的单子
|
||||
|
||||
### 1. 命令行脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 今日、默认账号、按创建时间(落库时间)
|
||||
python scripts/query_trades_today.py
|
||||
|
||||
# 指定账号
|
||||
python scripts/query_trades_today.py --account 2
|
||||
|
||||
# 指定日期
|
||||
python scripts/query_trades_today.py --date 2026-02-21
|
||||
|
||||
# 按入场时间筛选
|
||||
python scripts/query_trades_today.py --time-filter entry
|
||||
|
||||
# 仅可对账记录(有开仓/平仓订单号)
|
||||
python scripts/query_trades_today.py --reconciled-only
|
||||
|
||||
# 导出到 JSON 文件
|
||||
python scripts/query_trades_today.py -o today_trades.json
|
||||
```
|
||||
|
||||
### 2. API 接口
|
||||
|
||||
```
|
||||
GET /api/trades?period=today&time_filter=created&reconciled_only=false
|
||||
```
|
||||
|
||||
- `period=today`:今天
|
||||
- `time_filter=created`:按创建时间(落库时间),便于对照「何时写入 DB」
|
||||
- `time_filter=entry`:按入场时间
|
||||
- `time_filter=exit`:按平仓时间
|
||||
- `reconciled_only=false`:包含所有记录(含取消、无订单号)
|
||||
|
||||
### 3. 前端导出
|
||||
|
||||
交易记录页面 → 选择「今天」→ 导出 JSON / Excel。
|
||||
|
||||
---
|
||||
|
||||
## 二、币安订单推送日志
|
||||
|
||||
系统会将收到的 **ORDER_TRADE_UPDATE**、**ALGO_UPDATE** 写入日志,便于与 DB 对照。
|
||||
|
||||
### 日志路径
|
||||
|
||||
```
|
||||
{项目根}/logs/binance_order_events.log
|
||||
```
|
||||
|
||||
### 格式
|
||||
|
||||
每行一条 JSON,例如:
|
||||
|
||||
```json
|
||||
{"ts":1737500000000,"event_type":"ORDER_TRADE_UPDATE","account_id":1,"E":1737500000123,"symbol":"BTCUSDT","orderId":123456,"clientOrderId":"SYS_1737500000_abcd","event":"TRADE","status":"FILLED","reduceOnly":false,"avgPrice":"95000","executedQty":"0.01","realizedPnl":"0"}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| ts | 本机接收时间戳 |
|
||||
| event_type | ORDER_TRADE_UPDATE / ALGO_UPDATE |
|
||||
| account_id | 账号 ID |
|
||||
| E | 币安事件时间(毫秒) |
|
||||
| symbol | 交易对 |
|
||||
| orderId | 币安订单号 |
|
||||
| clientOrderId | 自定义订单号(系统前缀) |
|
||||
| event | NEW/TRADE/CANCELED |
|
||||
| status | NEW/FILLED/CANCELED 等 |
|
||||
| reduceOnly | 是否只减仓(平仓单) |
|
||||
| avgPrice/executedQty | 成交价/成交量(FILLED 时) |
|
||||
| realizedPnl | 实现盈亏(平仓时) |
|
||||
| algoId/triggeredOrderId | ALGO_UPDATE 专用 |
|
||||
|
||||
### 对账用法
|
||||
|
||||
```bash
|
||||
# 查看今天收到的所有订单推送
|
||||
grep "ORDER_TRADE_UPDATE" logs/binance_order_events.log
|
||||
|
||||
# 查看 FILLED 成交
|
||||
grep '"status":"FILLED"' logs/binance_order_events.log
|
||||
|
||||
# 按 clientOrderId 对照
|
||||
grep "SYS_1737500000_abcd" logs/binance_order_events.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、从币安拉取订单/成交(DB 缺失时)
|
||||
|
||||
当 DB 记录查不到或需直接从币安做策略分析时,可用脚本拉取:
|
||||
|
||||
```bash
|
||||
# 拉取最近 7 天成交记录(默认,适合策略分析)
|
||||
python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT
|
||||
|
||||
# 多个交易对
|
||||
python scripts/fetch_binance_orders.py --account 2 --symbols ASTERUSDT,FILUSDT,PENGUUSDT
|
||||
|
||||
# 拉取订单列表
|
||||
python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT --type orders
|
||||
|
||||
# 指定天数、导出
|
||||
python scripts/fetch_binance_orders.py --account 2 --symbol BTCUSDT --days 7 -o binance_trades.json
|
||||
```
|
||||
|
||||
- `--type trades`:成交记录(含价格、数量、盈亏,策略分析推荐)
|
||||
- `--type orders`:订单列表(含 FILLED/CANCELED)
|
||||
- 币安单次时间范围最多 7 天
|
||||
|
||||
---
|
||||
|
||||
## 四、对账流程建议
|
||||
|
||||
1. **查 DB 今日记录**:`python scripts/query_trades_today.py -o db_today.json`
|
||||
2. **查币安推送日志**:`tail -f logs/binance_order_events.log` 或 `grep "ORDER_TRADE_UPDATE" logs/binance_order_events.log`
|
||||
3. **对照**:用 `clientOrderId` 或 `orderId` 关联 DB 记录与推送日志,确认:
|
||||
- DB 有 pending 且收到 FILLED 推送 → 应更新为 open
|
||||
- DB 有 open 且收到 reduceOnly FILLED → 应更新 exit_order_id
|
||||
- 收到推送但 DB 无对应记录 → 可能漏建或为手动单
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
# 山寨币专属策略配置更新总结
|
||||
|
||||
> 更新时间: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笔交易,确保新策略按预期运行。如有异常,立即暂停并检查日志。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user