From fca8a961bade092a490ccc280bdc0978b191e8a8 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sat, 23 May 2026 14:59:57 +0800 Subject: [PATCH] 11 --- .env.example | 2 + app/config/system_config.py | 2 + app/core/global_risk.py | 64 ++++++++++++ app/core/opportunity_lifecycle.py | 17 ++++ app/db/review_center.py | 2 + app/db/strategy_insights.py | 147 ++++++++++++++++++++++++++++ docs/OPTIMIZATION_TODO.md | 20 ++-- rules.yaml | 2 + static/review_center.html | 2 +- static/strategy.html | 5 +- tests/test_market_regime.py | 33 +++++++ tests/test_opportunity_lifecycle.py | 25 +++++ tests/test_review_center.py | 1 + 13 files changed, 310 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index f82037d..c9f3248 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,8 @@ ALPHAX_PAPER_GLOBAL_RISK_HIGH_MIN_REC_SCORE=70 ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT=3 ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT=6 ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS=0 +ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS=3 +ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS=6 ALPHAX_PAPER_ORDER_MIN_REC_SCORE=50 ALPHAX_PAPER_ORDER_MIN_RR=1.8 ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK=1 diff --git a/app/config/system_config.py b/app/config/system_config.py index 936973c..96e6919 100644 --- a/app/config/system_config.py +++ b/app/config/system_config.py @@ -132,6 +132,8 @@ def default_paper_trading_config(): "global_risk_high_drawdown_pct": _env_float("ALPHAX_PAPER_GLOBAL_RISK_HIGH_DRAWDOWN_PCT", 3.0), "global_risk_critical_drawdown_pct": _env_float("ALPHAX_PAPER_GLOBAL_RISK_CRITICAL_DRAWDOWN_PCT", 6.0), "global_risk_max_open_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_OPEN_POSITIONS", 0), + "global_risk_max_same_sector_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_SECTOR_POSITIONS", 3), + "global_risk_max_same_direction_positions": _env_int("ALPHAX_PAPER_GLOBAL_RISK_MAX_SAME_DIRECTION_POSITIONS", 6), "order_min_rec_score": _env_float("ALPHAX_PAPER_ORDER_MIN_REC_SCORE", 50.0), "order_min_rr": _env_float("ALPHAX_PAPER_ORDER_MIN_RR", 1.8), "order_require_risk_reward_ok": _env_bool("ALPHAX_PAPER_ORDER_REQUIRE_RISK_REWARD_OK", True), diff --git a/app/core/global_risk.py b/app/core/global_risk.py index 9043696..ac17bbc 100644 --- a/app/core/global_risk.py +++ b/app/core/global_risk.py @@ -3,6 +3,7 @@ from __future__ import annotations from app.core.market_regime import classify_market_regime +from app.core.sector_map import get_sector_for_coin from app.services.market_overview import get_crypto_market_overview @@ -46,6 +47,48 @@ def _portfolio_snapshot(conn, account_equity: float, additional_notional: float) } +def _sector_names(symbol: str, rec: dict | None = None) -> list[str]: + sectors = [] + rec = rec or {} + raw = rec.get("sector") or "" + if raw: + sectors.extend([x.strip() for x in str(raw).split(",") if x.strip()]) + if not sectors: + sectors.extend(get_sector_for_coin(symbol)) + return sorted({x for x in sectors if x}) + + +def _concentration_snapshot(conn, rec: dict | None = None) -> dict: + rec = rec or {} + target_symbol = str(rec.get("symbol") or "").strip().upper() + target_side = str(rec.get("side") or rec.get("direction") or "long").strip().lower() + target_sectors = _sector_names(target_symbol, rec) + open_rows = [dict(r) for r in conn.execute("SELECT symbol, side, notional_usdt FROM paper_trades WHERE status='open'").fetchall()] + same_direction_count = 0 + same_direction_notional = 0.0 + sector_counts = {sector: 0 for sector in target_sectors} + sector_notional = {sector: 0.0 for sector in target_sectors} + for row in open_rows: + row_side = str(row.get("side") or "long").strip().lower() + if row_side == target_side: + same_direction_count += 1 + same_direction_notional += _safe_float(row.get("notional_usdt")) + row_sectors = _sector_names(row.get("symbol") or "") + for sector in target_sectors: + if sector in row_sectors: + sector_counts[sector] = sector_counts.get(sector, 0) + 1 + sector_notional[sector] = sector_notional.get(sector, 0.0) + _safe_float(row.get("notional_usdt")) + return { + "target_symbol": target_symbol, + "target_side": target_side, + "target_sectors": target_sectors, + "same_direction_count": same_direction_count, + "same_direction_notional_usdt": round(same_direction_notional, 8), + "same_sector_counts": sector_counts, + "same_sector_notional_usdt": {k: round(v, 8) for k, v in sector_notional.items()}, + } + + def evaluate_global_risk( *, conn, @@ -69,6 +112,7 @@ def evaluate_global_risk( regime = classify_market_regime(overview) account_equity = max(1.0, _safe_float(cfg.get("account_equity_usdt"), 20000.0)) portfolio = _portfolio_snapshot(conn, account_equity, additional_notional) + concentration = _concentration_snapshot(conn, rec) rec_score = _safe_float((rec or {}).get("rec_score") or (rec or {}).get("score")) min_score_high = max(0.0, _safe_float(cfg.get("global_risk_high_min_rec_score"), 70.0)) max_drawdown_critical = max(0.0, _safe_float(cfg.get("global_risk_critical_drawdown_pct"), 6.0)) @@ -101,6 +145,25 @@ def evaluate_global_risk( risk_level = "high" if risk_level not in {"critical"} else risk_level reasons.append(f"持仓数量已达到上限 {max_open_positions}") + max_same_direction = max(0, _safe_int(cfg.get("global_risk_max_same_direction_positions"), 0)) + projected_same_direction = _safe_int(concentration.get("same_direction_count")) + (1 if rec else 0) + if allow and max_same_direction > 0 and projected_same_direction > max_same_direction: + allow = False + decision = "block_same_direction_concentration" + risk_level = "high" if risk_level not in {"critical"} else risk_level + reasons.append(f"同方向持仓将达到 {projected_same_direction} 个,超过上限 {max_same_direction}") + + max_same_sector = max(0, _safe_int(cfg.get("global_risk_max_same_sector_positions"), 0)) + if allow and max_same_sector > 0: + for sector, count in (concentration.get("same_sector_counts") or {}).items(): + projected = _safe_int(count) + 1 + if projected > max_same_sector: + allow = False + decision = "block_same_sector_concentration" + risk_level = "high" if risk_level not in {"critical"} else risk_level + reasons.append(f"{sector} 板块持仓将达到 {projected} 个,超过上限 {max_same_sector}") + break + return { "enabled": True, "allow_new_entries": allow, @@ -112,6 +175,7 @@ def evaluate_global_risk( "reasons": reasons, "market_regime": regime, "portfolio": portfolio, + "concentration": concentration, } diff --git a/app/core/opportunity_lifecycle.py b/app/core/opportunity_lifecycle.py index 124f672..8a12bd3 100644 --- a/app/core/opportunity_lifecycle.py +++ b/app/core/opportunity_lifecycle.py @@ -25,6 +25,8 @@ DEFAULT_ENTRY_GATE = { "low_plan_min_static_count": 3, "low_plan_min_top_long_pct": 55, "max_wait_pullback_deviation_pct": 12, + "min_entry_score_buy_now": 3, + "min_entry_score_wait_pullback": 1, } @@ -311,6 +313,9 @@ def apply_entry_quality_gate( invalid_plan_geometry = True reasons.append("多头计划无效:TP1不高于计划入场价,转为观察") entry_action = str(entry_plan.get("entry_action") or "").strip() + score_components = normalize_json_object(entry_plan.get("score_components")) + has_entry_score = "entry_score" in score_components + entry_score = to_float(score_components.get("entry_score")) if has_entry_score else None opportunity_level = str(entry_plan.get("opportunity_level") or "").strip() level_meta = OPPORTUNITY_LEVELS.get(opportunity_level, {}) level_max_action = level_meta.get("max_action") or str(entry_plan.get("max_action") or "").strip() @@ -346,6 +351,8 @@ def apply_entry_quality_gate( reasons.append(f"rr1={rr1} < {_cfg_value(cfg, 'min_rr_buy_now')},禁止现价买入") if action_status == "可即刻买入": + if has_entry_score and entry_score < _cfg_value(cfg, "min_entry_score_buy_now"): + reasons.append(f"买点分{entry_score:.1f}低于现价买入门槛{_cfg_value(cfg, 'min_entry_score_buy_now')},禁止立即买入") if level_max_action == "observe": reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许{level_meta.get('label') and ('观察/等待') or '观察/等待'},禁止现价买入") elif level_max_action == "wait_pullback" and not current_entry_trigger: @@ -369,6 +376,9 @@ def apply_entry_quality_gate( reasons.append(f"24h涨幅{change_24h:.1f}%且rr1不足,禁止追涨") if action_status == "等回踩" and current_price > 0: + if has_entry_score and entry_score < _cfg_value(cfg, "min_entry_score_wait_pullback"): + target_action = "观察" + reasons.append(f"买点分{entry_score:.1f}低于挂单门槛{_cfg_value(cfg, 'min_entry_score_wait_pullback')},不生成回踩挂单") if plan_entry_price > 0: wait_deviation_pct = round((current_price / plan_entry_price - 1) * 100, 2) entry_plan["wait_pullback_deviation_pct"] = wait_deviation_pct @@ -419,6 +429,9 @@ def apply_entry_quality_gate( elif action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and current_price <= to_float(entry_plan.get("entry_price")) * 1.003 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): target_action = "观察" reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察") + elif has_entry_score and action_status == "可即刻买入" and entry_score < _cfg_value(cfg, "min_entry_score_wait_pullback"): + target_action = "观察" + reasons.append("买点分不足以进入挂单池,转为观察") elif action_status == "可即刻买入" and current_price > 0 and stop_loss > 0 and tp1 > stop_loss and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): rr_target_entry = calc_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now")) if rr_target_entry > stop_loss and rr_target_entry < current_price: @@ -456,4 +469,8 @@ def apply_entry_quality_gate( "breakout_distance_pct": breakout_distance, "change_24h": change_24h, } + if has_entry_score: + entry_plan["entry_quality_gate"]["entry_score"] = entry_score + entry_plan["entry_quality_gate"]["min_entry_score_buy_now"] = _cfg_value(cfg, "min_entry_score_buy_now") + entry_plan["entry_quality_gate"]["min_entry_score_wait_pullback"] = _cfg_value(cfg, "min_entry_score_wait_pullback") return target_action, entry_plan, reasons diff --git a/app/db/review_center.py b/app/db/review_center.py index d49aaba..7190b67 100644 --- a/app/db/review_center.py +++ b/app/db/review_center.py @@ -141,6 +141,7 @@ def _opportunity_review(conn, since): def _paper_review(conn, since, days): summary = get_paper_trading_summary(days=days) trade_attribution = (get_strategy_insights().get("trade_attribution") or {}) + watch_order_attribution = (get_strategy_insights().get("watch_order_attribution") or {}) trades = [dict(r) for r in conn.execute( """ SELECT * @@ -169,6 +170,7 @@ def _paper_review(conn, since, days): "exit_reasons": exit_reasons, "event_types": event_types, "trade_attribution": trade_attribution, + "watch_order_attribution": watch_order_attribution, "recent_trades": trades, "recent_events": events, } diff --git a/app/db/strategy_insights.py b/app/db/strategy_insights.py index 1701aff..3d876b7 100644 --- a/app/db/strategy_insights.py +++ b/app/db/strategy_insights.py @@ -122,9 +122,15 @@ def get_strategy_insights(): trade_env_map = {} trade_evidence_map = {} trade_version_map = {} + trade_factor_group_map = {} + trade_regime_map = {} + trade_score_band_map = {} + watch_map = {} + order_map = {} for item in items: labels = safe_list_json(item.get("signal_labels_json")) or safe_list_json(item.get("signals")) codes = safe_list_json(item.get("signal_codes_json")) + ep = safe_dict_json(item.get("entry_plan_json")) for factor in labels: add_bucket(factor_map, str(factor).strip(), item) for code in codes: @@ -134,21 +140,38 @@ def get_strategy_insights(): elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_")): add_bucket(evidence_map, "链上:" + text, item) mc = safe_dict_json(item.get("market_context_json")) + factor_breakdown = safe_dict_json(mc.get("factor_score_breakdown")) or safe_dict_json(ep.get("factor_score_breakdown")) + score_components = safe_dict_json(mc.get("score_components")) or safe_dict_json(ep.get("score_components")) + market_regime = safe_dict_json(mc.get("market_regime")) or safe_dict_json(ep.get("market_regime")) + regime_name = market_regime.get("regime") or mc.get("market_regime") for key in ("btc_trend", "market_regime", "altcoin_regime", "sentiment"): if mc.get(key): add_bucket(env_map, f"{key}:{mc.get(key)}", item) + if regime_name: + add_bucket(env_map, f"regime:{regime_name}", item) for bucket in env_buckets_from_market_context(mc): add_bucket(env_map, bucket, item) if item.get("strategy_version"): add_bucket(version_map, str(item.get("strategy_version")).strip(), item) + if (item.get("execution_status") or "") in {"observe", "wait_pullback"} or (item.get("display_bucket") or "") == "watch_pool": + add_watch_bucket(watch_map, watch_bucket(item), item) + if item.get("paper_order_id"): + add_order_bucket(order_map, order_bucket(item), item) if item.get("paper_status") == "closed": for factor in labels: add_trade_bucket(trade_factor_map, str(factor).strip(), item) + for group in factor_groups_from_breakdown(factor_breakdown): + add_trade_bucket(trade_factor_group_map, group, item) add_trade_bucket(trade_entry_map, trade_entry_bucket(item), item) add_trade_bucket(trade_exit_map, item.get("paper_exit_reason") or "未记录退出原因", item) add_trade_bucket(trade_entry_map, f"方向:{item.get('paper_side') or item.get('side') or 'long'}", item) if item.get("paper_order_id"): add_trade_bucket(trade_entry_map, f"挂单路径:{item.get('paper_order_status') or 'filled'}", item) + add_trade_bucket(trade_score_band_map, score_band("机会分", score_components.get("opportunity_score")), item) + add_trade_bucket(trade_score_band_map, score_band("买点分", score_components.get("entry_score")), item) + add_trade_bucket(trade_score_band_map, score_band("风险分", score_components.get("risk_score")), item) + if regime_name: + add_trade_bucket(trade_regime_map, f"regime:{regime_name}", item) for bucket in env_buckets_from_market_context(mc): add_trade_bucket(trade_env_map, bucket, item) for code in codes: @@ -175,12 +198,20 @@ def get_strategy_insights(): "trade_attribution": { "definition": "交易级归因只统计已平仓策略交易,用 realized_pnl_usdt / realized_pnl_pct 衡量因子、入场路径、退出原因和环境的真实账本表现。", "factor": serialize_trade_buckets("factor", trade_factor_map)[:30], + "factor_group": serialize_trade_buckets("factor_group", trade_factor_group_map)[:20], "entry_path": serialize_trade_buckets("entry_path", trade_entry_map)[:20], "exit_reason": serialize_trade_buckets("exit_reason", trade_exit_map)[:20], + "market_regime": serialize_trade_buckets("market_regime", trade_regime_map)[:20], + "score_band": serialize_trade_buckets("score_band", trade_score_band_map)[:20], "market_environment": serialize_trade_buckets("environment", trade_env_map)[:20], "evidence": serialize_trade_buckets("evidence", trade_evidence_map)[:20], "strategy_version": serialize_trade_buckets("strategy_version", trade_version_map, sort_by_version=True)[:20], }, + "watch_order_attribution": { + "definition": "观察池和挂单池只评价机会是否推进,不计入交易收益;用于判断没买/等回踩是否合理。", + "watch_pool": serialize_watch_buckets("watch_bucket", watch_map)[:20], + "paper_orders": serialize_order_buckets("order_bucket", order_map)[:20], + }, } @@ -221,6 +252,122 @@ def trade_entry_bucket(item): return "入场:未标记" +def factor_groups_from_breakdown(breakdown): + groups = breakdown.get("groups") if isinstance(breakdown, dict) else {} + if isinstance(groups, dict): + return [str(k) for k, v in groups.items() if float((v or {}).get("score") or 0) != 0] + items = breakdown.get("items") if isinstance(breakdown, dict) else [] + result = [] + for item in items if isinstance(items, list) else []: + group = str((item or {}).get("factor_group") or "").strip() + if group: + result.append(group) + return sorted(set(result)) + + +def score_band(label, value): + try: + n = float(value) + except Exception: + return f"{label}:未知" + if n >= 8: + band = "高" + elif n >= 3: + band = "中" + elif n >= 0: + band = "低" + else: + band = "负" + return f"{label}:{band}({n:g})" + + +def watch_bucket(item): + status = str(item.get("execution_status") or item.get("display_bucket") or "watch").strip() + if status == "wait_pullback": + return "观察:等待回踩" + if status == "observe": + return "观察:普通观察" + return f"观察:{status or '未标记'}" + + +def order_bucket(item): + status = str(item.get("paper_order_status") or "unknown").strip() + source = str(item.get("paper_source_status") or item.get("execution_status") or "").strip() + if status == "filled": + return "挂单:已成交" + if status == "canceled": + return "挂单:已取消" + if status == "pending": + return "挂单:等待中" + return f"挂单:{source or status}" + + +def add_watch_bucket(bucket_map, key, item): + if not key: + return + b = bucket_map.setdefault(key, { + "opportunity_count": 0, + "executed_count": 0, + "order_count": 0, + "invalid_count": 0, + }) + b["opportunity_count"] += 1 + if item.get("paper_trade_id"): + b["executed_count"] += 1 + if item.get("paper_order_id"): + b["order_count"] += 1 + if (item.get("execution_status") or "") == "invalid" or (item.get("status") or "") in {"expired", "invalid", "archived"}: + b["invalid_count"] += 1 + + +def add_order_bucket(bucket_map, key, item): + if not key: + return + b = bucket_map.setdefault(key, { + "order_count": 0, + "filled_count": 0, + "canceled_count": 0, + "trade_count": 0, + }) + status = str(item.get("paper_order_status") or "") + b["order_count"] += 1 + if status == "filled": + b["filled_count"] += 1 + if status == "canceled": + b["canceled_count"] += 1 + if item.get("paper_trade_id"): + b["trade_count"] += 1 + + +def serialize_watch_buckets(name_key, bucket_map): + rows = [] + for key, bucket in bucket_map.items(): + total = bucket["opportunity_count"] + rows.append({ + name_key: key, + **bucket, + "executed_pct": round(bucket["executed_count"] / total * 100, 1) if total else 0, + "order_pct": round(bucket["order_count"] / total * 100, 1) if total else 0, + "invalid_pct": round(bucket["invalid_count"] / total * 100, 1) if total else 0, + }) + rows.sort(key=lambda x: (-x["opportunity_count"], x[name_key])) + return rows + + +def serialize_order_buckets(name_key, bucket_map): + rows = [] + for key, bucket in bucket_map.items(): + total = bucket["order_count"] + rows.append({ + name_key: key, + **bucket, + "fill_pct": round(bucket["filled_count"] / total * 100, 1) if total else 0, + "cancel_pct": round(bucket["canceled_count"] / total * 100, 1) if total else 0, + }) + rows.sort(key=lambda x: (-x["order_count"], x[name_key])) + return rows + + def env_buckets_from_market_context(mc): """Convert market_context_json numeric fields into attribution buckets.""" buckets = [] diff --git a/docs/OPTIMIZATION_TODO.md b/docs/OPTIMIZATION_TODO.md index 19668be..2b3f397 100644 --- a/docs/OPTIMIZATION_TODO.md +++ b/docs/OPTIMIZATION_TODO.md @@ -45,8 +45,9 @@ 待增强: -- 风控结果写入返回结果和操作日志,方便复盘。 -- 引入更丰富的组合风险:相关性、同板块集中度、同方向拥挤度。 +- 已把风控结果写入开仓事件返回与操作日志,方便复盘。 +- 已加入同板块集中度、同方向拥挤度门禁。 +- 后续可继续做更细的相关性矩阵和板块 Beta 暴露。 ### 2. Market Regime Engine @@ -83,8 +84,8 @@ 待增强: -- 让分组上限按 market regime 动态调整。 -- 做因子组级别交易归因,确认哪些组在不同市场下真正贡献收益。 +- 已做因子组级别交易归因,能在策略归因和复盘中心查看。 +- 后续让分组上限按 market regime 动态调整。 ### 4. Entry Quality Score @@ -97,7 +98,8 @@ 待增强: -- 把三分制进一步接入 `apply_entry_quality_gate`,明确用 `entryScore` 决定 `buy_now` / `wait_pullback` / `watch`。 +- 已把三分制接入 `apply_entry_quality_gate`:`entry_score` 不足时禁止 `buy_now`,过低时不进入挂单池,转观察。 +- 后续根据线上样本校准 `min_entry_score_buy_now` / `min_entry_score_wait_pullback`。 ### 5. 完整结构化决策日志 @@ -138,8 +140,8 @@ ## 下一步执行建议 -1. 部署运行一段时间,观察 `market_regime`、`score_components`、`factor_score_breakdown.groups` 是否能解释真实回撤。 -2. 做 Paper Trade Attribution,把收益按因子组和 regime 归因。 -3. 把三分制进一步接入买点质量闸门,明确高机会分但低买点分时只能挂单或观察。 +1. 部署运行一段时间,观察 `market_regime`、`score_components`、`factor_score_breakdown.groups`、观察/挂单推进率是否能解释真实回撤。 +2. 按线上样本校准 `entry_score` 门槛、group cap 和 high-risk 门槛。 +3. 做 Regime-based Scoring,让不同市场环境使用不同因子权重和分组上限。 4. 如果 JSON 决策日志查询不方便,再新增 `strategy_decision_log` 表和页面。 -5. 按线上样本继续调整 group cap 和 high-risk 门槛。 +5. 后续再接入 BTC Dominance、TOTAL3、稳定币净流,增强 Market Regime Engine。 diff --git a/rules.yaml b/rules.yaml index 003a68f..3ce5647 100644 --- a/rules.yaml +++ b/rules.yaml @@ -161,6 +161,8 @@ confirm: low_plan_max_gain_24h_pct: 8 low_plan_min_static_count: 3 low_plan_min_top_long_pct: 55 + min_entry_score_buy_now: 3 + min_entry_score_wait_pullback: 1 note: v1.7.5机会生命周期+买点硬闸门;低位静K蓄力生成潜伏计划,高位确认/rr不合格/risk_reward_ok=false不得显示可即刻买入 atr_multiplier: entry_offset: 0.5 diff --git a/static/review_center.html b/static/review_center.html index 9b0637e..2eb57c9 100644 --- a/static/review_center.html +++ b/static/review_center.html @@ -56,7 +56,7 @@ function rows(items,label,value,sub){items=items||[];if(!items.length)return '暂无动作';return items.slice(0,4).map(function(x){return '
'+esc(label(x))+''+esc(sub?sub(x):'')+'
'}).join('')} function renderStrategyDigest(d){var it=d.iteration||{},dig=it.digest||{},latest=dig.latest||{},m=latest.metrics||{},decision=latest.decision||'hold';var badgeCls=decision==='release'?'ok':decision==='gray'?'warn':'warn';$('strategyDigest').innerHTML='
策略迭代摘要
'+esc(latest.title||'暂无复盘')+' · '+time(latest.time)+' · 版本 '+esc(latest.strategy_version||'--')+'
'+esc(decision)+'
'+[['因子生效调整',m.factor_weight_updates||0,'blue'],['候选 / 灰度',(it.summary&&it.summary.candidate_count||0)+' / '+(it.summary&&it.summary.gray_count||0),''],['本轮有效复盘',m.effective_review_count||0,''],['发布状态',latest.reason||'继续观察','']].map(function(x){return '
'+esc(x[0])+''+esc(x[1])+'
'}).join('')+'

升权了什么

'+digestItems(dig.upgraded,function(x){return x.signal||'--'},function(x){return '权重 '+x.old_weight+' → '+x.new_weight+' · 样本 '+(x.sample_size||0)+' · 命中 '+(x.hit_rate||0)+'%'})+'

降权 / 淘汰

'+digestItems(dig.downgraded,function(x){return x.signal||'--'},function(x){return (x.action||'调整')+' · '+(x.old_weight!=null?'权重 '+x.old_weight+' → '+x.new_weight:x.detail||'')})+'

灰度观察

'+digestItems(dig.gray,function(x){return x.signal||('候选 #'+x.id)},function(x){return '样本 '+(x.sample_size||0)+' · 置信 '+(x.confidence||0)+'% · '+(x.description||'')})+'

最近迭代

'+digestItems(dig.timeline,function(x){return time(x.time)+' · '+(x.decision||'hold')},function(x){return x.reason||x.title||''})+'
'} function renderOpportunity(o){var s=o.summary||{};$('oppDef').textContent=o.definition||'';$('opportunityPanel').innerHTML=kpis([['机会样本',s.total_opportunities||0,'blue'],['可买/等回踩',(s.buy_now_count||0)+' / '+(s.wait_pullback_count||0),''],['策略执行',s.paper_executed_count||0,'green'],['漏选爆发',s.missed_explosion_count||0,'red'],['有效复盘',s.effective_review_count||0,''],['机会命中',num(s.opportunity_hit_rate,1)+'%','green'],['观察样本',s.observe_count||0,''],['失效样本',s.invalid_count||0,'red']])+'
状态分布
'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'
复盘结果
'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'
'} -function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},tf=ta.factor||[],te=ta.entry_path||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['开仓/平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'
退出原因
'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'
执行事件
'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'
真实交易因子
'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'
入场路径表现
'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'
'} +function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},wo=p.watch_order_attribution||{},tf=ta.factor||[],te=ta.entry_path||[],tg=ta.factor_group||[],wr=wo.watch_pool||[],orows=wo.paper_orders||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['开仓/平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'
退出原因
'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'
执行事件
'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'
真实交易因子
'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'
因子组表现
'+rows(tg.slice(0,6),function(x){return x.factor_group||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'
入场路径表现
'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'
观察/挂单推进
'+rows(wr.concat(orows).slice(0,6),function(x){return x.watch_bucket||x.order_bucket||'--'},function(x){return (x.executed_pct!=null?num(x.executed_pct,1):num(x.fill_pct,1))+'%'},function(x){return '样本 '+(x.opportunity_count||x.order_count||0)+' · 执行/成交 '+(x.executed_count||x.filled_count||0)})+'
'} function renderEvidence(e){var s=e.summary||{};$('evidenceDef').textContent=e.definition||'';$('evidencePanel').innerHTML=kpis([['新闻事件',s.news_count||0,'blue'],['有效舆情',s.actionable_news_count||0,''],['链上信号',s.onchain_signal_count||0,'blue'],['高置信链上',s.high_confidence_onchain_count||0,'green'],['原始链上',s.raw_onchain_count||0,''],['已映射原始',s.mapped_raw_onchain_count||0,'green'],['LLM 调用',s.llm_runs||0,''],['LLM 成功',s.llm_success_count||0,'green']])+'
链上信号
'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'
舆情决策
'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'
'} function renderIteration(i){var s=i.summary||{};$('iterationDef').textContent=i.definition||'';$('iterationPanel').innerHTML=kpis([['迭代记录',s.iteration_count||0,'blue'],['候选规则',s.candidate_count||0,''],['灰度规则',s.gray_count||0,'green'],['生效规则',s.active_count||0,'green']])+'
最新发布结论:'+esc(s.latest_release_decision||'hold')+'
'+esc(s.latest_release_reason||'暂无发布说明')+'
闸门
发布决策
'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'
候选状态
'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'
'} function renderRecent(d){var opp=(d.opportunity&&d.opportunity.missed_explosions)||[], trades=(d.paper_trading&&d.paper_trading.recent_trades)||[], news=(d.evidence&&d.evidence.recent_news)||[], chain=(d.evidence&&d.evidence.recent_onchain)||[];$('recentPanel').innerHTML='
最近策略交易
'+rows(trades.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.status||'--')},function(x){return x.status==='closed'?pct(x.realized_pnl_pct):pct(x.pnl_pct)},function(x){return time(x.opened_at)+' · '+(x.exit_reason||x.source_status||'')})+'
漏选爆发
'+rows(opp.slice(0,8),function(x){return x.symbol||'--'},function(x){return pct(x.gain_pct)},function(x){return time(x.detect_time)+' · '+(x.reason_missed||'')})+'
舆情事件
'+rows(news.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.title||'--')},function(x){return x.importance||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.decision||'未处理')})+'
链上信号
'+rows(chain.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.signal_label||x.signal_code||'--')},function(x){return x.severity||x.confidence||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.direction||'')})+'
'} diff --git a/static/strategy.html b/static/strategy.html index 72271fb..99a262f 100644 --- a/static/strategy.html +++ b/static/strategy.html @@ -37,7 +37,8 @@ function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){retur function pct(x){return Number(x||0).toFixed(1)+'%'}function usd(x){x=Number(x||0);return (x>=0?'+':'-')+'$'+Math.abs(x).toFixed(2)} function table(rows,key){if(!rows||!rows.length)return'
暂无数据
';return '
'+rows.map(function(r){return ''}).join('')+'
归因项机会可执行现买策略执行执行转化策略胜率策略已实现
'+esc(r[key]||'--')+''+esc(r.opportunity_count||0)+''+esc(r.actionable_count||0)+''+esc(r.buy_now_count||0)+''+esc(r.paper_trade_count||0)+''+pct(r.actionable_conversion_pct)+''+pct(r.paper_win_rate_pct)+''+usd(r.paper_realized_pnl_usdt)+'
'} function tradeTable(title,rows,key){if(!rows||!rows.length)return '
'+esc(title)+'
暂无已平仓交易样本
';return '
'+esc(title)+'
'+rows.map(function(r){return ''}).join('')+'
归因项平仓交易胜率已实现收益平均收益率最好最差
'+esc(r[key]||'--')+''+esc(r.closed_trade_count||0)+''+pct(r.win_rate_pct)+''+usd(r.realized_pnl_usdt)+''+pct(r.avg_realized_pnl_pct)+''+pct(r.best_pnl_pct)+''+pct(r.worst_pnl_pct)+'
'} -function renderTradeAttribution(t){t=t||{};tradeDef.textContent=t.definition||'交易级归因只统计已平仓策略交易。';tradePerf.innerHTML=tradeTable('因子真实交易表现',t.factor,'factor')+tradeTable('入场路径与方向',t.entry_path,'entry_path')+tradeTable('退出原因',t.exit_reason,'exit_reason')+tradeTable('市场环境',t.market_environment,'environment')} -async function load(){try{var d=await (await fetch('/api/strategy/insights?_ts='+Date.now(),{cache:'no-store'})).json();var o=d.overview||{};definition.textContent=o.definition||'策略归因只看机会转化和策略交易转化。';metrics.innerHTML=[['机会样本',o.total_opportunities||0,'blue'],['可执行机会',o.actionable_count||0,''],['现在可买',o.buy_now_count||0,'green'],['策略执行',o.paper_trade_count||0,'green'],['策略胜率',pct(o.paper_win_rate_pct),''],['已实现收益',usd(o.paper_realized_pnl_usdt),Number(o.paper_realized_pnl_usdt||0)>=0?'green':'red']].map(function(x){return '
'+esc(x[0])+''+esc(x[1])+'
'}).join('');factorPerf.innerHTML=table(d.factor_attribution,'factor');envPerf.innerHTML=table(d.market_environment,'environment');evidencePerf.innerHTML=table(d.evidence_attribution,'evidence');versionPerf.innerHTML=table(d.version_performance,'strategy_version');renderTradeAttribution(d.trade_attribution)}catch(e){metrics.innerHTML='
加载失败
'}}load(); +function watchTable(title,rows,key){if(!rows||!rows.length)return '
'+esc(title)+'
暂无观察/挂单样本
';return '
'+esc(title)+'
'+rows.map(function(r){var isOrder=key==='order_bucket';return ''}).join('')+'
归因项样本执行/成交挂单/取消转化率
'+esc(r[key]||'--')+''+esc(r.opportunity_count||r.order_count||0)+''+esc(isOrder?(r.filled_count||0):(r.executed_count||0))+''+esc(isOrder?(r.canceled_count||0):(r.order_count||0))+''+pct(isOrder?r.fill_pct:r.executed_pct)+'
'} +function renderTradeAttribution(t,w){t=t||{};w=w||{};tradeDef.textContent=t.definition||'交易级归因只统计已平仓策略交易。';tradePerf.innerHTML=tradeTable('因子真实交易表现',t.factor,'factor')+tradeTable('因子组表现',t.factor_group,'factor_group')+tradeTable('市场状态表现',t.market_regime,'market_regime')+tradeTable('评分分层表现',t.score_band,'score_band')+tradeTable('入场路径与方向',t.entry_path,'entry_path')+tradeTable('退出原因',t.exit_reason,'exit_reason')+tradeTable('市场环境',t.market_environment,'environment')+watchTable('观察池推进表现',w.watch_pool,'watch_bucket')+watchTable('挂单池成交/取消表现',w.paper_orders,'order_bucket')} +async function load(){try{var d=await (await fetch('/api/strategy/insights?_ts='+Date.now(),{cache:'no-store'})).json();var o=d.overview||{};definition.textContent=o.definition||'策略归因只看机会转化和策略交易转化。';metrics.innerHTML=[['机会样本',o.total_opportunities||0,'blue'],['可执行机会',o.actionable_count||0,''],['现在可买',o.buy_now_count||0,'green'],['策略执行',o.paper_trade_count||0,'green'],['策略胜率',pct(o.paper_win_rate_pct),''],['已实现收益',usd(o.paper_realized_pnl_usdt),Number(o.paper_realized_pnl_usdt||0)>=0?'green':'red']].map(function(x){return '
'+esc(x[0])+''+esc(x[1])+'
'}).join('');factorPerf.innerHTML=table(d.factor_attribution,'factor');envPerf.innerHTML=table(d.market_environment,'environment');evidencePerf.innerHTML=table(d.evidence_attribution,'evidence');versionPerf.innerHTML=table(d.version_performance,'strategy_version');renderTradeAttribution(d.trade_attribution,d.watch_order_attribution)}catch(e){metrics.innerHTML='
加载失败
'}}load(); {% endblock %} diff --git a/tests/test_market_regime.py b/tests/test_market_regime.py index 9b72441..b9487d8 100644 --- a/tests/test_market_regime.py +++ b/tests/test_market_regime.py @@ -1,4 +1,5 @@ from app.core.market_regime import classify_market_regime +from app.core.global_risk import evaluate_global_risk def _overview(**overrides): @@ -45,3 +46,35 @@ def test_market_regime_detects_altcoin_rotation(): assert result["regime"] == "altcoin_rotation" assert result["risk_level"] == "medium" + + +def test_global_risk_blocks_same_sector_concentration(pg_conn, monkeypatch): + monkeypatch.setattr( + "app.core.global_risk.get_crypto_market_overview", + lambda allow_live_fallback=False: _overview(), + ) + pg_conn.execute( + """ + INSERT INTO paper_trades ( + recommendation_id, symbol, side, status, opened_at, entry_price, qty, + notional_usdt, current_price, pnl_pct, created_at, updated_at + ) VALUES + (1, 'DOGE/USDT', 'long', 'open', '2026-05-23T10:00:00', 1, 100, 1000, 1, 0, '2026-05-23T10:00:00', '2026-05-23T10:00:00'), + (2, 'SHIB/USDT', 'long', 'open', '2026-05-23T10:00:00', 1, 100, 1000, 1, 0, '2026-05-23T10:00:00', '2026-05-23T10:00:00') + """ + ) + pg_conn.commit() + + result = evaluate_global_risk( + conn=pg_conn, + config={ + "account_equity_usdt": 20000, + "global_risk_max_same_sector_positions": 2, + "global_risk_max_same_direction_positions": 10, + }, + rec={"symbol": "PEPE/USDT", "sector": "MEME"}, + additional_notional=1000, + ) + + assert result["allow_new_entries"] is False + assert result["decision"] == "block_same_sector_concentration" diff --git a/tests/test_opportunity_lifecycle.py b/tests/test_opportunity_lifecycle.py index 19ee15c..6ea40bc 100644 --- a/tests/test_opportunity_lifecycle.py +++ b/tests/test_opportunity_lifecycle.py @@ -52,6 +52,31 @@ def test_buy_now_with_bad_rr_sets_real_pullback_price(): assert any('现价不买' in r for r in reasons) +def test_low_entry_score_blocks_buy_now_and_weak_pullback(): + action, plan, reasons = apply_entry_quality_gate( + action_status='可即刻买入', + entry_plan={ + 'entry_action': '可即刻买入', + 'entry_price': 1.0, + 'current_price': 1.0, + 'stop_loss': 0.95, + 'tp1': 1.12, + 'risk_reward_ok': True, + 'rr1': 2.4, + 'entry_trigger_confirmed': True, + 'score_components': {'opportunity_score': 12, 'entry_score': 0, 'risk_score': 1}, + }, + signals=['当前15min即刻入场信号'], + current_price=1.0, + market_context={'change_24h': 2.0}, + cfg={'min_entry_score_buy_now': 3, 'min_entry_score_wait_pullback': 1}, + ) + + assert action == '观察' + assert any('买点分' in r for r in reasons) + assert plan['entry_quality_gate']['entry_score'] == 0 + + def test_buy_now_requires_current_trigger_and_no_bearish_flow_risk(): action, plan, reasons = apply_entry_quality_gate( action_status='可即刻买入', diff --git a/tests/test_review_center.py b/tests/test_review_center.py index 25fea46..75a9934 100644 --- a/tests/test_review_center.py +++ b/tests/test_review_center.py @@ -83,6 +83,7 @@ def test_review_center_separates_opportunity_and_paper_pnl(pg_conn): assert data["paper_trading"]["summary"]["closed_count"] == 1 assert data["paper_trading"]["summary"]["realized_pnl_usdt"] == 490 assert data["paper_trading"]["trade_attribution"]["entry_path"][0]["closed_trade_count"] == 1 + assert "watch_order_attribution" in data["paper_trading"] def test_review_center_iteration_digest_summarizes_actions(pg_conn):