Compare commits

..

No commits in common. "master" and "all_in_one" have entirely different histories.

321 changed files with 3097 additions and 60712 deletions

View File

@ -1,8 +0,0 @@
node_modules
__pycache__
*.pyc
.venv
venv
.git
logs
*.log

View File

@ -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 密钥、数据库连接等)必须在服务器上配置,本地环境不得包含任何敏感信息。

View File

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

View File

@ -2,8 +2,6 @@
基于币安API的Python自动交易系统实现自动发现涨跌幅最大的货币对并执行交易策略。
**文档导航**:详细文档列表与优先阅读顺序见 [INDEX.md](INDEX.md)。历史/一次性分析已移至 [archive/](archive/)。
## 项目结构
```

View File

@ -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.020.025)。代码默认:止损 2.5%、止盈 2%。
## 如何配置

View File

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

View File

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

View File

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

View File

@ -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=1default
try:
UserAccountMembership.add(int(uid), 1, role="owner")
except Exception:
pass
logger.info(f"✓ 已创建初始管理员用户: {username} (id={uid})")
else:
# 若已存在但不是 admin则提升为 admin可注释掉更保守
if (u.get("role") or "user") != "admin":
try:
User.set_role(int(u["id"]), "admin")
logger.warning(f"已将用户 {username} 提升为 admin")
except Exception:
pass
except Exception as e:
logger.warning(f"初始化管理员失败(可忽略): {e}")
# 启动现货推荐定时扫描(后台任务)
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

View File

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

View File

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

View File

@ -1,71 +0,0 @@
"""
登录鉴权 APIJWT
"""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
import os
from database.models import User
from api.auth_utils import verify_password, jwt_encode
from api.auth_deps import get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginReq(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=1, max_length=200)
class LoginResp(BaseModel):
access_token: str
token_type: str = "bearer"
user: Dict[str, Any]
def _auth_enabled() -> bool:
v = (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower()
return v not in {"0", "false", "no"}
@router.post("/login", response_model=LoginResp)
async def login(payload: LoginReq):
if not _auth_enabled():
raise HTTPException(status_code=400, detail="当前环境未启用登录ATS_AUTH_ENABLED=false")
u = User.get_by_username(payload.username)
if not u:
raise HTTPException(status_code=401, detail="用户名或密码错误")
if (u.get("status") or "active") != "active":
raise HTTPException(status_code=403, detail="用户已被禁用")
if not verify_password(payload.password, u.get("password_hash") or ""):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = jwt_encode({"sub": str(u["id"]), "role": u.get("role") or "user"}, exp_sec=24 * 3600)
return {
"access_token": token,
"token_type": "bearer",
"user": {"id": u["id"], "username": u["username"], "role": u.get("role") or "user", "status": u.get("status") or "active"},
}
class MeResp(BaseModel):
id: int
username: str
role: str
status: str
@router.get("/me", response_model=MeResp)
async def me(user: Dict[str, Any] = Depends(get_current_user)):
return {
"id": int(user["id"]),
"username": user.get("username") or "",
"role": user.get("role") or "user",
"status": user.get("status") or "active",
}

File diff suppressed because it is too large Load Diff

View File

@ -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",
}

View File

@ -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可用性不暴露密码
- recommendationssnapshot 最新生成时间若推荐进程在跑会持续更新
"""
now_ms = int(time.time() * 1000)
# Redis + 推荐快照
redis_ok = False
reco: Dict[str, Any] = {"snapshot_ok": False}
redis_meta: Dict[str, Any] = {"ok": False, "db": int(os.getenv("REDIS_SELECT", "0") or 0), "url": _mask_redis_url(os.getenv("REDIS_URL", ""))}
rds = await _get_redis()
if rds is not None:
redis_ok = True
redis_meta["ok"] = True
try:
snap = await _get_cached_json(rds, REDIS_KEY_RECOMMENDATIONS_SNAPSHOT)
except Exception:
snap = None
if isinstance(snap, dict):
gen_ms = snap.get("generated_at_ms")
try:
gen_ms = int(gen_ms) if gen_ms is not None else None
except Exception:
gen_ms = None
count = snap.get("count")
try:
count = int(count) if count is not None else None
except Exception:
count = None
age_sec = None
if gen_ms:
age_sec = max(0, int((now_ms - gen_ms) / 1000))
reco = {
"snapshot_ok": True,
"generated_at_ms": gen_ms,
"generated_at": snap.get("generated_at"),
"generated_at_beijing": _beijing_time_str(gen_ms) if gen_ms else None,
"age_sec": age_sec,
"count": count,
"ttl_sec": snap.get("ttl_sec"),
}
try:
await rds.close()
except Exception:
pass
return {
"backend": {
"running": True,
"started_at_ms": _STARTED_AT_MS,
"started_at": _beijing_time_str(_STARTED_AT_MS),
"now_ms": now_ms,
"now": _beijing_time_str(now_ms),
},
"redis": redis_meta,
"recommendations": reco,
"auth": {
"enabled": (os.getenv("ATS_AUTH_ENABLED") or "true").strip().lower() not in {"0", "false", "no"},
},
}

View File

@ -366,14 +366,6 @@ 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,
@ -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():
"""

View File

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

View File

@ -6,7 +6,7 @@ import time
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from fastapi import APIRouter, HTTPException, Header, 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 条测试日志到 Rediserror/warning/info用于验证是否写入到同一台 Redis同一组 key
"""
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
client = _get_redis_client_for_logs()
if client is None:
raise HTTPException(status_code=503, detail="Redis 不可用,无法写入测试日志")
@ -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. 优先 pgrepLinux/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 + greppgrep 不可用或匹配失败时)
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

View File

@ -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)
# 当前进程 pythonbackend venv
candidates.append(sys.executable)
# 常见 venv 位置
candidates += [
str(project_root / "backend" / ".venv" / "bin" / "python"),
str(project_root / ".venv" / "bin" / "python"),
str(project_root / "trading_system" / ".venv" / "bin" / "python"),
"/usr/bin/python3",
"/usr/local/bin/python3",
]
python_bin = None
for c in candidates:
try:
p = Path(c)
if p.exists() and os.access(str(p), os.X_OK):
python_bin = str(p)
break
except Exception:
continue
if not python_bin:
# 最后兜底:写 sys.executable让错误能在日志里体现
python_bin = sys.executable
# 日志目录可通过环境变量覆盖
log_dir, out_log, err_log = expected_trading_log_paths(project_root, int(account_id))
# supervisor 在 reread/update 时会校验 logfile 目录是否存在;这里提前创建避免 CANT_REREAD
try:
log_dir.mkdir(parents=True, exist_ok=True)
except Exception:
# 最后兜底到 /tmp确保一定存在
log_dir = Path("/tmp") / "autosys_logs"
log_dir.mkdir(parents=True, exist_ok=True)
out_log = log_dir / f"trading_{int(account_id)}.out.log"
err_log = log_dir / f"trading_{int(account_id)}.err.log"
# 默认不自动启动,避免“创建账号=立刻下单”
autostart = (os.getenv("TRADING_AUTOSTART_DEFAULT", "false") or "false").lower() == "true"
run_user = (os.getenv("SUPERVISOR_RUN_USER") or "").strip()
return "\n".join(
[
f"[program:{program_name}]",
f"directory={project_root}",
f"command={python_bin} -m trading_system.main",
"autostart=" + ("true" if autostart else "false"),
# 更合理仅在“非0退出”时重启0 退出视为“正常结束”,不进入 FATAL 反复拉起
"autorestart=unexpected",
# 兼容0/2 都视为“预期退出”(例如配置不完整/前置检查失败时主动退出)
"exitcodes=0,2",
"startsecs=0",
"stopasgroup=true",
"killasgroup=true",
"stopsignal=TERM",
"",
# 关键PYTHONPATH 指向项目根,确保 -m trading_system.main 可导入
f'environment=ATS_ACCOUNT_ID="{int(account_id)}",PYTHONUNBUFFERED="1",PYTHONPATH="{project_root}"',
(f"user={run_user}" if run_user else "").rstrip(),
"",
f"stdout_logfile={out_log}",
f"stderr_logfile={err_log}",
"stdout_logfile_maxbytes=20MB",
"stdout_logfile_backups=5",
"stderr_logfile_maxbytes=20MB",
"stderr_logfile_backups=5",
"",
]
)
def write_program_ini(program_dir: Path, filename: str, content: str) -> Path:
program_dir.mkdir(parents=True, exist_ok=True)
target = program_dir / filename
tmp = program_dir / (filename + ".tmp")
tmp.write_text(content, encoding="utf-8")
os.replace(str(tmp), str(target))
return target
def _build_supervisorctl_cmd(args: list[str]) -> list[str]:
supervisorctl_path = _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),
)

View File

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

View File

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

View File

@ -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,242 +55,16 @@ 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客户端同步"""
if not REDIS_SYNC_AVAILABLE:
@ -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.02026-02-12优化减少噪音止损配合止盈拉远
'ATR_TAKE_PROFIT_MULTIPLIER': eff_get('ATR_TAKE_PROFIT_MULTIPLIER', 2.0), # ATR止盈倍数2.02026-01-27优化降低止盈目标更容易触发
'RISK_REWARD_RATIO': eff_get('RISK_REWARD_RATIO', 3.0), # 盈亏比3:12026-01-27优化降低更容易触发保证胜率
'ATR_PERIOD': eff_get('ATR_PERIOD', 14), # ATR计算周期
'USE_DYNAMIC_ATR_MULTIPLIER': eff_get('USE_DYNAMIC_ATR_MULTIPLIER', False), # 是否根据波动率动态调整ATR倍数
'ATR_MULTIPLIER_MIN': eff_get('ATR_MULTIPLIER_MIN', 1.5), # 动态ATR倍数最小值
'ATR_MULTIPLIER_MAX': eff_get('ATR_MULTIPLIER_MAX', 2.5), # 动态ATR倍数最大值
'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避免被压到 24x 导致单笔盈利过少)
'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),
'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),
# 持仓详细监控日志开关(用于排查问题时观察每次检查的当前价/目标价/ROE 等)
'POSITION_DETAILED_LOG_ENABLED': eff_get('POSITION_DETAILED_LOG_ENABLED', False),
# 动态过滤优化
'BETA_FILTER_ENABLED': eff_get('BETA_FILTER_ENABLED', True), # 大盘共振过滤BTC/ETH下跌时屏蔽多单
'BETA_FILTER_THRESHOLD': eff_get('BETA_FILTER_THRESHOLD', -0.005), # -0.5%2026-01-27优化更敏感地过滤大盘风险15分钟内跌幅超过0.5%即屏蔽多单)
# RSI / 24h 涨跌幅过滤(避免追高杀跌)
'MAX_RSI_FOR_LONG': eff_get('MAX_RSI_FOR_LONG', 65), # 做多时 RSI 超过此值则不开多2026-02-1265 避免追高)
'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):

View File

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

View File

@ -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='币安订单记录(定时同步)';

View File

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

View File

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

View File

@ -1,21 +0,0 @@
-- 为 trades 表添加「入场思路/过程」字段,便于事后分析策略执行效果
-- 存储 JSONsignal_strength, market_regime, trend_4h, change_percent, rsi, reason, volume_confirmed 等
-- 使用动态 SQL 检查列是否存在(兼容已有库)
SET @column_exists = (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'trades'
AND column_name = 'entry_context'
);
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `trades` ADD COLUMN `entry_context` JSON NULL COMMENT ''入场时的思路与过程(信号强度、市场状态、趋势、过滤通过情况等),便于综合分析策略执行效果'' AFTER `entry_reason`',
'SELECT "entry_context 列已存在,跳过添加" AS message'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT 'Migration completed: entry_context added to trades (if not exists).' AS result;

View File

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

View File

@ -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='市场数据缓存(交易对/费率规则等)';

View File

@ -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 KEYenc:v1:...',
`api_secret_enc` TEXT NULL COMMENT '加密后的 API SECRETenc:v1:...',
`use_testnet` BOOLEAN DEFAULT FALSE,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号表(多账号)';
INSERT INTO `accounts` (`id`, `name`, `status`, `use_testnet`)
VALUES (1, 'default', 'active', false)
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
-- 2) trading_config.account_id
SET @has_col := (
SELECT COUNT(1)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'trading_config'
AND COLUMN_NAME = 'account_id'
);
SET @sql := IF(@has_col = 0, 'ALTER TABLE trading_config ADD COLUMN account_id INT NOT NULL DEFAULT 1 AFTER id', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3) trades.account_id
SET @has_col := (
SELECT COUNT(1)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'trades'
AND COLUMN_NAME = 'account_id'
);
SET @sql := IF(@has_col = 0, 'ALTER TABLE trades ADD COLUMN account_id INT NOT NULL DEFAULT 1 AFTER id', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 4) account_snapshots.account_id
SET @has_col := (
SELECT COUNT(1)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'account_snapshots'
AND COLUMN_NAME = 'account_id'
);
SET @sql := IF(@has_col = 0, 'ALTER TABLE account_snapshots ADD COLUMN account_id INT NOT NULL DEFAULT 1 AFTER id', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 5) trading_config 唯一键:改为 (account_id, config_key)
-- 尝试删除旧 UNIQUE(config_key)(名字可能是 config_key 或其他)
SET @idx_name := (
SELECT INDEX_NAME
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'trading_config'
AND NON_UNIQUE = 0
AND COLUMN_NAME = 'config_key'
LIMIT 1
);
SET @sql := IF(@idx_name IS NOT NULL, CONCAT('ALTER TABLE trading_config DROP INDEX ', @idx_name), 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 添加新唯一键(如果不存在)
SET @has_uk := (
SELECT COUNT(1)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'trading_config'
AND INDEX_NAME = 'uk_account_config_key'
);
SET @sql := IF(@has_uk = 0, 'ALTER TABLE trading_config ADD UNIQUE KEY uk_account_config_key (account_id, config_key)', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 6) 索引(可选,老版本 MySQL 不支持 IF NOT EXISTS可忽略报错后手动检查
-- 如果你看到 “Duplicate key name” 可直接忽略。
CREATE INDEX idx_trades_account_id ON trades(account_id);
CREATE INDEX idx_account_snapshots_account_id ON account_snapshots(account_id);

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
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):
"""执行查询,返回所有结果"""

View File

@ -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;
-- 期望结果:空

View File

@ -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 KEYenc:v1:...',
`api_secret_enc` TEXT NULL COMMENT '加密后的 API SECRETenc:v1:...',
`use_testnet` BOOLEAN DEFAULT FALSE,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号表(多账号)';
-- 默认账号(兼容单账号)
INSERT INTO `accounts` (`id`, `name`, `status`, `use_testnet`)
VALUES (1, 'default', 'active', false)
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
-- 配置表
CREATE TABLE IF NOT EXISTS `trading_config` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`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`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,140 +0,0 @@
#!/usr/bin/env python3
"""
缺省全局配置项同步到数据库 global_strategy_config
- 已在 UI 保存过的项不会覆盖只插入缺失的 key
- 用于新上线配置项 MAX_RSI_FOR_LONGMIN_RSI_FOR_SHORT 一次性写入默认值
便于在数据库中可见可备份且不依赖先在页面改一次再保存
使用方式在项目根目录
cd backend && python sync_global_config_defaults.py
python backend/sync_global_config_defaults.py
"""
import os
import sys
from pathlib import Path
# 确保 backend 在路径中
backend_dir = Path(__file__).resolve().parent
if str(backend_dir) not in sys.path:
sys.path.insert(0, str(backend_dir))
# 需要同步的缺省项(仅插入数据库中不存在的 key
DEFAULTS_TO_SYNC = [
{"config_key": "MAX_RSI_FOR_LONG", "config_value": "65", "config_type": "number", "category": "strategy",
"description": "做多时 RSI 超过此值则不开多2026-02-1265 避免追高)。"},
{"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()

View File

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

View File

@ -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. 检查是否有大量未关闭的数据库连接"

View File

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

View File

@ -1,6 +0,0 @@
{
"blacklist": [],
"increase_position": [],
"decrease_position": [],
"worst_hours": []
}

View File

@ -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"}
]

View File

@ -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"}
]

View File

@ -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": "主流基础门槛7ranging+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分钟"
}
]

View File

@ -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 配合:非仅 trendingranging 需信号更强才开"
},
{
"key": "MIN_SIGNAL_STRENGTH",
"value": 7,
"type": "number",
"category": "strategy",
"description": "基础信号门槛 7ranging 时叠加 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": "主流币模式:关闭晚间连续禁开窗口"
}
]

View File

@ -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"}
]

View File

@ -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→1200s20分钟"
}
]

View File

@ -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"
}
]

View File

@ -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信号门槛"
}
]

View File

@ -1,5 +0,0 @@
{
"accuracy": 1.0,
"rolling_accuracy": 1.0,
"notes": "由 shadow_mode_analyzer / 人工更新;低于 SHADOW_MODE_MIN_CONFIDENCE 时不自动应用建议"
}

View File

@ -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": "与推荐脚本默认一致"
}
}

View File

@ -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 无对应记录 → 可能漏建或为手动单

View File

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