This commit is contained in:
薇薇安 2026-01-21 23:44:37 +08:00
parent 87e7865cbb
commit 5b1370a5a2
9 changed files with 463 additions and 83 deletions

View File

@ -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可修改所有配置
# - 非 adminaccount 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": "平台兜底模式:策略核心由全局策略账号统一管理;普通用户仅可调整风险旋钮。",
}

View File

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

View File

@ -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,7 +1367,8 @@ const ConfigPanel = ({ currentUser }) => {
</div>
) : null}
{/* 预设方案快速切换 */}
{/* 预设方案快速切换(仅管理员 + 全局策略账号:策略核心统一管理) */}
{isAdmin && isGlobalStrategyAccount ? (
<div className="preset-section">
<div className="preset-header">
<h3>快速切换方案</h3>
@ -1413,6 +1466,11 @@ const ConfigPanel = ({ currentUser }) => {
)
})()}
</div>
) : (
<div className="system-hint" style={{ marginTop: '12px' }}>
平台已开启傻瓜化模式策略核心由管理员统一管理你只需要配置密钥充值余额并调整少量风控参数如最小/最大仓位每日开仓次数等
</div>
)}
</div>
{/* 配置可行性检查提示 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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