From 5e963ecc01ee84a264f88ff664e4df11ecc33b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E8=96=87=E5=AE=89?= Date: Mon, 19 Jan 2026 17:37:40 +0800 Subject: [PATCH] a --- MULTI_USER_ARCHITECTURE.md | 146 ++++++++++++++++++++++++ backend/api/routes/config.py | 4 +- backend/api/routes/trades.py | 12 ++ frontend/src/components/ConfigPanel.jsx | 18 ++- frontend/src/components/TradeList.jsx | 17 +++ trading_system/config.py | 13 ++- trading_system/position_manager.py | 25 +++- 7 files changed, 217 insertions(+), 18 deletions(-) create mode 100644 MULTI_USER_ARCHITECTURE.md diff --git a/MULTI_USER_ARCHITECTURE.md b/MULTI_USER_ARCHITECTURE.md new file mode 100644 index 0000000..a536fb2 --- /dev/null +++ b/MULTI_USER_ARCHITECTURE.md @@ -0,0 +1,146 @@ +## 多用户(多账号)架构评估与落地方案(草案) + +本文档基于你提出的多用户需求,给出数据隔离边界、并发模型、Redis/DB 设计、以及 2核4G 服务器负载评估与分阶段落地路线。 + +--- + +## 目标与约束 + +### 目标(对应你的 6 点) + +1. **每个用户使用自己的 API KEY** +2. **每个用户独立自动交易、独立持仓** +3. **每个用户独立配置** +4. **交易推荐全局共用** +5. **订单/交易记录独立** +6. **错误日志全局共用** + +### 关键约束 + +- **币安限频是关键瓶颈**:公共行情接口通常按 IP 限频,单纯“多 key”无法线性扩容;必须通过共享行情缓存降低请求。 +- **隔离优先级**:下单/持仓/配置/订单记录必须强隔离;推荐/行情/错误日志可以共享。 + +--- + +## 数据归属与隔离(Data Ownership) + +### A. 全局共享(所有用户共用) + +- **推荐(Recommendations)**:一份全局 Redis Snapshot/List + - 建议 Key:`ats:recommendations:snapshot` + - 后端/前端只读(不在请求中触发扫描/生成) +- **行情热数据(Market Data)**:全局缓存(Redis) + - `ats:md:mark_price:all`(短 TTL,比如 2~5s) + - `ats:md:ticker_24h:all`(TTL 10~30s) + - `ats:md:klines:{symbol}:{interval}:{limit}`(TTL 30~120s) +- **错误日志(Logs)**:全局流 + - 继续使用 `ats:logs:{error|warning|info}` + - 每条日志建议增加字段:`account_id`(便于筛选定位) + +### B. 用户私有(必须隔离) + +- **API KEY/SECRET(敏感)**:按账号独立存储(必须加密) +- **交易配置**:按账号独立 + - DB:`(account_id, config_key)` 唯一 + - Redis:`ats:cfg:{account_id}`(hash) +- **自动交易运行态/热数据(可选但推荐)** + - `ats:positions:{account_id}`(更快的 UI 展示/减少后端压力) + - `ats:orders:pending:{account_id}`(挂单/入场状态) + - `ats:stats:{account_id}:*`(每日下单数、成交数、撤单数等) +- **交易/订单记录**:按账号独立 + - DB 表:`trades` 增加 `account_id`;统计/查询均按账号过滤 + +--- + +## 并发模型:是否需要多线程/多进程? + +结论:**不推荐多线程**。更推荐以下两种模型之一: + +### 方案 1(推荐先落地):每个账号一个 `trading_system` 进程(Supervisor 管理) + +- **优点** + - 隔离强:某个账号异常/限频/崩溃不影响其他账号 + - 上线快:对现有单账号 trading_system 改动最少 + - 运维简单:Supervisor 一账号一进程 +- **缺点** + - 进程数随账号增长(内存占用会上升) + - 若每进程都重复拉行情,会触发 IP 限频(必须配合“全局行情缓存”) + +### 方案 2(后期优化):单进程 + asyncio 多账号 worker + +- **优点** + - 资源更省,便于共享行情缓存/HTTP Session +- **缺点** + - 隔离弱:某个账号阻塞/异常可能影响整体 + - 实现复杂:需要更严格的超时/限频/异常隔离与任务监控 + +--- + +## Redis 用法:减轻后端与交易系统压力的关键 + +建议将 Redis 分为四层: + +1. **全局行情缓存层**(降低公共接口请求) +2. **全局推荐层**(只读 snapshot,避免“刷新=触发扫描”) +3. **账号配置层**(`ats:cfg:{account_id}`) +4. **账号运行态层**(positions/pending/stats) + +额外建议: +- 使用短 TTL + 分布式锁(你已有 lock 设计),避免并发刷新造成浪涌。 + +--- + +## 数据库改造(最小必要变更) + +1. 新增 `accounts` 表: + - `id, name, api_key_enc, api_secret_enc, status, created_at ...` + - API Key 必须加密存储(服务端用 master key 解密) +2. `trading_config` 增加 `account_id`(或新表 `trading_config_accounts`) +3. `trades` 增加 `account_id` +4. `account_snapshots` 增加 `account_id` +5. 其余按需:positions、signals(signals 可全局,positions 必须私有) + +--- + +## 2核4G 负载评估(粗略) + +负载主要来自两块: + +1. **公共行情拉取/指标计算(全局)** +2. **每账号的下单/持仓同步/风控计算(私有)** + +### 不做共享行情(不推荐) + +- 每账号都拉 klines/mark price/ticker:会先撞到 **币安 IP 限频**,2c4g 也撑不住。 + +### 做共享行情 + 推荐全局(推荐) + +- 公共行情只拉 1 份(Redis 分发),账号 worker 只消费缓存 + 下单/持仓同步。 +- 2c4g 在“波段低频”(如 15min 扫描、每账号持仓 0~5)场景下,通常可支撑 **10~30 个账号**(视持仓监控方式与限频策略而定)。 +- 若每个账号都对每个持仓开 WebSocket 监控,上限会明显降低(更建议用全局 mark price + 定时校验替代/降频)。 + +--- + +## 分阶段落地路线(推荐) + +### Phase 1:最快上线(强隔离、成本最低) + +- accounts + account_id 改造(DB/API/前端) +- 每账号一个 trading_system 进程(Supervisor) +- 推荐/行情继续全局缓存(Redis) +- 日志全局流(带 account_id) + +### Phase 2:提升承载与稳定性 + +- 抽全局 MarketDataService(行情/klines 统一刷新一次) +- 每账号 worker 只做决策/下单,减少公共 API 调用 +- 降低 per-position websocket 数量(用 mark price 缓存替代) + +--- + +## 风险提示与建议 + +- **安全**:API Key 必须加密存储;前端永远不返回明文 secret。 +- **限频**:公共接口一定要共享缓存;多账号并发拉行情会让整体不可用。 +- **隔离**:交易执行建议多进程优先;后期再做单进程多 worker 的资源优化。 + diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py index 61792a8..9fea898 100644 --- a/backend/api/routes/config.py +++ b/backend/api/routes/config.py @@ -21,10 +21,10 @@ router = APIRouter() # 智能入场(方案C)配置:为了“配置页可见”,即使数据库尚未创建,也在 GET /api/config 返回默认项 SMART_ENTRY_CONFIG_DEFAULTS = { "SMART_ENTRY_ENABLED": { - "value": True, + "value": False, "type": "boolean", "category": "strategy", - "description": "智能入场开关(方案C):趋势时减少错过,震荡时避免追价打损。", + "description": "智能入场开关。关闭后回归“纯限价单模式”(不追价/不市价兜底/未成交则撤单跳过),更适合低频波段。", }, "SMART_ENTRY_STRONG_SIGNAL": { "value": 8, diff --git a/backend/api/routes/trades.py b/backend/api/routes/trades.py index 50156e0..654323d 100644 --- a/backend/api/routes/trades.py +++ b/backend/api/routes/trades.py @@ -219,6 +219,13 @@ async def get_trade_stats( # 只统计有意义的交易(排除0盈亏)的胜率 win_trades = [t for t in meaningful_trades if float(t['pnl']) > 0] loss_trades = [t for t in meaningful_trades if float(t['pnl']) < 0] + + # 盈利/亏损均值(用于观察是否接近 3:1) + avg_win_pnl = sum(float(t["pnl"]) for t in win_trades) / len(win_trades) if win_trades else 0.0 + avg_loss_pnl_abs = ( + sum(abs(float(t["pnl"])) for t in loss_trades) / len(loss_trades) if loss_trades else 0.0 + ) + win_loss_ratio = (avg_win_pnl / avg_loss_pnl_abs) if avg_loss_pnl_abs > 0 else None stats = { "total_trades": len(trades), @@ -231,6 +238,11 @@ async def get_trade_stats( "win_rate": len(win_trades) / len(meaningful_trades) * 100 if meaningful_trades else 0, # 基于有意义的交易计算胜率 "total_pnl": sum(float(t['pnl']) for t in closed_trades), "avg_pnl": sum(float(t['pnl']) for t in closed_trades) / len(closed_trades) if closed_trades else 0, + # 额外统计:盈利单均值 vs 亏损单均值(绝对值)以及比值(目标 3:1) + "avg_win_pnl": avg_win_pnl, + "avg_loss_pnl_abs": avg_loss_pnl_abs, + "avg_win_loss_ratio": win_loss_ratio, + "avg_win_loss_ratio_target": 3.0, # 总交易量(名义下单量口径):优先使用 notional_usdt(新字段),否则回退 entry_price * quantity "total_notional_usdt": sum( float(t.get('notional_usdt') or (float(t.get('entry_price', 0)) * float(t.get('quantity', 0)))) diff --git a/frontend/src/components/ConfigPanel.jsx b/frontend/src/components/ConfigPanel.jsx index 2517822..9b91f9a 100644 --- a/frontend/src/components/ConfigPanel.jsx +++ b/frontend/src/components/ConfigPanel.jsx @@ -25,10 +25,10 @@ const ConfigPanel = () => { const presets = { swing: { name: '波段回归(推荐)', - desc: '回归“15分钟节奏 + 精选机会 + 小保证金占用”的波段交易。建议先跑20-30单再评估。', + desc: '根治高频与追价:关闭智能入场,回归“纯限价 + 30分钟扫描 + 更高信号门槛”的低频波段。建议先跑20-30单再评估。', configs: { // 操作频率 - SCAN_INTERVAL: 900, // 15分钟 + SCAN_INTERVAL: 1800, // 30分钟 TOP_N_SYMBOLS: 8, // 仓位管理(重要语义:这些百分比均按“保证金占用比例”理解) @@ -37,8 +37,11 @@ const ConfigPanel = () => { MIN_POSITION_PERCENT: 0.0, // 0%(等价于关闭最小仓位占比) // 风控 - MIN_SIGNAL_STRENGTH: 7, + MIN_SIGNAL_STRENGTH: 8, USE_TRAILING_STOP: false, + + // 根治:关闭智能入场(回归纯限价,不追价/不市价兜底) + SMART_ENTRY_ENABLED: false, }, }, conservative: { @@ -434,6 +437,10 @@ const ConfigPanel = () => { // 根据key判断类型和分类 let type = 'number' let category = 'risk' + if (typeof value === 'boolean') { + type = 'boolean' + category = 'strategy' + } if (key.includes('PERCENT') || key.includes('PCT')) { type = 'number' if (key.includes('STOP_LOSS') || key.includes('TAKE_PROFIT')) { @@ -1150,9 +1157,9 @@ const ConfigItem = ({ label, config, onUpdate, disabled }) => { const getConfigDetail = (key) => { const details = { // 市场扫描参数 - 'SCAN_INTERVAL': '扫描间隔(秒)。系统每隔多长时间扫描一次市场寻找交易机会。值越小扫描越频繁,能更快捕捉波动,但会增加API请求和系统负载。建议:保守策略3600秒(1小时),平衡策略600秒(10分钟),激进策略300秒(5分钟)。晚间波动大时可降低到300-600秒。', + 'SCAN_INTERVAL': '扫描间隔(秒)。系统每隔多长时间扫描一次市场寻找交易机会。值越小扫描越频繁,能更快捕捉波动,但更容易产生噪音与过度交易。建议:低频波段1800秒(30分钟)或更长;稳健/保守可3600秒(1小时);高频不建议长期使用。', 'MIN_CHANGE_PERCENT': '最小涨跌幅阈值(%)。只有24小时涨跌幅达到此值的交易对才会被考虑交易。值越小捕捉机会越多,但可能包含噪音和假信号。值越大只捕捉大幅波动,信号质量更高但机会更少。建议:保守策略2.0-3.0%,平衡策略1.5-2.0%,激进策略1.0-1.5%。', - 'MIN_SIGNAL_STRENGTH': '最小信号强度(0-10)。技术指标综合评分,只有达到此强度的信号才会执行交易。值越小交易机会越多,但信号质量可能下降,胜率降低。值越大只执行高质量信号,胜率更高但机会更少。建议:保守策略5-7,平衡策略4-5,激进策略3-4。', + 'MIN_SIGNAL_STRENGTH': '最小信号强度(0-10)。技术指标综合评分,只有达到此强度的信号才会执行交易。值越小机会越多但噪音更大;值越大更偏“精选高质量”。建议:低频波段建议≥8;一般保守5-7;平衡4-5;高频3-4(不推荐长期)。', 'TOP_N_SYMBOLS': '每次扫描后处理的交易对数量。从符合条件的交易对中选择涨跌幅最大的前N个进行详细分析。值越大机会越多,但计算量增加,API请求增多。建议:保守策略8-10个,平衡策略12-15个,激进策略15-20个。', 'MAX_SCAN_SYMBOLS': '扫描的最大交易对数量(0表示扫描所有)。限制每次扫描时处理的交易对总数,减少API请求和计算量。值越小扫描越快,但可能错过一些机会。值越大覆盖更全面,但API请求和计算量增加。建议:保守策略100-200个,平衡策略200-300个,激进策略300-500个。设置为0会扫描所有交易对(约500+个)。', 'MIN_VOLATILITY': '最小波动率(小数形式,如0.02表示2%)。过滤掉波动率低于此值的交易对,确保只交易有足够波动的币种。值越小允许更多交易对,但可能包含波动不足的币种。值越大只交易高波动币种,但可能错过一些机会。建议:0.015-0.025(1.5%-2.5%)。', @@ -1176,6 +1183,7 @@ const getConfigDetail = (key) => { // 策略参数 'LEVERAGE': '交易杠杆倍数。放大资金利用率,同时放大收益和风险。杠杆越高,相同仓位下需要的保证金越少,但风险越大。建议:保守策略5-10倍,平衡策略10倍,激进策略10-15倍。注意:高杠杆会增加爆仓风险,请谨慎使用。', 'USE_TRAILING_STOP': '是否启用移动止损(true/false)。启用后,当盈利达到激活阈值时,止损会自动跟踪价格,保护利润。适合趋势行情,可以捕捉更大的利润空间。建议:平衡和激进策略启用,保守策略可关闭。', + 'SMART_ENTRY_ENABLED': '智能入场开关(true/false)。开启时会进行“限价回调 + 追价 +(趋势强时)市价兜底”,以减少错过;关闭时回归“纯限价单模式”:只下一次限价单,未在确认时间内成交则撤单跳过,更适合低频波段与控频。', 'TRAILING_STOP_ACTIVATION': '移动止损激活阈值(百分比,如0.01表示1%)。当盈利达到此百分比时,移动止损开始跟踪价格,将止损移至成本价(保本)。值越小激活越早,更早保护利润但可能过早退出。值越大激活越晚,给价格更多波动空间。建议:1-2%。', 'TRAILING_STOP_PROTECT': '移动止损保护利润(百分比,如0.01表示1%)。当价格从最高点回撤达到此百分比时,触发止损平仓,锁定利润。值越小保护更严格,能锁定更多利润但可能过早退出。值越大允许更大回撤,可能捕捉更大趋势但利润可能回吐。建议:1-2%。', diff --git a/frontend/src/components/TradeList.jsx b/frontend/src/components/TradeList.jsx index 903666c..2021216 100644 --- a/frontend/src/components/TradeList.jsx +++ b/frontend/src/components/TradeList.jsx @@ -266,6 +266,23 @@ const TradeList = () => { {stats.avg_pnl.toFixed(2)} USDT + {"avg_win_pnl" in stats && "avg_loss_pnl_abs" in stats && ( +
+
平均盈利 / 平均亏损(期望 3:1)
+
= 3 ? 'positive' : '' + }`} + > + {typeof stats.avg_win_loss_ratio === 'number' + ? `${stats.avg_win_loss_ratio.toFixed(2)} : 1` + : '—'} +
+
+ +{Number(stats.avg_win_pnl || 0).toFixed(2)} / -{Number(stats.avg_loss_pnl_abs || 0).toFixed(2)} USDT +
+
+ )} {"total_notional_usdt" in stats && (
总交易量(名义)
diff --git a/trading_system/config.py b/trading_system/config.py index f5aa4fd..5b89f6c 100644 --- a/trading_system/config.py +++ b/trading_system/config.py @@ -192,14 +192,14 @@ def _get_trading_config(): 'USE_DYNAMIC_ATR_MULTIPLIER': False, # 是否根据波动率动态调整ATR倍数 'ATR_MULTIPLIER_MIN': 1.5, # 动态ATR倍数最小值 'ATR_MULTIPLIER_MAX': 2.5, # 动态ATR倍数最大值 - 'SCAN_INTERVAL': 3600, + 'SCAN_INTERVAL': 1800, 'KLINE_INTERVAL': '1h', 'PRIMARY_INTERVAL': '1h', 'CONFIRM_INTERVAL': '4h', 'ENTRY_INTERVAL': '15m', 'MIN_VOLUME_24H': 5000000, # 降低到500万以获取更多推荐(推荐系统可以更宽松) 'MIN_VOLATILITY': 0.02, - 'MIN_SIGNAL_STRENGTH': 7, # 提高至7,只交易高质量信号(简化策略后) + 'MIN_SIGNAL_STRENGTH': 8, # 提高至8,只交易高质量信号(低频波段) 'LEVERAGE': 10, # 基础杠杆倍数 'USE_DYNAMIC_LEVERAGE': True, # 是否启用动态杠杆(根据信号强度调整) 'MAX_LEVERAGE': 15, # 最大杠杆倍数(降低到15,更保守,配合更大的保证金) @@ -208,8 +208,9 @@ def _get_trading_config(): 'TRAILING_STOP_PROTECT': 0.05, # 保护利润提高到5%(保护5%利润,更合理) 'POSITION_SYNC_INTERVAL': 60, # 持仓状态同步间隔(秒),缩短到1分钟,确保状态及时同步 - # ===== 智能入场(方案C:趋势少错过,震荡不追价)===== - 'SMART_ENTRY_ENABLED': True, + # ===== 智能入场(方案C)===== + # 根治方案:默认关闭。关闭后回归“纯限价单模式”(不追价/不市价兜底/未成交撤单跳过) + 'SMART_ENTRY_ENABLED': False, 'SMART_ENTRY_STRONG_SIGNAL': 8, # 强信号阈值:≥8 更倾向趋势模式(允许市价兜底) 'ENTRY_SYMBOL_COOLDOWN_SEC': 120, # 同一symbol两次入场尝试的冷却时间(避免反复挂单/重入) 'ENTRY_TIMEOUT_SEC': 180, # 智能入场总预算(秒)(限价/追价逻辑内部使用) @@ -233,14 +234,14 @@ TRADING_CONFIG = _get_trading_config() # 确保包含所有必要的默认值 defaults = { - 'SCAN_INTERVAL': 3600, + 'SCAN_INTERVAL': 1800, 'KLINE_INTERVAL': '1h', 'PRIMARY_INTERVAL': '1h', 'CONFIRM_INTERVAL': '4h', 'ENTRY_INTERVAL': '15m', 'LIMIT_ORDER_OFFSET_PCT': 0.5, # 限价单偏移百分比(默认0.5%) # 智能入场默认值(即使DB里没配置,也能用) - 'SMART_ENTRY_ENABLED': True, + 'SMART_ENTRY_ENABLED': False, 'ENTRY_SYMBOL_COOLDOWN_SEC': 120, 'ENTRY_TIMEOUT_SEC': 180, 'ENTRY_STEP_WAIT_SEC': 15, diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index a1e3c6a..b912d63 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -298,10 +298,26 @@ class PositionManager: order_status = None actual_entry_price = None filled_quantity = 0.0 - entry_mode_used = "market" if not smart_entry_enabled else ("limit+fallback" if allow_market_fallback else "limit-chase") + entry_mode_used = "limit-only" if not smart_entry_enabled else ("limit+fallback" if allow_market_fallback else "limit-chase") if not smart_entry_enabled: - order = await self.client.place_order(symbol=symbol, side=side, quantity=quantity, order_type="MARKET") + # 根治方案:关闭智能入场后,回归“纯限价单模式” + # - 不追价 + # - 不市价兜底 + # - 未在确认时间内成交则撤单并跳过(属于策略未触发入场,不是系统错误) + confirm_timeout = int(config.TRADING_CONFIG.get("ENTRY_CONFIRM_TIMEOUT_SEC", 30) or 30) + logger.info( + f"{symbol} [纯限价入场] side={side} | 限价={initial_limit:.6f} (offset={limit_offset_ratio*100:.2f}%) | " + f"确认超时={confirm_timeout}s(未成交将撤单跳过)" + ) + order = await self.client.place_order( + symbol=symbol, side=side, quantity=quantity, order_type="LIMIT", price=initial_limit + ) + if not order: + return None + entry_order_id = order.get("orderId") + if entry_order_id: + self._pending_entry_orders[symbol] = {"order_id": entry_order_id, "created_at_ms": int(time.time() * 1000)} else: # 1) 先挂限价单 logger.info( @@ -418,11 +434,10 @@ class PositionManager: filled_quantity = float(res.get("executed_qty") or 0) else: # 未成交(NEW/超时/CANCELED 等)属于“策略未触发入场”或“挂单没成交” - # 这不应当当作系统错误;同时需要撤单,避免留下悬挂委托造成后续混乱。 + # 这不应当当作系统错误;同时需要撤单(best-effort),避免留下悬挂委托造成后续混乱。 logger.warning(f"{symbol} [开仓] 未成交,状态: {order_status},跳过本次开仓并撤销挂单") try: - if str(order_status).upper() in {"NEW", "PARTIALLY_FILLED", "PENDING_NEW", "TIMEOUT"}: - await self.client.cancel_order(symbol, int(entry_order_id)) + await self.client.cancel_order(symbol, int(entry_order_id)) except Exception: pass self._pending_entry_orders.pop(symbol, None)