diff --git a/backend/database/models.py b/backend/database/models.py index 67774ec..e33cca9 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -1527,6 +1527,60 @@ class TradeStats: logger.debug(f"[TradeStats] get_hourly_stats 失败: {e}") return [] + @staticmethod + def get_global_symbol_stats(days: int = 7): + """ + 查询最近 N 天【跨账号聚合】的按交易对统计(无 account_id 维度), + 用于全局白名单/黑名单评估。「大家一视同仁」。 + """ + try: + rows = db.execute_query( + """SELECT + symbol, + SUM(trade_count) AS trade_count, + SUM(win_count) AS win_count, + SUM(loss_count) AS loss_count, + SUM(gross_pnl) AS gross_pnl, + SUM(net_pnl) AS net_pnl, + SUM(total_commission) AS total_commission + FROM trade_stats_daily + WHERE trade_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY) + GROUP BY symbol + ORDER BY net_pnl DESC""", + (max(1, int(days)),), + ) + return [dict(r) for r in rows] if rows else [] + except Exception as e: + logger.debug(f"[TradeStats] get_global_symbol_stats 失败: {e}") + return [] + + @staticmethod + def get_global_hourly_stats(days: int = 7): + """ + 查询最近 N 天【跨账号聚合】的按小时统计(无 account_id 维度), + 用于全局时段表现评估。 + """ + try: + rows = db.execute_query( + """SELECT + hour, + SUM(trade_count) AS trade_count, + SUM(win_count) AS win_count, + SUM(loss_count) AS loss_count, + SUM(gross_pnl) AS gross_pnl, + SUM(net_pnl) AS net_pnl, + SUM(total_commission) AS total_commission + FROM trade_stats_time_bucket + WHERE trade_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY) + GROUP BY hour + ORDER BY hour ASC""", + (max(1, int(days)),), + ) + return [dict(r) for r in rows] if rows else [] + except Exception as e: + logger.debug(f"[TradeStats] get_global_hourly_stats 失败: {e}") + return [] + class AccountSnapshot: """账户快照模型""" diff --git a/frontend/src/components/GlobalConfig.jsx b/frontend/src/components/GlobalConfig.jsx index 6525593..3198c84 100644 --- a/frontend/src/components/GlobalConfig.jsx +++ b/frontend/src/components/GlobalConfig.jsx @@ -568,6 +568,9 @@ const GlobalConfig = () => { BLOCK_LONG_WHEN_4H_DOWN: { value: false, type: 'boolean', category: 'strategy', description: '4H 趋势下跌时禁止开多。bear / conservative 方案下自动为 true。' }, BLOCK_SHORT_WHEN_4H_UP: { value: true, type: 'boolean', category: 'strategy', description: '4H 趋势上涨时禁止开空。默认 true,避免逆势做空导致止损。' }, AUTO_MARKET_SCHEME_ENABLED: { value: false, type: 'boolean', category: 'strategy', description: '开启后,crontab 定时运行 update_market_scheme.py --apply 时自动更新 MARKET_SCHEME(根据 BTC 行情识别牛/熊/正常)。' }, + // 基于7天统计自动生成的全局过滤结果(仅展示,自动更新) + STATS_SYMBOL_FILTERS: { value: {}, type: 'json', category: 'stats', description: '基于最近 N 天交易统计自动生成的 symbol 白/黑名单(软降权),由脚本 aggregate_trade_stats.py 自动更新,请勿手动修改。' }, + STATS_HOUR_FILTERS: { value: {}, type: 'json', category: 'stats', description: '基于最近 N 天交易统计自动生成的按小时过滤建议,用于差时段减仓/提门槛,由脚本 aggregate_trade_stats.py 自动更新,请勿手动修改。' }, } const loadConfigs = async () => { diff --git a/scripts/aggregate_trade_stats.py b/scripts/aggregate_trade_stats.py index e03ae9f..5e341b9 100644 --- a/scripts/aggregate_trade_stats.py +++ b/scripts/aggregate_trade_stats.py @@ -27,9 +27,134 @@ def main(): print("days 须 > 0") sys.exit(1) try: - from database.models import TradeStats + from datetime import datetime, timezone + from database.models import TradeStats, GlobalStrategyConfig, TradingConfig + + # 1) 先按账号聚合到 trade_stats_* 表(供仪表盘等使用) TradeStats.aggregate_recent_days(days=args.days, account_id=args.account) print(f"已聚合最近 {args.days} 天统计 (account_id={args.account or 'default'})") + + # 2) 再基于 trade_stats_* 做一次【全局】评估:按交易对 / 按小时生成软黑名单与时段过滤建议 + days = max(1, int(args.days)) + symbol_rows = TradeStats.get_global_symbol_stats(days=days) + hour_rows = TradeStats.get_global_hourly_stats(days=days) + + # 规则参数(后续可改成全局配置,这里先写死一版直观规则): + min_trades_for_symbol = 5 # 至少 N 笔才纳入评估,避免样本太少 + min_trades_for_hour = 10 # 小时至少 N 笔才纳入评估 + blacklist_pnl_threshold = 0.0 # 净盈亏 < 0 视为表现不佳 + good_hour_pnl_threshold = 0.0 # 净盈亏 > 0 视为表现较好 + + symbol_blacklist = [] + symbol_whitelist = [] + for r in symbol_rows or []: + try: + sym = (r.get("symbol") or "").strip() + tc = int(r.get("trade_count") or 0) + net_pnl = float(r.get("net_pnl") or 0.0) + win = int(r.get("win_count") or 0) + loss = int(r.get("loss_count") or 0) + except Exception: + continue + if not sym or tc < min_trades_for_symbol: + continue + total = max(1, win + loss) + win_rate = 100.0 * win / total + + # 软黑名单:近期净亏 + 有一定笔数 -> 降权(不一刀切禁止) + if net_pnl < blacklist_pnl_threshold: + symbol_blacklist.append( + { + "symbol": sym, + "trade_count": tc, + "net_pnl": round(net_pnl, 4), + "win_rate_pct": round(win_rate, 1), + "mode": "reduced", # 软黑名单:仅降权+提门槛 + } + ) + # 白名单:近期净盈且胜率尚可 + elif net_pnl > 0 and win_rate >= 50.0: + symbol_whitelist.append( + { + "symbol": sym, + "trade_count": tc, + "net_pnl": round(net_pnl, 4), + "win_rate_pct": round(win_rate, 1), + } + ) + + hour_filters = [] + for r in hour_rows or []: + try: + h = int(r.get("hour")) + tc = int(r.get("trade_count") or 0) + net_pnl = float(r.get("net_pnl") or 0.0) + except Exception: + continue + if h < 0 or h > 23: + continue + if tc < min_trades_for_hour: + bucket = "unknown" + elif net_pnl > good_hour_pnl_threshold: + bucket = "good" + elif net_pnl < -good_hour_pnl_threshold: + bucket = "bad" + else: + bucket = "neutral" + + # 约定:bad 时段默认 0.5 仓位系数 + 信号门槛+1;good 时段略微放宽(可视需求调整) + if bucket == "bad": + position_factor = 0.5 + signal_boost = 1 + elif bucket == "good": + position_factor = 1.1 + signal_boost = 0 + else: + position_factor = 1.0 + signal_boost = 0 + + hour_filters.append( + { + "hour": h, + "bucket": bucket, + "trade_count": tc, + "net_pnl": round(net_pnl, 4), + "position_factor": position_factor, + "signal_boost": signal_boost, + } + ) + + now_iso = datetime.now(timezone.utc).isoformat() + symbol_payload = { + "generated_at": now_iso, + "days": days, + "min_trades": min_trades_for_symbol, + "blacklist": sorted(symbol_blacklist, key=lambda x: (x["net_pnl"], -x["trade_count"])), + "whitelist": sorted(symbol_whitelist, key=lambda x: (-x["net_pnl"], -x["trade_count"])), + } + hour_payload = { + "generated_at": now_iso, + "days": days, + "min_trades": min_trades_for_hour, + "hours": sorted(hour_filters, key=lambda x: x["hour"]), + } + + # 写入全局策略配置(json),供交易进程 & 前端全局配置页读取 + GlobalStrategyConfig.set( + "STATS_SYMBOL_FILTERS", + symbol_payload, + "json", + "stats_filters", + description="基于最近 N 天交易统计自动生成的 symbol 白/黑名单(软降权),请勿手动修改。", + ) + GlobalStrategyConfig.set( + "STATS_HOUR_FILTERS", + hour_payload, + "json", + "stats_filters", + description="基于最近 N 天交易统计自动生成的按小时时段过滤建议,请勿手动修改。", + ) + print(f"已更新全局统计过滤配置:{len(symbol_blacklist)} 个黑名单 symbol,{len(symbol_whitelist)} 个白名单 symbol。") except Exception as e: print(f"聚合失败: {e}") sys.exit(1) diff --git a/trading_system/risk_manager.py b/trading_system/risk_manager.py index 8df8a87..e0d3099 100644 --- a/trading_system/risk_manager.py +++ b/trading_system/risk_manager.py @@ -771,7 +771,7 @@ class RiskManager: # 计算最终的名义价值与保证金 final_notional_value = quantity * current_price final_margin = final_notional_value / actual_leverage if actual_leverage > 0 else final_notional_value - + # 仓位放大系数(盈利时适当放大仓位,1.0=正常,1.2=+20%,上限2.0,仍受 MAX_POSITION_PERCENT 约束) position_scale = config.TRADING_CONFIG.get('POSITION_SCALE_FACTOR', 1.0) try: @@ -779,8 +779,47 @@ class RiskManager: position_scale = max(1.0, min(position_scale, 2.0)) except (TypeError, ValueError): position_scale = 1.0 - if position_scale > 1.0: - quantity = quantity * position_scale + + # 基于 7 天统计的 symbol / 小时软黑名单:差标的 & 差时段进一步缩小仓位 + stats_scale = 1.0 + try: + from datetime import datetime, timezone, timedelta + from config_manager import GlobalStrategyConfigManager + + mgr = GlobalStrategyConfigManager() + stats_symbol = mgr.get("STATS_SYMBOL_FILTERS", {}) or {} + stats_hour = mgr.get("STATS_HOUR_FILTERS", {}) or {} + + bl = stats_symbol.get("blacklist") or [] + if symbol and isinstance(bl, list): + if any((item.get("symbol") or "").upper() == symbol.upper() for item in bl): + # 软黑名单:默认 0.5 倍仓位 + stats_scale *= 0.5 + + # 小时过滤 + try: + bj = timezone(timedelta(hours=8)) + h = datetime.now(bj).hour + hours = stats_hour.get("hours") or [] + for item in hours: + if int(item.get("hour", -1)) == int(h): + pf = item.get("position_factor") + try: + pf_val = float(pf) + except (TypeError, ValueError): + pf_val = 1.0 + # 仅在 <1 时缩小仓位;>1 的“好时段”放大留给 POSITION_SCALE_FACTOR 处理 + if pf_val > 0 and pf_val < 1.0: + stats_scale *= pf_val + break + except Exception: + pass + except Exception: + stats_scale = 1.0 + + total_scale = position_scale * stats_scale + if total_scale != 1.0: + quantity = quantity * total_scale final_notional_value = quantity * current_price final_margin = final_notional_value / actual_leverage if actual_leverage > 0 else final_notional_value max_margin_cap = available_balance * config.get_effective_config('MAX_POSITION_PERCENT', 0.20) @@ -789,7 +828,7 @@ class RiskManager: final_notional_value = final_margin * actual_leverage quantity = final_notional_value / current_price if current_price and current_price > 0 else quantity logger.info(f" 仓位放大后超过单笔上限,已截断至 MAX_POSITION_PERCENT 对应保证金") - logger.info(f" 仓位放大系数: {position_scale:.2f} -> 最终数量: {quantity:.4f}") + logger.info(f" 仓位缩放系数: POSITION_SCALE={position_scale:.2f}, STATS_SCALE={stats_scale:.2f} -> 最终数量: {quantity:.4f}") # 添加最小名义价值检查(0.2 USDT),避免下无意义的小单子 MIN_NOTIONAL_VALUE = 0.2 # 最小名义价值0.2 USDT diff --git a/trading_system/strategy.py b/trading_system/strategy.py index 6a92615..2bf14c3 100644 --- a/trading_system/strategy.py +++ b/trading_system/strategy.py @@ -688,7 +688,41 @@ class TradingStrategy: reasons.append("❌ 逆4H趋势,拒绝交易") # 判断是否应该交易(信号强度 >= 有效 MIN_SIGNAL_STRENGTH 才交易;低波动期自动提高门槛) - min_signal_strength = config.get_effective_config('MIN_SIGNAL_STRENGTH', 7) + base_min_signal_strength = config.get_effective_config('MIN_SIGNAL_STRENGTH', 7) + + # 基于全局 7 天统计的 symbol / 小时过滤:差标的、差时段提高信号门槛 + extra_signal_boost = 0 + try: + from datetime import datetime, timezone, timedelta + from config_manager import GlobalStrategyConfigManager + + mgr = GlobalStrategyConfigManager() + stats_symbol = mgr.get("STATS_SYMBOL_FILTERS", {}) or {} + stats_hour = mgr.get("STATS_HOUR_FILTERS", {}) or {} + + # symbol 软黑名单:命中则在基础门槛上 +1 + bl = stats_symbol.get("blacklist") or [] + if symbol and isinstance(bl, list): + if any((item.get("symbol") or "").upper() == symbol.upper() for item in bl): + extra_signal_boost += 1 + + # 按小时过滤:bad 小时再 +1 + try: + bj = timezone(timedelta(hours=8)) + h = datetime.now(bj).hour + hours = stats_hour.get("hours") or [] + for item in hours: + if int(item.get("hour", -1)) == int(h): + if (item.get("bucket") or "").lower() == "bad": + extra_signal_boost += int(item.get("signal_boost") or 1) + break + except Exception: + pass + except Exception: + base_min_signal_strength = config.get_effective_config('MIN_SIGNAL_STRENGTH', 7) + extra_signal_boost = 0 + + min_signal_strength = max(0, min(10, int(base_min_signal_strength) + int(extra_signal_boost))) # 强度上限归一到 0-10,避免出现 12/10 这种误导显示 try: signal_strength = max(0, min(int(signal_strength), 10))