From 5b1370a5a27bfdfc8ade199df5ebed8ffe117cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E8=96=87=E5=AE=89?= Date: Wed, 21 Jan 2026 23:44:37 +0800 Subject: [PATCH] a --- backend/api/routes/config.py | 137 ++++++++++++++++++++-- backend/config_manager.py | 146 ++++++++++++++++-------- frontend/src/components/ConfigPanel.jsx | 112 +++++++++++++----- frontend/src/services/api.js | 8 ++ trading_system/config.py | 10 ++ trading_system/position_manager.py | 6 + trading_system/redis_cache.py | 54 +++++++++ trading_system/risk_manager.py | 67 ++++++++++- trading_system/strategy.py | 6 + 9 files changed, 463 insertions(+), 83 deletions(-) diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py index ca5462d..5f01af3 100644 --- a/backend/api/routes/config.py +++ b/backend/api/routes/config.py @@ -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": "平台兜底模式:策略核心由全局策略账号统一管理;普通用户仅可调整风险旋钮。", + } diff --git a/backend/config_manager.py b/backend/config_manager.py index c19e5cd..07e55b2 100644 --- a/backend/config_manager.py +++ b/backend/config_manager.py @@ -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), } diff --git a/frontend/src/components/ConfigPanel.jsx b/frontend/src/components/ConfigPanel.jsx index 595db62..105537a 100644 --- a/frontend/src/components/ConfigPanel.jsx +++ b/frontend/src/components/ConfigPanel.jsx @@ -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 }) => {
当前账号:#{accountId}(在顶部导航切换)
+ {isAdmin ? ( +
+
+ {isGlobalStrategyAccount ? ( + <> + 你正在编辑 全局策略账号 #{globalStrategyAccountId}: + 此处修改将影响所有用户的策略核心。 + + ) : ( + <> + 你正在编辑账号 #{accountId}:这里只允许调整 风险旋钮(仓位/次数/自动交易开关等)。 + 策略核心统一来自 全局策略账号 #{globalStrategyAccountId}。 + + )} +
+ {!isGlobalStrategyAccount ? ( +
+ +
+ ) : null} +
+ ) : null}
) : null} - {/* 预设方案快速切换 */} -
-
-

快速切换方案

-
- 当前方案: - - {currentPreset ? presets[currentPreset].name : '自定义'} - + {/* 预设方案快速切换(仅管理员 + 全局策略账号:策略核心统一管理) */} + {isAdmin && isGlobalStrategyAccount ? ( +
+
+

快速切换方案

+
+ 当前方案: + + {currentPreset ? presets[currentPreset].name : '自定义'} + +
+
+
+
怎么选更不迷糊
+
    +
  • + 先选入场机制:纯限价(更控频但可能撤单) vs 智能入场(更少漏单但需限制追价)。 +
  • +
  • + 再看“会不会下单”:如果你发现几乎不出单,优先把 AUTO_TRADE_ONLY_TRENDING 关掉、把 AUTO_TRADE_ALLOW_4H_NEUTRAL 打开。 +
  • +
  • + 最后再微调:想更容易成交 → 调小 LIMIT_ORDER_OFFSET_PCT、调大 ENTRY_CONFIRM_TIMEOUT_SEC。 +
  • +
-
-
-
怎么选更不迷糊
-
    -
  • - 先选入场机制:纯限价(更控频但可能撤单) vs 智能入场(更少漏单但需限制追价)。 -
  • -
  • - 再看“会不会下单”:如果你发现几乎不出单,优先把 AUTO_TRADE_ONLY_TRENDING 关掉、把 AUTO_TRADE_ALLOW_4H_NEUTRAL 打开。 -
  • -
  • - 最后再微调:想更容易成交 → 调小 LIMIT_ORDER_OFFSET_PCT、调大 ENTRY_CONFIRM_TIMEOUT_SEC。 -
  • -
-
- {(() => { + {(() => { const presetUiMeta = { swing: { group: 'limit', tag: '纯限价' }, strict: { group: 'limit', tag: '纯限价' }, @@ -1411,8 +1464,13 @@ const ConfigPanel = ({ currentUser }) => { ))}
) - })()} -
+ })()} +
+ ) : ( +
+ 平台已开启“傻瓜化模式”:策略核心由管理员统一管理。你只需要配置密钥、充值余额,并调整少量风控参数(如最小/最大仓位、每日开仓次数等)。 +
+ )} {/* 配置可行性检查提示 */} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 7dab9c3..c3a1348 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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) { diff --git a/trading_system/config.py b/trading_system/config.py index 8f60ea3..309bfdf 100644 --- a/trading_system/config.py +++ b/trading_system/config.py @@ -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', diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index 1fcd895..b8580ba 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -628,6 +628,12 @@ class PositionManager: # 启动WebSocket实时监控 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 diff --git a/trading_system/redis_cache.py b/trading_system/redis_cache.py index 545d3d5..a083fae 100644 --- a/trading_system/redis_cache.py +++ b/trading_system/redis_cache.py @@ -243,6 +243,60 @@ class RedisCache: # 降级到内存缓存(不设置 TTL,因为内存缓存不支持) 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): """删除缓存""" diff --git a/trading_system/risk_manager.py b/trading_system/risk_manager.py index 5be66af..89b84d6 100644 --- a/trading_system/risk_manager.py +++ b/trading_system/risk_manager.py @@ -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 @@ -432,8 +447,58 @@ class RiskManager: if existing_position: 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, diff --git a/trading_system/strategy.py b/trading_system/strategy.py index e02527b..6db7e51 100644 --- a/trading_system/strategy.py +++ b/trading_system/strategy.py @@ -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':