a
This commit is contained in:
parent
f798782a6d
commit
0c489bfdee
14
.cursorrules
Normal file
14
.cursorrules
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# 交易系统开发最高准则
|
||||||
|
|
||||||
|
## 1. 风险控制(核心)
|
||||||
|
- **止损高于一切**:严禁在任何平仓逻辑前添加时间限制。任何情况下,只要触发止损条件,必须立即执行平仓。
|
||||||
|
- **严禁恢复时间锁**:绝对不允许重新启用 `MIN_HOLD_TIME_SEC` 来限制止损或止盈。
|
||||||
|
- **异常处理**:所有涉及 `binance.create_order` 的操作必须包含 try-catch 逻辑,并有重试机制或错误预警。
|
||||||
|
|
||||||
|
## 2. 币安合约逻辑
|
||||||
|
- **挂单确认**:在开仓订单成交后,必须立即调用 `_ensure_exchange_sltp_orders` 在交易所侧挂好止损单。
|
||||||
|
- **价格类型**:区分 Mark Price(标记价格)和 Last Price(最新价格),止损逻辑应优先参考标记价格以防插针。
|
||||||
|
|
||||||
|
## 3. 代码风格
|
||||||
|
- 使用 Python 异步编程 (asyncio)。
|
||||||
|
- 所有的交易日志必须记录 Symbol、价格、原因和时间戳。
|
||||||
|
|
@ -832,11 +832,208 @@ async def update_configs_batch(
|
||||||
|
|
||||||
@router.get("/meta")
|
@router.get("/meta")
|
||||||
async def get_config_meta(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
async def get_config_meta(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||||
gid = _global_strategy_account_id()
|
|
||||||
is_admin = (user.get("role") or "user") == "admin"
|
is_admin = (user.get("role") or "user") == "admin"
|
||||||
return {
|
return {
|
||||||
"global_strategy_account_id": int(gid),
|
|
||||||
"is_admin": bool(is_admin),
|
"is_admin": bool(is_admin),
|
||||||
"user_risk_knobs": sorted(list(USER_RISK_KNOBS)),
|
"user_risk_knobs": sorted(list(USER_RISK_KNOBS)),
|
||||||
"note": "平台兜底模式:策略核心由全局策略账号统一管理;普通用户仅可调整风险旋钮。",
|
"note": "平台兜底模式:策略核心由全局配置表统一管理(管理员专用);普通用户仅可调整风险旋钮。",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/global")
|
||||||
|
async def get_global_configs(
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""获取全局策略配置(仅管理员)"""
|
||||||
|
if (user.get("role") or "user") != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可访问全局策略配置")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from database.models import GlobalStrategyConfig
|
||||||
|
configs = GlobalStrategyConfig.get_all()
|
||||||
|
result = {}
|
||||||
|
for config in configs:
|
||||||
|
key = config['config_key']
|
||||||
|
value = GlobalStrategyConfig._convert_value(
|
||||||
|
config['config_value'],
|
||||||
|
config['config_type']
|
||||||
|
)
|
||||||
|
result[key] = {
|
||||||
|
"value": value,
|
||||||
|
"type": config['config_type'],
|
||||||
|
"category": config['category'],
|
||||||
|
"description": config.get('description'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 添加默认配置(如果数据库中没有)
|
||||||
|
for k, meta in CORE_STRATEGY_CONFIG_DEFAULTS.items():
|
||||||
|
if k not in result:
|
||||||
|
result[k] = meta
|
||||||
|
|
||||||
|
# 固定风险百分比配置
|
||||||
|
FIXED_RISK_CONFIG_DEFAULTS = {
|
||||||
|
"USE_FIXED_RISK_SIZING": {
|
||||||
|
"value": True,
|
||||||
|
"type": "boolean",
|
||||||
|
"category": "risk",
|
||||||
|
"description": "使用固定风险百分比计算仓位(凯利公式)。启用后,每笔单子承受的风险固定为 FIXED_RISK_PERCENT,避免大额亏损。",
|
||||||
|
},
|
||||||
|
"FIXED_RISK_PERCENT": {
|
||||||
|
"value": 0.02,
|
||||||
|
"type": "number",
|
||||||
|
"category": "risk",
|
||||||
|
"description": "每笔单子承受的风险百分比(相对于总资金)。例如 0.02 表示 2%。启用固定风险后,每笔亏损限制在该百分比内。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for k, meta in FIXED_RISK_CONFIG_DEFAULTS.items():
|
||||||
|
if k not in result:
|
||||||
|
result[k] = meta
|
||||||
|
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/global/{key}")
|
||||||
|
async def update_global_config(
|
||||||
|
key: str,
|
||||||
|
item: ConfigUpdate,
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""更新全局策略配置(仅管理员)"""
|
||||||
|
if (user.get("role") or "user") != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可修改全局策略配置")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from database.models import GlobalStrategyConfig
|
||||||
|
|
||||||
|
# 获取现有配置以确定类型和分类
|
||||||
|
existing = GlobalStrategyConfig.get(key)
|
||||||
|
if existing:
|
||||||
|
config_type = item.type or existing['config_type']
|
||||||
|
category = item.category or existing['category']
|
||||||
|
description = item.description or existing['description']
|
||||||
|
else:
|
||||||
|
# 从默认配置获取
|
||||||
|
meta = CORE_STRATEGY_CONFIG_DEFAULTS.get(key) or {
|
||||||
|
"USE_FIXED_RISK_SIZING": {"type": "boolean", "category": "risk", "description": "使用固定风险百分比计算仓位"},
|
||||||
|
"FIXED_RISK_PERCENT": {"type": "number", "category": "risk", "description": "每笔单子承受的风险百分比"},
|
||||||
|
}.get(key)
|
||||||
|
config_type = item.type or (meta.get("type") if meta else "string")
|
||||||
|
category = item.category or (meta.get("category") if meta else "strategy")
|
||||||
|
description = item.description or (meta.get("description") if meta else f"{key}配置")
|
||||||
|
|
||||||
|
# 验证配置值
|
||||||
|
if config_type == 'number':
|
||||||
|
try:
|
||||||
|
float(item.value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid number value for {key}")
|
||||||
|
elif config_type == 'boolean':
|
||||||
|
if not isinstance(item.value, bool):
|
||||||
|
item.value = str(item.value).lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
# 更新全局配置
|
||||||
|
GlobalStrategyConfig.set(
|
||||||
|
key,
|
||||||
|
item.value,
|
||||||
|
config_type,
|
||||||
|
category,
|
||||||
|
description,
|
||||||
|
updated_by=user.get("username")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新Redis缓存
|
||||||
|
try:
|
||||||
|
from config_manager import GlobalStrategyConfigManager
|
||||||
|
global_mgr = GlobalStrategyConfigManager()
|
||||||
|
if isinstance(item.value, (dict, list, bool, int, float)):
|
||||||
|
import json
|
||||||
|
value_str = json.dumps(item.value, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
value_str = str(item.value)
|
||||||
|
global_mgr._set_to_redis(key, item.value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"更新全局配置Redis缓存失败: {e}")
|
||||||
|
|
||||||
|
return {"message": f"全局配置 {key} 已更新", "key": key, "value": item.value}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/global/batch")
|
||||||
|
async def update_global_configs_batch(
|
||||||
|
configs: list[ConfigItem],
|
||||||
|
user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""批量更新全局策略配置(仅管理员)"""
|
||||||
|
if (user.get("role") or "user") != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可修改全局策略配置")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from database.models import GlobalStrategyConfig
|
||||||
|
from config_manager import GlobalStrategyConfigManager
|
||||||
|
|
||||||
|
updated_count = 0
|
||||||
|
errors = []
|
||||||
|
global_mgr = GlobalStrategyConfigManager()
|
||||||
|
|
||||||
|
for item in configs:
|
||||||
|
try:
|
||||||
|
# 获取现有配置
|
||||||
|
existing = GlobalStrategyConfig.get(item.key)
|
||||||
|
if existing:
|
||||||
|
config_type = item.type or existing['config_type']
|
||||||
|
category = item.category or existing['category']
|
||||||
|
description = item.description or existing['description']
|
||||||
|
else:
|
||||||
|
# 从默认配置获取
|
||||||
|
meta = CORE_STRATEGY_CONFIG_DEFAULTS.get(item.key) or {
|
||||||
|
"USE_FIXED_RISK_SIZING": {"type": "boolean", "category": "risk"},
|
||||||
|
"FIXED_RISK_PERCENT": {"type": "number", "category": "risk"},
|
||||||
|
}.get(item.key)
|
||||||
|
config_type = item.type or (meta.get("type") if meta else "string")
|
||||||
|
category = item.category or (meta.get("category") if meta else "strategy")
|
||||||
|
description = item.description or (meta.get("description") if meta else f"{item.key}配置")
|
||||||
|
|
||||||
|
# 验证配置值
|
||||||
|
if config_type == 'number':
|
||||||
|
try:
|
||||||
|
float(item.value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
errors.append(f"{item.key}: Invalid number value")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 更新全局配置
|
||||||
|
GlobalStrategyConfig.set(
|
||||||
|
item.key,
|
||||||
|
item.value,
|
||||||
|
config_type,
|
||||||
|
category,
|
||||||
|
description,
|
||||||
|
updated_by=user.get("username")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新Redis缓存
|
||||||
|
global_mgr._set_to_redis(item.key, item.value)
|
||||||
|
updated_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"{item.key}: {str(e)}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return {
|
||||||
|
"message": f"成功更新 {updated_count} 个配置,{len(errors)} 个失败",
|
||||||
|
"updated": updated_count,
|
||||||
|
"errors": errors,
|
||||||
|
"note": "交易系统将在下次扫描时自动使用新配置"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"成功更新 {updated_count} 个全局配置",
|
||||||
|
"updated": updated_count,
|
||||||
|
"note": "交易系统将在下次扫描时自动使用新配置"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,10 @@ import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 平台兜底:策略核心使用全局账号配置(默认 account_id=1),普通用户账号只允许调整“风险旋钮”
|
# 平台兜底:策略核心使用全局配置表(global_strategy_config),普通用户账号只允许调整“风险旋钮”
|
||||||
# - 风险旋钮:每个账号独立(仓位/频次等)
|
# - 风险旋钮:每个账号独立(仓位/频次等)
|
||||||
# - 其它策略参数:统一从全局账号读取,避免每个用户乱改导致策略不可控
|
# - 其它策略参数:统一从全局配置表读取,避免每个用户乱改导致策略不可控
|
||||||
try:
|
# 注意:不再依赖account_id=1,全局配置存储在独立的global_strategy_config表中
|
||||||
GLOBAL_STRATEGY_ACCOUNT_ID = int(os.getenv("ATS_GLOBAL_STRATEGY_ACCOUNT_ID") or "1")
|
|
||||||
except Exception:
|
|
||||||
GLOBAL_STRATEGY_ACCOUNT_ID = 1
|
|
||||||
|
|
||||||
RISK_KNOBS_KEYS = {
|
RISK_KNOBS_KEYS = {
|
||||||
"MIN_MARGIN_USDT",
|
"MIN_MARGIN_USDT",
|
||||||
|
|
@ -74,6 +71,217 @@ except ImportError:
|
||||||
redis = None
|
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" # 独立的Redis键
|
||||||
|
self._init_redis()
|
||||||
|
self._load_from_db()
|
||||||
|
|
||||||
|
def _init_redis(self):
|
||||||
|
"""初始化Redis客户端(同步)"""
|
||||||
|
if not REDIS_SYNC_AVAILABLE:
|
||||||
|
logger.debug("redis-py未安装,全局配置缓存将不使用Redis")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379')
|
||||||
|
redis_use_tls = os.getenv('REDIS_USE_TLS', 'False').lower() == 'true'
|
||||||
|
redis_username = os.getenv('REDIS_USERNAME', None)
|
||||||
|
redis_password = os.getenv('REDIS_PASSWORD', None)
|
||||||
|
|
||||||
|
if not redis_url or not isinstance(redis_url, str):
|
||||||
|
redis_url = 'redis://localhost:6379'
|
||||||
|
|
||||||
|
if redis_use_tls and not redis_url.startswith('rediss://'):
|
||||||
|
if redis_url.startswith('redis://'):
|
||||||
|
redis_url = redis_url.replace('redis://', 'rediss://', 1)
|
||||||
|
|
||||||
|
connection_kwargs = {
|
||||||
|
'username': redis_username,
|
||||||
|
'password': redis_password,
|
||||||
|
'decode_responses': True
|
||||||
|
}
|
||||||
|
|
||||||
|
if redis_url.startswith('rediss://') or redis_use_tls:
|
||||||
|
ssl_cert_reqs = os.getenv('REDIS_SSL_CERT_REQS', 'required')
|
||||||
|
ssl_ca_certs = os.getenv('REDIS_SSL_CA_CERTS', None)
|
||||||
|
connection_kwargs['select'] = int(os.getenv('REDIS_SELECT', 0))
|
||||||
|
connection_kwargs['ssl_cert_reqs'] = ssl_cert_reqs
|
||||||
|
if ssl_ca_certs:
|
||||||
|
connection_kwargs['ssl_ca_certs'] = ssl_ca_certs
|
||||||
|
if ssl_cert_reqs == 'none':
|
||||||
|
connection_kwargs['ssl_check_hostname'] = False
|
||||||
|
elif ssl_cert_reqs == 'required':
|
||||||
|
connection_kwargs['ssl_check_hostname'] = True
|
||||||
|
else:
|
||||||
|
connection_kwargs['ssl_check_hostname'] = False
|
||||||
|
|
||||||
|
self._redis_client = redis.from_url(redis_url, **connection_kwargs)
|
||||||
|
self._redis_client.ping()
|
||||||
|
self._redis_connected = True
|
||||||
|
logger.info("✓ 全局策略配置Redis缓存连接成功")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"全局策略配置Redis缓存连接失败: {e},将使用数据库缓存")
|
||||||
|
self._redis_client = None
|
||||||
|
self._redis_connected = False
|
||||||
|
|
||||||
|
def _get_from_redis(self, key: str) -> Optional[Any]:
|
||||||
|
"""从Redis获取全局配置值"""
|
||||||
|
if not self._redis_connected or not self._redis_client:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = self._redis_client.hget(self._redis_hash_key, key)
|
||||||
|
if value is not None and value != '':
|
||||||
|
return ConfigManager._coerce_redis_value(value)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"从Redis获取全局配置失败 {key}: {e}")
|
||||||
|
try:
|
||||||
|
self._redis_client.ping()
|
||||||
|
self._redis_connected = True
|
||||||
|
except:
|
||||||
|
self._redis_connected = False
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _set_to_redis(self, key: str, value: Any):
|
||||||
|
"""设置全局配置到Redis"""
|
||||||
|
if not self._redis_connected or not self._redis_client:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(value, (dict, list, bool, int, float)):
|
||||||
|
value_str = json.dumps(value, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
value_str = str(value)
|
||||||
|
|
||||||
|
self._redis_client.hset(self._redis_hash_key, key, value_str)
|
||||||
|
self._redis_client.expire(self._redis_hash_key, 3600)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"设置全局配置到Redis失败 {key}: {e}")
|
||||||
|
try:
|
||||||
|
self._redis_client.ping()
|
||||||
|
self._redis_connected = True
|
||||||
|
except:
|
||||||
|
self._redis_connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _load_from_db(self):
|
||||||
|
"""从数据库加载全局配置"""
|
||||||
|
try:
|
||||||
|
from database.models import GlobalStrategyConfig
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("GlobalStrategyConfig未导入,无法从数据库加载全局配置")
|
||||||
|
self._cache = {}
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 先尝试从Redis加载
|
||||||
|
if self._redis_connected and self._redis_client:
|
||||||
|
try:
|
||||||
|
self._redis_client.ping()
|
||||||
|
redis_configs = self._redis_client.hgetall(self._redis_hash_key)
|
||||||
|
if redis_configs and len(redis_configs) > 0:
|
||||||
|
for key, value_str in redis_configs.items():
|
||||||
|
self._cache[key] = ConfigManager._coerce_redis_value(value_str)
|
||||||
|
logger.info(f"从Redis加载了 {len(self._cache)} 个全局配置项")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"从Redis加载全局配置失败: {e},回退到数据库")
|
||||||
|
try:
|
||||||
|
self._redis_client.ping()
|
||||||
|
except:
|
||||||
|
self._redis_connected = False
|
||||||
|
|
||||||
|
# 从数据库加载
|
||||||
|
configs = GlobalStrategyConfig.get_all()
|
||||||
|
for config in configs:
|
||||||
|
key = config['config_key']
|
||||||
|
# 使用TradingConfig的转换方法(GlobalStrategyConfig复用)
|
||||||
|
from database.models import TradingConfig
|
||||||
|
value = TradingConfig._convert_value(
|
||||||
|
config['config_value'],
|
||||||
|
config['config_type']
|
||||||
|
)
|
||||||
|
self._cache[key] = value
|
||||||
|
self._set_to_redis(key, value)
|
||||||
|
|
||||||
|
logger.info(f"从数据库加载了 {len(self._cache)} 个全局配置项,已同步到Redis")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"从数据库加载全局配置失败,使用默认配置: {e}")
|
||||||
|
self._cache = {}
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""获取全局配置值"""
|
||||||
|
# 1. 优先从Redis缓存读取
|
||||||
|
if self._redis_connected and self._redis_client:
|
||||||
|
redis_value = self._get_from_redis(key)
|
||||||
|
if redis_value is not None:
|
||||||
|
self._cache[key] = redis_value
|
||||||
|
return redis_value
|
||||||
|
|
||||||
|
# 2. 从本地缓存读取
|
||||||
|
if key in self._cache:
|
||||||
|
return self._cache[key]
|
||||||
|
|
||||||
|
# 3. 从数据库读取
|
||||||
|
try:
|
||||||
|
from database.models import GlobalStrategyConfig
|
||||||
|
db_value = GlobalStrategyConfig.get_value(key)
|
||||||
|
if db_value is not None:
|
||||||
|
self._cache[key] = db_value
|
||||||
|
self._set_to_redis(key, db_value)
|
||||||
|
return db_value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. 从环境变量读取
|
||||||
|
env_value = os.getenv(key)
|
||||||
|
if env_value is not None:
|
||||||
|
return env_value
|
||||||
|
|
||||||
|
# 5. 返回默认值
|
||||||
|
return default
|
||||||
|
|
||||||
|
def reload_from_redis(self):
|
||||||
|
"""强制从Redis重新加载全局配置"""
|
||||||
|
if not self._redis_connected or not self._redis_client:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._redis_client.ping()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Redis连接不可用: {e},跳过从Redis重新加载")
|
||||||
|
self._redis_connected = False
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
redis_configs = self._redis_client.hgetall(self._redis_hash_key)
|
||||||
|
if redis_configs and len(redis_configs) > 0:
|
||||||
|
self._cache = {}
|
||||||
|
for key, value_str in redis_configs.items():
|
||||||
|
self._cache[key] = ConfigManager._coerce_redis_value(value_str)
|
||||||
|
logger.debug(f"从Redis重新加载了 {len(self._cache)} 个全局配置项")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"从Redis重新加载全局配置失败: {e},保持现有缓存")
|
||||||
|
|
||||||
|
|
||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
"""配置管理器 - 优先从Redis缓存读取,其次从数据库读取,回退到环境变量和默认值"""
|
"""配置管理器 - 优先从Redis缓存读取,其次从数据库读取,回退到环境变量和默认值"""
|
||||||
|
|
||||||
|
|
@ -492,32 +700,24 @@ class ConfigManager:
|
||||||
|
|
||||||
def get_trading_config(self):
|
def get_trading_config(self):
|
||||||
"""获取交易配置字典(兼容原有config.py的TRADING_CONFIG)"""
|
"""获取交易配置字典(兼容原有config.py的TRADING_CONFIG)"""
|
||||||
# 全局策略配置管理器(避免递归:当 self 就是全局账号时,不做跨账号读取)
|
# 全局策略配置管理器(从独立的global_strategy_config表读取)
|
||||||
global_mgr = None
|
global_config_mgr = GlobalStrategyConfigManager()
|
||||||
if self.account_id != int(GLOBAL_STRATEGY_ACCOUNT_ID or 1):
|
try:
|
||||||
try:
|
global_config_mgr.reload_from_redis()
|
||||||
global_mgr = ConfigManager.for_account(int(GLOBAL_STRATEGY_ACCOUNT_ID or 1))
|
except Exception:
|
||||||
except Exception:
|
pass
|
||||||
global_mgr = None
|
|
||||||
# 预热全局 cache:避免每个 key 都 HGET 一次
|
|
||||||
if global_mgr is not None:
|
|
||||||
try:
|
|
||||||
global_mgr.reload_from_redis()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def eff_get(key: str, default: Any):
|
def eff_get(key: str, default: Any):
|
||||||
"""
|
"""
|
||||||
策略核心:默认从全局账号读取(GLOBAL_STRATEGY_ACCOUNT_ID)。
|
策略核心:从全局配置表读取(global_strategy_config)。
|
||||||
风险旋钮:从当前账号读取。
|
风险旋钮:从当前账号读取。
|
||||||
"""
|
"""
|
||||||
# API key/secret/testnet 永远按账号读取(在 get() 内部已处理)
|
# API key/secret/testnet 永远按账号读取(在 get() 内部已处理)
|
||||||
if key in RISK_KNOBS_KEYS or global_mgr is None:
|
if key in RISK_KNOBS_KEYS:
|
||||||
return self.get(key, default)
|
return self.get(key, default)
|
||||||
|
# 从全局配置表读取
|
||||||
try:
|
try:
|
||||||
if key in global_mgr._cache: # noqa: SLF001
|
return global_config_mgr.get(key, default)
|
||||||
return global_mgr._cache.get(key, default) # noqa: SLF001
|
|
||||||
return global_mgr.get(key, default)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return self.get(key, default)
|
return self.get(key, default)
|
||||||
|
|
||||||
|
|
|
||||||
45
backend/database/add_global_strategy_config.sql
Normal file
45
backend/database/add_global_strategy_config.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
-- 创建全局策略配置表(独立于账户)
|
||||||
|
-- 全局配置不依赖任何account_id,由管理员统一管理
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `global_strategy_config` (
|
||||||
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
`config_key` VARCHAR(100) NOT NULL,
|
||||||
|
`config_value` TEXT NOT NULL,
|
||||||
|
`config_type` VARCHAR(50) NOT NULL COMMENT 'string, number, boolean, json',
|
||||||
|
`category` VARCHAR(50) NOT NULL COMMENT 'strategy, risk, scan',
|
||||||
|
`description` TEXT,
|
||||||
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`updated_by` VARCHAR(50) COMMENT '更新人(用户名)',
|
||||||
|
INDEX `idx_category` (`category`),
|
||||||
|
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全局策略配置表(管理员专用)';
|
||||||
|
|
||||||
|
-- 迁移现有account_id=1的核心策略配置到全局配置表
|
||||||
|
-- 注意:只迁移非风险旋钮的配置
|
||||||
|
INSERT INTO `global_strategy_config` (`config_key`, `config_value`, `config_type`, `category`, `description`)
|
||||||
|
SELECT
|
||||||
|
`config_key`,
|
||||||
|
`config_value`,
|
||||||
|
`config_type`,
|
||||||
|
`category`,
|
||||||
|
`description`
|
||||||
|
FROM `trading_config`
|
||||||
|
WHERE `account_id` = 1
|
||||||
|
AND `config_key` NOT IN (
|
||||||
|
'MIN_MARGIN_USDT',
|
||||||
|
'MIN_POSITION_PERCENT',
|
||||||
|
'MAX_POSITION_PERCENT',
|
||||||
|
'MAX_TOTAL_POSITION_PERCENT',
|
||||||
|
'AUTO_TRADE_ENABLED',
|
||||||
|
'MAX_OPEN_POSITIONS',
|
||||||
|
'MAX_DAILY_ENTRIES',
|
||||||
|
'BINANCE_API_KEY',
|
||||||
|
'BINANCE_API_SECRET',
|
||||||
|
'USE_TESTNET'
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`config_value` = VALUES(`config_value`),
|
||||||
|
`config_type` = VALUES(`config_type`),
|
||||||
|
`category` = VALUES(`category`),
|
||||||
|
`description` = VALUES(`description`),
|
||||||
|
`updated_at` = CURRENT_TIMESTAMP;
|
||||||
|
|
@ -292,6 +292,74 @@ class TradingConfig:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalStrategyConfig:
|
||||||
|
"""全局策略配置模型(独立于账户,管理员专用)"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all():
|
||||||
|
"""获取所有全局配置"""
|
||||||
|
if not _table_has_column("global_strategy_config", "config_key"):
|
||||||
|
return []
|
||||||
|
return db.execute_query(
|
||||||
|
"SELECT * FROM global_strategy_config ORDER BY category, config_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(key):
|
||||||
|
"""获取单个全局配置"""
|
||||||
|
if not _table_has_column("global_strategy_config", "config_key"):
|
||||||
|
return None
|
||||||
|
return db.execute_one(
|
||||||
|
"SELECT * FROM global_strategy_config WHERE config_key = %s",
|
||||||
|
(key,)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def set(key, value, config_type, category, description=None, updated_by=None):
|
||||||
|
"""设置全局配置"""
|
||||||
|
if not _table_has_column("global_strategy_config", "config_key"):
|
||||||
|
# 表不存在时,回退到trading_config(兼容旧系统)
|
||||||
|
return TradingConfig.set(key, value, config_type, category, description, account_id=1)
|
||||||
|
|
||||||
|
value_str = TradingConfig._convert_to_string(value, config_type)
|
||||||
|
db.execute_update(
|
||||||
|
"""INSERT INTO global_strategy_config
|
||||||
|
(config_key, config_value, config_type, category, description, updated_by)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
config_value = VALUES(config_value),
|
||||||
|
config_type = VALUES(config_type),
|
||||||
|
category = VALUES(category),
|
||||||
|
description = VALUES(description),
|
||||||
|
updated_by = VALUES(updated_by),
|
||||||
|
updated_at = CURRENT_TIMESTAMP""",
|
||||||
|
(key, value_str, config_type, category, description, updated_by),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_value(key, default=None):
|
||||||
|
"""获取全局配置值(自动转换类型)"""
|
||||||
|
result = GlobalStrategyConfig.get(key)
|
||||||
|
if result:
|
||||||
|
return GlobalStrategyConfig._convert_value(result['config_value'], result['config_type'])
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_value(value, config_type):
|
||||||
|
"""转换配置值类型(复用TradingConfig的逻辑)"""
|
||||||
|
return TradingConfig._convert_value(value, config_type)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete(key):
|
||||||
|
"""删除全局配置"""
|
||||||
|
if not _table_has_column("global_strategy_config", "config_key"):
|
||||||
|
return
|
||||||
|
db.execute_update(
|
||||||
|
"DELETE FROM global_strategy_config WHERE config_key = %s",
|
||||||
|
(key,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Trade:
|
class Trade:
|
||||||
"""交易记录模型"""
|
"""交易记录模型"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -383,30 +383,17 @@ const GlobalConfig = () => {
|
||||||
|
|
||||||
const loadConfigs = async () => {
|
const loadConfigs = async () => {
|
||||||
try {
|
try {
|
||||||
// 管理员全局配置:始终使用全局策略账号的配置,不依赖当前 account
|
// 管理员全局配置:从独立的全局配置表读取,不依赖任何 account
|
||||||
// 即使 configMeta 还没加载完成,也使用默认值 1
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const globalAccountId = configMeta?.global_strategy_account_id
|
const data = await api.getGlobalConfigs()
|
||||||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
|
||||||
: 1 // 如果 configMeta 还没加载,使用默认值 1
|
|
||||||
const data = await api.getGlobalConfigs(globalAccountId)
|
|
||||||
setConfigs(data)
|
setConfigs(data)
|
||||||
} else {
|
} else {
|
||||||
// 非管理员:使用默认方式(会受当前 account 影响,但非管理员不应该访问这个页面)
|
// 非管理员不应该访问这个页面
|
||||||
const data = await api.getConfigs()
|
setConfigs({})
|
||||||
setConfigs(data)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load configs:', error)
|
console.error('Failed to load global configs:', error)
|
||||||
// 如果加载失败,尝试使用默认值 1 重试一次
|
setConfigs({})
|
||||||
if (isAdmin) {
|
|
||||||
try {
|
|
||||||
const data = await api.getGlobalConfigs(1)
|
|
||||||
setConfigs(data)
|
|
||||||
} catch (retryError) {
|
|
||||||
console.error('Retry load configs with accountId=1 failed:', retryError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,22 +461,9 @@ const GlobalConfig = () => {
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
// 只有管理员才加载配置和系统状态
|
// 只有管理员才加载配置和系统状态
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
// 立即加载 configs(不等待 configMeta,使用默认值 1)
|
// 加载全局配置(独立于账户)
|
||||||
// 同时加载 configMeta,加载完成后会触发重新加载 configs(如果 global_strategy_account_id 不是 1)
|
|
||||||
loadConfigs().catch(() => {})
|
loadConfigs().catch(() => {})
|
||||||
loadConfigMeta()
|
loadConfigMeta().catch(() => {}) // 静默失败
|
||||||
.then(() => {
|
|
||||||
// configMeta 加载完成后,如果 global_strategy_account_id 不是 1,重新加载 configs
|
|
||||||
// 这样可以确保使用正确的全局策略账号ID
|
|
||||||
const globalAccountId = configMeta?.global_strategy_account_id
|
|
||||||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
|
||||||
: 1
|
|
||||||
// 如果 globalAccountId 不是 1,说明之前加载的是默认值,需要重新加载
|
|
||||||
if (globalAccountId !== 1) {
|
|
||||||
loadConfigs().catch(() => {})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {}) // 静默失败
|
|
||||||
loadSystemStatus().catch(() => {}) // 静默失败
|
loadSystemStatus().catch(() => {}) // 静默失败
|
||||||
loadBackendStatus().catch(() => {}) // 静默失败
|
loadBackendStatus().catch(() => {}) // 静默失败
|
||||||
|
|
||||||
|
|
@ -651,16 +625,13 @@ const GlobalConfig = () => {
|
||||||
}
|
}
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
|
|
||||||
// 管理员全局配置:始终使用全局策略账号,即使 configMeta 还没加载也使用默认值 1
|
// 管理员全局配置:使用独立的全局配置API
|
||||||
let response
|
let response
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const globalAccountId = configMeta?.global_strategy_account_id
|
response = await api.updateGlobalConfigsBatch(configItems)
|
||||||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
|
||||||
: 1
|
|
||||||
response = await api.updateGlobalConfigsBatch(configItems, globalAccountId)
|
|
||||||
} else {
|
} else {
|
||||||
// 非管理员不应该访问这个页面,但为了安全还是处理一下
|
// 非管理员不应该访问这个页面,但为了安全还是处理一下
|
||||||
response = await api.updateConfigsBatch(configItems)
|
throw new Error('只有管理员可以修改全局配置')
|
||||||
}
|
}
|
||||||
setMessage(response.message || `已应用${preset.name}`)
|
setMessage(response.message || `已应用${preset.name}`)
|
||||||
if (response.note) {
|
if (response.note) {
|
||||||
|
|
@ -700,13 +671,10 @@ const GlobalConfig = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildConfigSnapshot = async (includeSecrets) => {
|
const buildConfigSnapshot = async (includeSecrets) => {
|
||||||
// 管理员全局配置:始终使用全局策略账号的配置,即使 configMeta 还没加载也使用默认值 1
|
// 管理员全局配置:从独立的全局配置表读取
|
||||||
let data
|
let data
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const globalAccountId = configMeta?.global_strategy_account_id
|
data = await api.getGlobalConfigs()
|
||||||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
|
||||||
: 1
|
|
||||||
data = await api.getGlobalConfigs(globalAccountId)
|
|
||||||
} else {
|
} else {
|
||||||
data = await api.getConfigs()
|
data = await api.getConfigs()
|
||||||
}
|
}
|
||||||
|
|
@ -910,11 +878,7 @@ const GlobalConfig = () => {
|
||||||
return <div className="global-config">加载中...</div>
|
return <div className="global-config">加载中...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 简单计算:全局策略账号ID(在 render 时计算)
|
// 管理员全局配置页面:不依赖任何 account,直接管理全局配置表
|
||||||
const globalStrategyAccountId = configMeta?.global_strategy_account_id
|
|
||||||
? parseInt(String(configMeta?.global_strategy_account_id || '1'), 10)
|
|
||||||
: 1
|
|
||||||
// 管理员全局配置页面:不依赖当前 account,直接管理全局策略账号
|
|
||||||
const isGlobalStrategyAccount = isAdmin
|
const isGlobalStrategyAccount = isAdmin
|
||||||
|
|
||||||
// 简单计算:当前预设(直接在 render 时计算,不使用 useMemo)
|
// 简单计算:当前预设(直接在 render 时计算,不使用 useMemo)
|
||||||
|
|
@ -1217,13 +1181,7 @@ const GlobalConfig = () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
// 管理员始终使用全局策略账号,即使 configMeta 还没加载也使用默认值 1
|
if (!isAdmin) {
|
||||||
const globalAccountId = isAdmin
|
|
||||||
? (configMeta?.global_strategy_account_id
|
|
||||||
? parseInt(String(configMeta.global_strategy_account_id || '1'), 10) || 1
|
|
||||||
: 1)
|
|
||||||
: null
|
|
||||||
if (!isAdmin || !globalAccountId) {
|
|
||||||
setMessage('只有管理员可以修改全局配置')
|
setMessage('只有管理员可以修改全局配置')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1233,7 +1191,7 @@ const GlobalConfig = () => {
|
||||||
type: config.type,
|
type: config.type,
|
||||||
category: config.category,
|
category: config.category,
|
||||||
description: config.description
|
description: config.description
|
||||||
}], globalAccountId)
|
}])
|
||||||
setMessage(`已更新 ${key}`)
|
setMessage(`已更新 ${key}`)
|
||||||
await loadConfigs()
|
await loadConfigs()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -218,10 +218,10 @@ export const api = {
|
||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
// 全局配置:获取全局策略账号的配置(管理员专用,不依赖当前 account)
|
// 全局配置:获取全局策略配置(管理员专用,独立于账户)
|
||||||
getGlobalConfigs: async (globalAccountId) => {
|
getGlobalConfigs: async () => {
|
||||||
const response = await fetch(buildUrl('/api/config'), {
|
const response = await fetch(buildUrl('/api/config/global'), {
|
||||||
headers: withAuthHeaders({ 'X-Account-Id': String(globalAccountId || 1) })
|
headers: withAuthHeaders()
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: '获取全局配置失败' }))
|
const error = await response.json().catch(() => ({ detail: '获取全局配置失败' }))
|
||||||
|
|
@ -230,13 +230,12 @@ export const api = {
|
||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
// 全局配置:批量更新全局策略账号的配置(管理员专用)
|
// 全局配置:批量更新全局策略配置(管理员专用)
|
||||||
updateGlobalConfigsBatch: async (configs, globalAccountId) => {
|
updateGlobalConfigsBatch: async (configs) => {
|
||||||
const response = await fetch(buildUrl('/api/config/batch'), {
|
const response = await fetch(buildUrl('/api/config/global/batch'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: withAuthHeaders({
|
headers: withAuthHeaders({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json'
|
||||||
'X-Account-Id': String(globalAccountId || 1)
|
|
||||||
}),
|
}),
|
||||||
body: JSON.stringify(configs)
|
body: JSON.stringify(configs)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user