a
This commit is contained in:
parent
87e7865cbb
commit
5b1370a5a2
|
|
@ -20,6 +20,49 @@ from api.auth_deps import get_current_user, get_account_id, require_admin, requi
|
|||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# 全局策略账号(管理员统一维护策略核心)。默认 1,可用环境变量覆盖。
|
||||
def _global_strategy_account_id() -> int:
|
||||
try:
|
||||
return int((__import__("os").getenv("ATS_GLOBAL_STRATEGY_ACCOUNT_ID") or "1").strip() or "1")
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
# 产品模式:平台兜底(策略核心由管理员统一控制),普通用户仅能调“风险旋钮”
|
||||
# - admin:可修改所有配置
|
||||
# - 非 admin(account owner):只允许修改少量风险类配置 + 账号私有密钥/测试网
|
||||
USER_RISK_KNOBS = {
|
||||
# 风险暴露(保证金占用比例/最小保证金)
|
||||
"MIN_MARGIN_USDT",
|
||||
"MIN_POSITION_PERCENT",
|
||||
"MAX_POSITION_PERCENT",
|
||||
"MAX_TOTAL_POSITION_PERCENT",
|
||||
# 行为控制(傻瓜化)
|
||||
"AUTO_TRADE_ENABLED", # 总开关:关闭则只生成推荐不自动下单
|
||||
"MAX_OPEN_POSITIONS", # 同时持仓数量上限
|
||||
"MAX_DAILY_ENTRIES", # 每日最多开仓次数
|
||||
}
|
||||
|
||||
RISK_KNOBS_DEFAULTS = {
|
||||
"AUTO_TRADE_ENABLED": {
|
||||
"value": True,
|
||||
"type": "boolean",
|
||||
"category": "risk",
|
||||
"description": "自动交易总开关:关闭后仅生成推荐,不会自动下单(适合先观察/体验)。",
|
||||
},
|
||||
"MAX_OPEN_POSITIONS": {
|
||||
"value": 3,
|
||||
"type": "number",
|
||||
"category": "risk",
|
||||
"description": "同时持仓数量上限(防止仓位过多/难管理)。建议 1-5。",
|
||||
},
|
||||
"MAX_DAILY_ENTRIES": {
|
||||
"value": 8,
|
||||
"type": "number",
|
||||
"category": "risk",
|
||||
"description": "每日最多开仓次数(防止高频下单/过度交易)。建议 3-15。",
|
||||
},
|
||||
}
|
||||
|
||||
# API key/secret 脱敏
|
||||
def _mask(s: str) -> str:
|
||||
s = "" if s is None else str(s)
|
||||
|
|
@ -163,6 +206,18 @@ async def get_all_configs(
|
|||
for k, meta in AUTO_TRADE_FILTER_DEFAULTS.items():
|
||||
if k not in result:
|
||||
result[k] = meta
|
||||
|
||||
for k, meta in RISK_KNOBS_DEFAULTS.items():
|
||||
if k not in result:
|
||||
result[k] = meta
|
||||
|
||||
# 普通用户:只展示风险旋钮 + 账号密钥(尽量傻瓜化,避免改坏策略)
|
||||
# 管理员:若当前不是“全局策略账号”,同样只展示风险旋钮,避免误以为这里改策略能生效
|
||||
is_admin = (user.get("role") or "user") == "admin"
|
||||
gid = _global_strategy_account_id()
|
||||
if (not is_admin) or (is_admin and int(account_id) != int(gid)):
|
||||
allowed = set(USER_RISK_KNOBS) | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}
|
||||
result = {k: v for k, v in result.items() if k in allowed}
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
@ -170,6 +225,7 @@ async def get_all_configs(
|
|||
|
||||
@router.get("/feasibility-check")
|
||||
async def check_config_feasibility(
|
||||
user: Dict[str, Any] = Depends(get_current_user),
|
||||
account_id: int = Depends(get_account_id),
|
||||
):
|
||||
"""
|
||||
|
|
@ -194,13 +250,27 @@ async def check_config_feasibility(
|
|||
"suggestions": []
|
||||
}
|
||||
|
||||
# 获取当前配置
|
||||
min_margin_usdt = TradingConfig.get_value('MIN_MARGIN_USDT', 5.0, account_id=account_id)
|
||||
min_position_percent = TradingConfig.get_value('MIN_POSITION_PERCENT', 0.02, account_id=account_id)
|
||||
max_position_percent = TradingConfig.get_value('MAX_POSITION_PERCENT', 0.08, account_id=account_id)
|
||||
base_leverage = TradingConfig.get_value('LEVERAGE', 10, account_id=account_id)
|
||||
max_leverage = TradingConfig.get_value('MAX_LEVERAGE', 15, account_id=account_id)
|
||||
use_dynamic_leverage = TradingConfig.get_value('USE_DYNAMIC_LEVERAGE', True, account_id=account_id)
|
||||
# 获取当前“有效配置”(平台兜底:策略核心可能来自全局账号)
|
||||
try:
|
||||
import config_manager as _cfg_mgr # type: ignore
|
||||
|
||||
mgr = _cfg_mgr.ConfigManager.for_account(int(account_id)) if hasattr(_cfg_mgr, "ConfigManager") else None
|
||||
tc = mgr.get_trading_config() if mgr else {}
|
||||
except Exception:
|
||||
tc = {}
|
||||
|
||||
def _tc(key: str, default):
|
||||
try:
|
||||
return tc.get(key, default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
min_margin_usdt = float(_tc('MIN_MARGIN_USDT', 5.0))
|
||||
min_position_percent = float(_tc('MIN_POSITION_PERCENT', 0.02))
|
||||
max_position_percent = float(_tc('MAX_POSITION_PERCENT', 0.08))
|
||||
base_leverage = int(_tc('LEVERAGE', 10))
|
||||
max_leverage = int(_tc('MAX_LEVERAGE', 15))
|
||||
use_dynamic_leverage = bool(_tc('USE_DYNAMIC_LEVERAGE', True))
|
||||
|
||||
# 检查所有可能的杠杆倍数(考虑动态杠杆)
|
||||
leverage_to_check = [base_leverage]
|
||||
|
|
@ -425,6 +495,13 @@ async def check_config_feasibility(
|
|||
"leverage_results": leverage_results
|
||||
})
|
||||
|
||||
# 普通用户:只返回可调整的风险旋钮建议,避免前端一键应用时触发 403
|
||||
if (user.get("role") or "user") != "admin":
|
||||
try:
|
||||
suggestions = [s for s in (suggestions or []) if s.get("config_key") in USER_RISK_KNOBS]
|
||||
except Exception:
|
||||
suggestions = []
|
||||
|
||||
return {
|
||||
"feasible": is_feasible,
|
||||
"account_balance": available_balance,
|
||||
|
|
@ -506,6 +583,18 @@ async def update_config(
|
|||
if (user.get("role") or "user") != "admin":
|
||||
require_account_owner(account_id, user)
|
||||
|
||||
# 管理员:若不是全局策略账号,则禁止修改策略核心(避免误操作)
|
||||
if (user.get("role") or "user") == "admin":
|
||||
gid = _global_strategy_account_id()
|
||||
if int(account_id) != int(gid):
|
||||
if key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
raise HTTPException(status_code=403, detail=f"该配置由全局策略账号 #{gid} 统一管理,请切换到该账号修改")
|
||||
|
||||
# 产品模式:普通用户只能改“风险旋钮”与账号私有密钥/测试网
|
||||
if (user.get("role") or "user") != "admin":
|
||||
if key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
raise HTTPException(status_code=403, detail="该配置由平台统一管理(仅管理员可修改)")
|
||||
|
||||
# API Key/Secret/Testnet:写入 accounts 表(账号私有)
|
||||
if key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}:
|
||||
if (user.get("role") or "user") != "admin":
|
||||
|
|
@ -534,7 +623,7 @@ async def update_config(
|
|||
description = item.description or existing['description']
|
||||
else:
|
||||
# 允许创建新配置(用于新功能首次上线,DB 里还没有 key 的情况)
|
||||
meta = SMART_ENTRY_CONFIG_DEFAULTS.get(key) or AUTO_TRADE_FILTER_DEFAULTS.get(key)
|
||||
meta = SMART_ENTRY_CONFIG_DEFAULTS.get(key) or AUTO_TRADE_FILTER_DEFAULTS.get(key) or RISK_KNOBS_DEFAULTS.get(key)
|
||||
config_type = item.type or (meta.get("type") if meta else "string")
|
||||
category = item.category or (meta.get("category") if meta else "strategy")
|
||||
description = item.description or (meta.get("description") if meta else f"{key}配置")
|
||||
|
|
@ -602,11 +691,31 @@ async def update_configs_batch(
|
|||
# 非管理员:必须是该账号 owner 才允许修改配置
|
||||
if (user.get("role") or "user") != "admin":
|
||||
require_account_owner(account_id, user)
|
||||
|
||||
# 管理员:若不是全局策略账号,则批量只允许风险旋钮/密钥
|
||||
if (user.get("role") or "user") == "admin":
|
||||
gid = _global_strategy_account_id()
|
||||
if int(account_id) != int(gid):
|
||||
# 直接过滤掉不允许的项(给出 errors,避免“部分成功但实际无效”的错觉)
|
||||
pass
|
||||
updated_count = 0
|
||||
errors = []
|
||||
|
||||
for item in configs:
|
||||
try:
|
||||
if (user.get("role") or "user") == "admin":
|
||||
gid = _global_strategy_account_id()
|
||||
if int(account_id) != int(gid):
|
||||
if item.key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
errors.append(f"{item.key}: 该配置由全局策略账号 #{gid} 统一管理,请切换账号修改")
|
||||
continue
|
||||
|
||||
# 产品模式:普通用户只能改“风险旋钮”与账号私有密钥/测试网
|
||||
if (user.get("role") or "user") != "admin":
|
||||
if item.key not in (USER_RISK_KNOBS | {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}):
|
||||
errors.append(f"{item.key}: 该配置由平台统一管理(仅管理员可修改)")
|
||||
continue
|
||||
|
||||
if item.key in {"BINANCE_API_KEY", "BINANCE_API_SECRET", "USE_TESTNET"}:
|
||||
if (user.get("role") or "user") != "admin":
|
||||
require_account_owner(account_id, user)
|
||||
|
|
@ -660,3 +769,15 @@ async def update_configs_batch(
|
|||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/meta")
|
||||
async def get_config_meta(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
||||
gid = _global_strategy_account_id()
|
||||
is_admin = (user.get("role") or "user") == "admin"
|
||||
return {
|
||||
"global_strategy_account_id": int(gid),
|
||||
"is_admin": bool(is_admin),
|
||||
"user_risk_knobs": sorted(list(USER_RISK_KNOBS)),
|
||||
"note": "平台兜底模式:策略核心由全局策略账号统一管理;普通用户仅可调整风险旋钮。",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,24 @@ import logging
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 平台兜底:策略核心使用全局账号配置(默认 account_id=1),普通用户账号只允许调整“风险旋钮”
|
||||
# - 风险旋钮:每个账号独立(仓位/频次等)
|
||||
# - 其它策略参数:统一从全局账号读取,避免每个用户乱改导致策略不可控
|
||||
try:
|
||||
GLOBAL_STRATEGY_ACCOUNT_ID = int(os.getenv("ATS_GLOBAL_STRATEGY_ACCOUNT_ID") or "1")
|
||||
except Exception:
|
||||
GLOBAL_STRATEGY_ACCOUNT_ID = 1
|
||||
|
||||
RISK_KNOBS_KEYS = {
|
||||
"MIN_MARGIN_USDT",
|
||||
"MIN_POSITION_PERCENT",
|
||||
"MAX_POSITION_PERCENT",
|
||||
"MAX_TOTAL_POSITION_PERCENT",
|
||||
"AUTO_TRADE_ENABLED",
|
||||
"MAX_OPEN_POSITIONS",
|
||||
"MAX_DAILY_ENTRIES",
|
||||
}
|
||||
|
||||
# 尝试导入同步Redis客户端(用于配置缓存)
|
||||
try:
|
||||
import redis
|
||||
|
|
@ -474,71 +492,105 @@ class ConfigManager:
|
|||
|
||||
def get_trading_config(self):
|
||||
"""获取交易配置字典(兼容原有config.py的TRADING_CONFIG)"""
|
||||
# 全局策略配置管理器(避免递归:当 self 就是全局账号时,不做跨账号读取)
|
||||
global_mgr = None
|
||||
if self.account_id != int(GLOBAL_STRATEGY_ACCOUNT_ID or 1):
|
||||
try:
|
||||
global_mgr = ConfigManager.for_account(int(GLOBAL_STRATEGY_ACCOUNT_ID or 1))
|
||||
except Exception:
|
||||
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):
|
||||
"""
|
||||
策略核心:默认从全局账号读取(GLOBAL_STRATEGY_ACCOUNT_ID)。
|
||||
风险旋钮:从当前账号读取。
|
||||
"""
|
||||
# API key/secret/testnet 永远按账号读取(在 get() 内部已处理)
|
||||
if key in RISK_KNOBS_KEYS or global_mgr is None:
|
||||
return self.get(key, default)
|
||||
try:
|
||||
if key in global_mgr._cache: # noqa: SLF001
|
||||
return global_mgr._cache.get(key, default) # noqa: SLF001
|
||||
return global_mgr.get(key, default)
|
||||
except Exception:
|
||||
return self.get(key, default)
|
||||
|
||||
return {
|
||||
# 仓位控制
|
||||
'MAX_POSITION_PERCENT': self.get('MAX_POSITION_PERCENT', 0.08), # 提高单笔仓位到8%
|
||||
'MAX_TOTAL_POSITION_PERCENT': self.get('MAX_TOTAL_POSITION_PERCENT', 0.40), # 提高总仓位到40%
|
||||
'MIN_POSITION_PERCENT': self.get('MIN_POSITION_PERCENT', 0.02), # 提高最小仓位到2%
|
||||
'MIN_MARGIN_USDT': self.get('MIN_MARGIN_USDT', 5.0), # 提高最小保证金到5美元
|
||||
'MAX_POSITION_PERCENT': eff_get('MAX_POSITION_PERCENT', 0.08), # 单笔最大保证金占比
|
||||
'MAX_TOTAL_POSITION_PERCENT': eff_get('MAX_TOTAL_POSITION_PERCENT', 0.40), # 总保证金占比上限
|
||||
'MIN_POSITION_PERCENT': eff_get('MIN_POSITION_PERCENT', 0.02), # 最小保证金占比
|
||||
'MIN_MARGIN_USDT': eff_get('MIN_MARGIN_USDT', 5.0), # 最小保证金(USDT)
|
||||
|
||||
# 用户风险旋钮:自动交易开关/频次控制
|
||||
'AUTO_TRADE_ENABLED': eff_get('AUTO_TRADE_ENABLED', True),
|
||||
'MAX_OPEN_POSITIONS': eff_get('MAX_OPEN_POSITIONS', 3),
|
||||
'MAX_DAILY_ENTRIES': eff_get('MAX_DAILY_ENTRIES', 8),
|
||||
|
||||
# 涨跌幅阈值
|
||||
'MIN_CHANGE_PERCENT': self.get('MIN_CHANGE_PERCENT', 2.0),
|
||||
'TOP_N_SYMBOLS': self.get('TOP_N_SYMBOLS', 10),
|
||||
'MIN_CHANGE_PERCENT': eff_get('MIN_CHANGE_PERCENT', 2.0),
|
||||
'TOP_N_SYMBOLS': eff_get('TOP_N_SYMBOLS', 10),
|
||||
|
||||
# 风险控制
|
||||
'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倍数最大值
|
||||
'STOP_LOSS_PERCENT': eff_get('STOP_LOSS_PERCENT', 0.10), # 默认10%
|
||||
'TAKE_PROFIT_PERCENT': eff_get('TAKE_PROFIT_PERCENT', 0.30), # 默认30%(盈亏比3:1)
|
||||
'MIN_STOP_LOSS_PRICE_PCT': eff_get('MIN_STOP_LOSS_PRICE_PCT', 0.02), # 默认2%
|
||||
'MIN_TAKE_PROFIT_PRICE_PCT': eff_get('MIN_TAKE_PROFIT_PRICE_PCT', 0.03), # 默认3%
|
||||
'USE_ATR_STOP_LOSS': eff_get('USE_ATR_STOP_LOSS', True), # 是否使用ATR动态止损
|
||||
'ATR_STOP_LOSS_MULTIPLIER': eff_get('ATR_STOP_LOSS_MULTIPLIER', 1.8), # ATR止损倍数(1.5-2倍)
|
||||
'ATR_TAKE_PROFIT_MULTIPLIER': eff_get('ATR_TAKE_PROFIT_MULTIPLIER', 3.0), # ATR止盈倍数(3倍ATR)
|
||||
'RISK_REWARD_RATIO': eff_get('RISK_REWARD_RATIO', 3.0), # 盈亏比(止损距离的倍数)
|
||||
'ATR_PERIOD': eff_get('ATR_PERIOD', 14), # ATR计算周期
|
||||
'USE_DYNAMIC_ATR_MULTIPLIER': eff_get('USE_DYNAMIC_ATR_MULTIPLIER', False), # 是否根据波动率动态调整ATR倍数
|
||||
'ATR_MULTIPLIER_MIN': eff_get('ATR_MULTIPLIER_MIN', 1.5), # 动态ATR倍数最小值
|
||||
'ATR_MULTIPLIER_MAX': eff_get('ATR_MULTIPLIER_MAX', 2.5), # 动态ATR倍数最大值
|
||||
|
||||
# 市场扫描(1小时主周期)
|
||||
'SCAN_INTERVAL': self.get('SCAN_INTERVAL', 3600), # 1小时
|
||||
'TOP_N_SYMBOLS': self.get('TOP_N_SYMBOLS', 10), # 每次扫描后处理的交易对数量
|
||||
'MAX_SCAN_SYMBOLS': self.get('MAX_SCAN_SYMBOLS', 500), # 扫描的最大交易对数量(0表示扫描所有)
|
||||
'KLINE_INTERVAL': self.get('KLINE_INTERVAL', '1h'),
|
||||
'PRIMARY_INTERVAL': self.get('PRIMARY_INTERVAL', '1h'),
|
||||
'CONFIRM_INTERVAL': self.get('CONFIRM_INTERVAL', '4h'),
|
||||
'ENTRY_INTERVAL': self.get('ENTRY_INTERVAL', '15m'),
|
||||
'SCAN_INTERVAL': eff_get('SCAN_INTERVAL', 3600), # 1小时
|
||||
'TOP_N_SYMBOLS': eff_get('TOP_N_SYMBOLS', 10), # 每次扫描后处理的交易对数量
|
||||
'MAX_SCAN_SYMBOLS': eff_get('MAX_SCAN_SYMBOLS', 500), # 扫描的最大交易对数量(0表示扫描所有)
|
||||
'KLINE_INTERVAL': eff_get('KLINE_INTERVAL', '1h'),
|
||||
'PRIMARY_INTERVAL': eff_get('PRIMARY_INTERVAL', '1h'),
|
||||
'CONFIRM_INTERVAL': eff_get('CONFIRM_INTERVAL', '4h'),
|
||||
'ENTRY_INTERVAL': eff_get('ENTRY_INTERVAL', '15m'),
|
||||
|
||||
# 过滤条件
|
||||
'MIN_VOLUME_24H': self.get('MIN_VOLUME_24H', 10000000),
|
||||
'MIN_VOLATILITY': self.get('MIN_VOLATILITY', 0.02),
|
||||
'MIN_VOLUME_24H': eff_get('MIN_VOLUME_24H', 10000000),
|
||||
'MIN_VOLATILITY': eff_get('MIN_VOLATILITY', 0.02),
|
||||
|
||||
# 高胜率策略参数
|
||||
'MIN_SIGNAL_STRENGTH': self.get('MIN_SIGNAL_STRENGTH', 5),
|
||||
'LEVERAGE': self.get('LEVERAGE', 10),
|
||||
'USE_DYNAMIC_LEVERAGE': self.get('USE_DYNAMIC_LEVERAGE', True),
|
||||
'MAX_LEVERAGE': self.get('MAX_LEVERAGE', 15), # 降低到15,更保守,配合更大的保证金
|
||||
'USE_TRAILING_STOP': self.get('USE_TRAILING_STOP', True),
|
||||
'TRAILING_STOP_ACTIVATION': self.get('TRAILING_STOP_ACTIVATION', 0.10), # 默认10%(给趋势更多空间)
|
||||
'TRAILING_STOP_PROTECT': self.get('TRAILING_STOP_PROTECT', 0.05), # 默认5%(保护更多利润)
|
||||
'MIN_SIGNAL_STRENGTH': eff_get('MIN_SIGNAL_STRENGTH', 5),
|
||||
'LEVERAGE': eff_get('LEVERAGE', 10),
|
||||
'USE_DYNAMIC_LEVERAGE': eff_get('USE_DYNAMIC_LEVERAGE', True),
|
||||
'MAX_LEVERAGE': eff_get('MAX_LEVERAGE', 15), # 降低到15,更保守,配合更大的保证金
|
||||
'USE_TRAILING_STOP': eff_get('USE_TRAILING_STOP', True),
|
||||
'TRAILING_STOP_ACTIVATION': eff_get('TRAILING_STOP_ACTIVATION', 0.10), # 默认10%(给趋势更多空间)
|
||||
'TRAILING_STOP_PROTECT': eff_get('TRAILING_STOP_PROTECT', 0.05), # 默认5%(保护更多利润)
|
||||
|
||||
# 自动交易过滤(用于提升胜率/控频)
|
||||
# 说明:这两个 key 需要出现在 TRADING_CONFIG 中,否则 trading_system 在每次 reload_from_redis 后会丢失它们,
|
||||
# 导致始终按默认值拦截自动交易(用户在配置页怎么开都没用)。
|
||||
'AUTO_TRADE_ONLY_TRENDING': self.get('AUTO_TRADE_ONLY_TRENDING', True),
|
||||
'AUTO_TRADE_ALLOW_4H_NEUTRAL': self.get('AUTO_TRADE_ALLOW_4H_NEUTRAL', False),
|
||||
'AUTO_TRADE_ONLY_TRENDING': eff_get('AUTO_TRADE_ONLY_TRENDING', True),
|
||||
'AUTO_TRADE_ALLOW_4H_NEUTRAL': eff_get('AUTO_TRADE_ALLOW_4H_NEUTRAL', False),
|
||||
|
||||
# 智能入场/限价偏移(部分逻辑会直接读取 TRADING_CONFIG)
|
||||
'LIMIT_ORDER_OFFSET_PCT': self.get('LIMIT_ORDER_OFFSET_PCT', 0.5),
|
||||
'SMART_ENTRY_ENABLED': self.get('SMART_ENTRY_ENABLED', False),
|
||||
'SMART_ENTRY_STRONG_SIGNAL': self.get('SMART_ENTRY_STRONG_SIGNAL', 8),
|
||||
'ENTRY_SYMBOL_COOLDOWN_SEC': self.get('ENTRY_SYMBOL_COOLDOWN_SEC', 120),
|
||||
'ENTRY_TIMEOUT_SEC': self.get('ENTRY_TIMEOUT_SEC', 180),
|
||||
'ENTRY_STEP_WAIT_SEC': self.get('ENTRY_STEP_WAIT_SEC', 15),
|
||||
'ENTRY_CHASE_MAX_STEPS': self.get('ENTRY_CHASE_MAX_STEPS', 4),
|
||||
'ENTRY_MARKET_FALLBACK_AFTER_SEC': self.get('ENTRY_MARKET_FALLBACK_AFTER_SEC', 45),
|
||||
'ENTRY_CONFIRM_TIMEOUT_SEC': self.get('ENTRY_CONFIRM_TIMEOUT_SEC', 30),
|
||||
'ENTRY_MAX_DRIFT_PCT_TRENDING': self.get('ENTRY_MAX_DRIFT_PCT_TRENDING', 0.6),
|
||||
'ENTRY_MAX_DRIFT_PCT_RANGING': self.get('ENTRY_MAX_DRIFT_PCT_RANGING', 0.3),
|
||||
'LIMIT_ORDER_OFFSET_PCT': eff_get('LIMIT_ORDER_OFFSET_PCT', 0.5),
|
||||
'SMART_ENTRY_ENABLED': eff_get('SMART_ENTRY_ENABLED', False),
|
||||
'SMART_ENTRY_STRONG_SIGNAL': eff_get('SMART_ENTRY_STRONG_SIGNAL', 8),
|
||||
'ENTRY_SYMBOL_COOLDOWN_SEC': eff_get('ENTRY_SYMBOL_COOLDOWN_SEC', 120),
|
||||
'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.6),
|
||||
'ENTRY_MAX_DRIFT_PCT_RANGING': eff_get('ENTRY_MAX_DRIFT_PCT_RANGING', 0.3),
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,22 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
const [accountTradingStatus, setAccountTradingStatus] = useState(null)
|
||||
const [accountTradingErr, setAccountTradingErr] = useState('')
|
||||
const [currentAccountMeta, setCurrentAccountMeta] = useState(null)
|
||||
const [configMeta, setConfigMeta] = useState(null)
|
||||
|
||||
// 多账号:当前账号(仅用于配置页提示;全局切换器在顶部导航)
|
||||
const [accountId, setAccountId] = useState(getCurrentAccountId())
|
||||
|
||||
const isAdmin = (currentUser?.role || '') === 'admin'
|
||||
const globalStrategyAccountId = parseInt(String(configMeta?.global_strategy_account_id || '1'), 10) || 1
|
||||
const isGlobalStrategyAccount = isAdmin && accountId === globalStrategyAccountId
|
||||
const loadConfigMeta = async () => {
|
||||
try {
|
||||
const m = await api.getConfigMeta()
|
||||
setConfigMeta(m || null)
|
||||
} catch (e) {
|
||||
setConfigMeta(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 账号管理(超管)
|
||||
const [accountsAdmin, setAccountsAdmin] = useState([])
|
||||
|
|
@ -394,6 +405,7 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigMeta()
|
||||
loadConfigs()
|
||||
checkFeasibility()
|
||||
if (isAdmin) {
|
||||
|
|
@ -808,6 +820,46 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
<div className="account-switch">
|
||||
<span className="account-hint">当前账号:#{accountId}(在顶部导航切换)</span>
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<div
|
||||
className="system-hint"
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '10px',
|
||||
border: '1px solid #e0b400',
|
||||
background: '#fff8e1',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700 }}>
|
||||
{isGlobalStrategyAccount ? (
|
||||
<>
|
||||
你正在编辑 <span style={{ fontWeight: 900 }}>全局策略账号 #{globalStrategyAccountId}</span>:
|
||||
此处修改将影响所有用户的策略核心。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
你正在编辑账号 #{accountId}:这里只允许调整 <span style={{ fontWeight: 900 }}>风险旋钮</span>(仓位/次数/自动交易开关等)。
|
||||
策略核心统一来自 <span style={{ fontWeight: 900 }}>全局策略账号 #{globalStrategyAccountId}</span>。
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isGlobalStrategyAccount ? (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="system-btn primary"
|
||||
onClick={() => {
|
||||
setCurrentAccountId(globalStrategyAccountId)
|
||||
setAccountId(globalStrategyAccountId)
|
||||
setMessage('已切换到全局策略账号(策略核心统一在这里维护)')
|
||||
}}
|
||||
>
|
||||
切换到全局策略账号 #{globalStrategyAccountId}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
|
|
@ -1315,33 +1367,34 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 预设方案快速切换 */}
|
||||
<div className="preset-section">
|
||||
<div className="preset-header">
|
||||
<h3>快速切换方案</h3>
|
||||
<div className="current-preset-status">
|
||||
<span className="status-label">当前方案:</span>
|
||||
<span className={`status-badge ${currentPreset ? 'preset' : 'custom'}`}>
|
||||
{currentPreset ? presets[currentPreset].name : '自定义'}
|
||||
</span>
|
||||
{/* 预设方案快速切换(仅管理员 + 全局策略账号:策略核心统一管理) */}
|
||||
{isAdmin && isGlobalStrategyAccount ? (
|
||||
<div className="preset-section">
|
||||
<div className="preset-header">
|
||||
<h3>快速切换方案</h3>
|
||||
<div className="current-preset-status">
|
||||
<span className="status-label">当前方案:</span>
|
||||
<span className={`status-badge ${currentPreset ? 'preset' : 'custom'}`}>
|
||||
{currentPreset ? presets[currentPreset].name : '自定义'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preset-guide">
|
||||
<div className="preset-guide-title">怎么选更不迷糊</div>
|
||||
<ul className="preset-guide-list">
|
||||
<li>
|
||||
<strong>先选入场机制</strong>:纯限价(更控频但可能撤单) vs 智能入场(更少漏单但需限制追价)。
|
||||
</li>
|
||||
<li>
|
||||
<strong>再看“会不会下单”</strong>:如果你发现几乎不出单,优先把 <code>AUTO_TRADE_ONLY_TRENDING</code> 关掉、把 <code>AUTO_TRADE_ALLOW_4H_NEUTRAL</code> 打开。
|
||||
</li>
|
||||
<li>
|
||||
<strong>最后再微调</strong>:想更容易成交 → 调小 <code>LIMIT_ORDER_OFFSET_PCT</code>、调大 <code>ENTRY_CONFIRM_TIMEOUT_SEC</code>。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preset-guide">
|
||||
<div className="preset-guide-title">怎么选更不迷糊</div>
|
||||
<ul className="preset-guide-list">
|
||||
<li>
|
||||
<strong>先选入场机制</strong>:纯限价(更控频但可能撤单) vs 智能入场(更少漏单但需限制追价)。
|
||||
</li>
|
||||
<li>
|
||||
<strong>再看“会不会下单”</strong>:如果你发现几乎不出单,优先把 <code>AUTO_TRADE_ONLY_TRENDING</code> 关掉、把 <code>AUTO_TRADE_ALLOW_4H_NEUTRAL</code> 打开。
|
||||
</li>
|
||||
<li>
|
||||
<strong>最后再微调</strong>:想更容易成交 → 调小 <code>LIMIT_ORDER_OFFSET_PCT</code>、调大 <code>ENTRY_CONFIRM_TIMEOUT_SEC</code>。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
{(() => {
|
||||
const presetUiMeta = {
|
||||
swing: { group: 'limit', tag: '纯限价' },
|
||||
strict: { group: 'limit', tag: '纯限价' },
|
||||
|
|
@ -1411,8 +1464,13 @@ const ConfigPanel = ({ currentUser }) => {
|
|||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="system-hint" style={{ marginTop: '12px' }}>
|
||||
平台已开启“傻瓜化模式”:策略核心由管理员统一管理。你只需要配置密钥、充值余额,并调整少量风控参数(如最小/最大仓位、每日开仓次数等)。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 配置可行性检查提示 */}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,14 @@ export const api = {
|
|||
},
|
||||
|
||||
// 配置管理
|
||||
getConfigMeta: async () => {
|
||||
const response = await fetch(buildUrl('/api/config/meta'), { headers: withAuthHeaders() })
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: '获取配置元信息失败' }))
|
||||
throw new Error(error.detail || '获取配置元信息失败')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
getConfigs: async () => {
|
||||
const response = await fetch(buildUrl('/api/config'), { headers: withAccountHeaders() });
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -175,6 +175,11 @@ def _get_trading_config():
|
|||
return _config_manager.get_trading_config()
|
||||
# 回退到默认配置
|
||||
return {
|
||||
# ===== 用户风险旋钮(傻瓜化)=====
|
||||
'AUTO_TRADE_ENABLED': True, # 自动交易总开关
|
||||
'MAX_OPEN_POSITIONS': 3, # 同时持仓数量上限
|
||||
'MAX_DAILY_ENTRIES': 8, # 每日最多开仓次数
|
||||
|
||||
'MAX_POSITION_PERCENT': 0.08, # 提高单笔仓位到8%(原来5%),增加收益
|
||||
'MAX_TOTAL_POSITION_PERCENT': 0.40, # 提高总仓位到40%(原来30%),允许更多持仓
|
||||
'MIN_POSITION_PERCENT': 0.02, # 提高最小仓位到2%(原来1%),避免过小仓位
|
||||
|
|
@ -242,6 +247,11 @@ TRADING_CONFIG = _get_trading_config()
|
|||
|
||||
# 确保包含所有必要的默认值
|
||||
defaults = {
|
||||
# 用户风险旋钮(即使DB里没配置,也能用)
|
||||
'AUTO_TRADE_ENABLED': True,
|
||||
'MAX_OPEN_POSITIONS': 3,
|
||||
'MAX_DAILY_ENTRIES': 8,
|
||||
|
||||
'SCAN_INTERVAL': 1800,
|
||||
'KLINE_INTERVAL': '1h',
|
||||
'PRIMARY_INTERVAL': '1h',
|
||||
|
|
|
|||
|
|
@ -629,6 +629,12 @@ class PositionManager:
|
|||
if self._monitoring_enabled:
|
||||
await self._start_position_monitoring(symbol)
|
||||
|
||||
# 记录“今日开仓次数”(用于用户风控旋钮 MAX_DAILY_ENTRIES)
|
||||
try:
|
||||
await self.risk_manager.record_entry(symbol)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return position_info
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -244,6 +244,60 @@ class RedisCache:
|
|||
self._memory_cache[key] = value
|
||||
return False
|
||||
|
||||
async def get_int(self, key: str, default: int = 0) -> int:
|
||||
"""读取一个整数值(用于计数器等)"""
|
||||
try:
|
||||
if self.redis and self._connected:
|
||||
v = await self.redis.get(key)
|
||||
if v is None:
|
||||
return int(default or 0)
|
||||
try:
|
||||
return int(v)
|
||||
except Exception:
|
||||
return int(default or 0)
|
||||
if key in self._memory_cache:
|
||||
try:
|
||||
return int(self._memory_cache.get(key) or 0)
|
||||
except Exception:
|
||||
return int(default or 0)
|
||||
except Exception:
|
||||
pass
|
||||
return int(default or 0)
|
||||
|
||||
async def incr(self, key: str, amount: int = 1, ttl: int = None) -> int:
|
||||
"""
|
||||
自增计数器(用于每日开仓次数等)。
|
||||
- Redis 可用:INCRBY + EXPIRE
|
||||
- Redis 不可用:降级到内存计数
|
||||
"""
|
||||
inc = int(amount or 1)
|
||||
if inc <= 0:
|
||||
inc = 1
|
||||
try:
|
||||
if self.redis and self._connected:
|
||||
n = await self.redis.incrby(key, inc)
|
||||
if ttl:
|
||||
try:
|
||||
await self.redis.expire(key, int(ttl))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return int(n)
|
||||
except Exception:
|
||||
return int(await self.get_int(key, 0))
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis incr失败 {key}: {e}")
|
||||
|
||||
# 内存兜底(不做 TTL)
|
||||
cur = 0
|
||||
try:
|
||||
cur = int(self._memory_cache.get(key) or 0)
|
||||
except Exception:
|
||||
cur = 0
|
||||
cur += inc
|
||||
self._memory_cache[key] = cur
|
||||
return int(cur)
|
||||
|
||||
async def delete(self, key: str):
|
||||
"""删除缓存"""
|
||||
if self.redis and self._connected:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
风险管理模块 - 严格控制仓位和风险
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
try:
|
||||
from .binance_client import BinanceClient
|
||||
|
|
@ -417,13 +419,26 @@ class RiskManager:
|
|||
Returns:
|
||||
是否应该交易
|
||||
"""
|
||||
# 用户风险旋钮:自动交易总开关
|
||||
if not bool(config.TRADING_CONFIG.get("AUTO_TRADE_ENABLED", True)):
|
||||
logger.info(f"{symbol} 自动交易已关闭(AUTO_TRADE_ENABLED=false),跳过")
|
||||
return False
|
||||
|
||||
# 检查最小涨跌幅阈值
|
||||
if abs(change_percent) < config.TRADING_CONFIG['MIN_CHANGE_PERCENT']:
|
||||
logger.debug(f"{symbol} 涨跌幅 {change_percent:.2f}% 小于阈值")
|
||||
return False
|
||||
|
||||
# 检查是否已有持仓
|
||||
# 检查是否已有持仓 / 总持仓数量限制
|
||||
positions = await self.client.get_open_positions()
|
||||
try:
|
||||
max_open = int(config.TRADING_CONFIG.get("MAX_OPEN_POSITIONS", 0) or 0)
|
||||
except Exception:
|
||||
max_open = 0
|
||||
if max_open > 0 and len(positions) >= max_open:
|
||||
logger.info(f"{symbol} 持仓数量已达上限:{len(positions)}/{max_open},跳过开仓")
|
||||
return False
|
||||
|
||||
existing_position = next(
|
||||
(p for p in positions if p['symbol'] == symbol),
|
||||
None
|
||||
|
|
@ -433,8 +448,58 @@ class RiskManager:
|
|||
logger.info(f"{symbol} 已有持仓,跳过")
|
||||
return False
|
||||
|
||||
# 每日开仓次数限制(Redis 计数;无 Redis 时降级为内存计数)
|
||||
try:
|
||||
max_daily = int(config.TRADING_CONFIG.get("MAX_DAILY_ENTRIES", 0) or 0)
|
||||
except Exception:
|
||||
max_daily = 0
|
||||
if max_daily > 0:
|
||||
c = await self._get_daily_entries_count()
|
||||
if c >= max_daily:
|
||||
logger.info(f"{symbol} 今日开仓次数已达上限:{c}/{max_daily},跳过")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _daily_entries_key(self) -> str:
|
||||
try:
|
||||
aid = int(os.getenv("ATS_ACCOUNT_ID") or os.getenv("ACCOUNT_ID") or 1)
|
||||
except Exception:
|
||||
aid = 1
|
||||
bj = timezone(timedelta(hours=8))
|
||||
d = datetime.now(bj).strftime("%Y%m%d")
|
||||
return f"ats:acc:{aid}:daily_entries:{d}"
|
||||
|
||||
def _seconds_until_beijing_day_end(self) -> int:
|
||||
bj = timezone(timedelta(hours=8))
|
||||
now = datetime.now(bj)
|
||||
end = (now.replace(hour=23, minute=59, second=59, microsecond=0))
|
||||
return max(60, int((end - now).total_seconds()) + 1)
|
||||
|
||||
async def _get_daily_entries_count(self) -> int:
|
||||
key = self._daily_entries_key()
|
||||
try:
|
||||
# redis_cache 已有内存降级逻辑
|
||||
return int(await self.client.redis_cache.get_int(key, 0))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def record_entry(self, symbol: str = "") -> None:
|
||||
"""在“开仓真正成功”后调用,用于累计每日开仓次数。"""
|
||||
try:
|
||||
max_daily = int(config.TRADING_CONFIG.get("MAX_DAILY_ENTRIES", 0) or 0)
|
||||
except Exception:
|
||||
max_daily = 0
|
||||
if max_daily <= 0:
|
||||
return
|
||||
key = self._daily_entries_key()
|
||||
ttl = self._seconds_until_beijing_day_end()
|
||||
try:
|
||||
n = await self.client.redis_cache.incr(key, 1, ttl=ttl)
|
||||
logger.info(f"{symbol} 今日开仓计数 +1:{n}/{max_daily}")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def get_stop_loss_price(
|
||||
self,
|
||||
entry_price: float,
|
||||
|
|
|
|||
|
|
@ -139,6 +139,12 @@ class TradingStrategy:
|
|||
except Exception as e:
|
||||
logger.warning(f"自动生成推荐失败 {symbol}: {e}")
|
||||
|
||||
# 用户风险旋钮:自动交易总开关(关闭则只生成推荐)
|
||||
auto_enabled = bool(config.TRADING_CONFIG.get("AUTO_TRADE_ENABLED", True))
|
||||
if not auto_enabled:
|
||||
logger.info(f"{symbol} 自动交易已关闭(AUTO_TRADE_ENABLED=false),跳过自动下单(推荐已生成)")
|
||||
continue
|
||||
|
||||
# 提升胜率:可配置的“仅 trending 自动交易”过滤
|
||||
only_trending = bool(config.TRADING_CONFIG.get("AUTO_TRADE_ONLY_TRENDING", True))
|
||||
if only_trending and market_regime != 'trending':
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user