diff --git a/app/db/review_basic_queries.py b/app/db/review_basic_queries.py
index b025bc0..0597f4d 100644
--- a/app/db/review_basic_queries.py
+++ b/app/db/review_basic_queries.py
@@ -25,10 +25,38 @@ def record_review(rec_id, symbol, outcome, pnl_48h, max_pnl_48h,
conn.close()
-def update_signal_performance(signal_type, category, is_hit, pnl):
- """Update rolling signal performance stats after review."""
+def update_signal_performance(signal_type, category, is_hit, pnl, weight_override=None):
+ """Update rolling signal performance stats after review.
+
+ ``weight_override`` is used by the daily review governance step to make
+ reviewed factor weights actually affect the next screening run.
+ """
conn = get_conn()
row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=%s", (signal_type,)).fetchone()
+ if weight_override is not None:
+ weight = max(0.0, float(weight_override or 0))
+ if row:
+ conn.execute(
+ """
+ UPDATE signal_performance
+ SET weight=%s, last_updated=%s
+ WHERE signal_type=%s
+ """,
+ (weight, datetime.now().isoformat(), signal_type),
+ )
+ else:
+ conn.execute(
+ """
+ INSERT INTO signal_performance (
+ signal_type, category, total_count, hit_count, miss_count,
+ hit_rate, avg_pnl, weight, last_updated
+ ) VALUES (%s, %s, 0, 0, 0, 0, 0, %s, %s)
+ """,
+ (signal_type, category or "", weight, datetime.now().isoformat()),
+ )
+ conn.commit()
+ conn.close()
+ return
if row:
total = row["total_count"] + 1
diff --git a/app/db/review_center.py b/app/db/review_center.py
index 76b3bdb..d49aaba 100644
--- a/app/db/review_center.py
+++ b/app/db/review_center.py
@@ -13,6 +13,7 @@ from datetime import datetime, timedelta
from app.db.paper_trading import get_paper_trading_summary
from app.db.schema import get_conn
+from app.db.strategy_insights import get_strategy_insights
def _safe_int(value, default=0):
@@ -139,6 +140,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 {})
trades = [dict(r) for r in conn.execute(
"""
SELECT *
@@ -166,6 +168,7 @@ def _paper_review(conn, since, days):
"summary": summary,
"exit_reasons": exit_reasons,
"event_types": event_types,
+ "trade_attribution": trade_attribution,
"recent_trades": trades,
"recent_events": events,
}
@@ -269,9 +272,11 @@ def _iteration_review(conn, since):
("findings_json", []),
("problems_json", []),
("actions_json", []),
+ ("changed_rules_json", []),
("candidate_rules_json", []),
):
item[field.replace("_json", "")] = _loads(item.get(field), fallback)
+ digest = _iteration_digest(logs, candidates)
return {
"definition": "策略迭代只产生候选假设和发布闸门结论,不直接等于收益提升。",
"summary": {
@@ -284,11 +289,93 @@ def _iteration_review(conn, since):
},
"release_decisions": _bucket_count(logs, "release_decision", "unknown"),
"candidate_status": _bucket_count(candidates, "status", "candidate"),
+ "digest": digest,
"recent_logs": logs,
"recent_candidates": candidates[:12],
}
+def _iteration_digest(logs, candidates):
+ latest = logs[0] if logs else {}
+ upgraded = []
+ downgraded = []
+ released = []
+ reviewed = []
+ for log in logs[:8]:
+ for rule in log.get("changed_rules") or []:
+ if not isinstance(rule, dict):
+ continue
+ rtype = rule.get("type") or ""
+ action = rule.get("action") or ""
+ signal = rule.get("signal") or rule.get("signal_name") or ""
+ if rtype == "factor_weight_governance":
+ item = {
+ "time": log.get("created_at") or "",
+ "signal": signal,
+ "action": action,
+ "old_weight": rule.get("old_weight"),
+ "new_weight": rule.get("new_weight"),
+ "sample_size": rule.get("sample_size"),
+ "hit_rate": rule.get("hit_rate"),
+ "avg_pnl": rule.get("avg_pnl"),
+ }
+ if action == "升权":
+ upgraded.append(item)
+ else:
+ downgraded.append(item)
+ elif rtype == "signal_deprecation":
+ downgraded.append({
+ "time": log.get("created_at") or "",
+ "signal": signal or "低绩效因子",
+ "action": "淘汰/降权候选",
+ "detail": rule.get("detail") or "",
+ })
+ elif rtype == "candidate_release":
+ released.append({
+ "time": log.get("created_at") or "",
+ "candidate_id": rule.get("candidate_id"),
+ "rule_id": rule.get("rule_id"),
+ "description": rule.get("description") or "",
+ "version": rule.get("new_version") or log.get("strategy_version") or "",
+ })
+ reviewed.append({
+ "time": log.get("created_at") or "",
+ "title": log.get("title") or "",
+ "decision": log.get("release_decision") or "hold",
+ "reason": log.get("release_reason") or log.get("summary") or "",
+ "metrics": log.get("metrics") or {},
+ })
+ gray = [
+ {
+ "id": c.get("id"),
+ "signal": c.get("signal_name") or "",
+ "type": c.get("rule_type") or "",
+ "description": c.get("rule_description") or "",
+ "sample_size": c.get("sample_size") or 0,
+ "confidence": c.get("confidence_score") or 0,
+ }
+ for c in candidates
+ if c.get("status") == "gray"
+ ]
+ active = [c for c in candidates if c.get("status") == "active"]
+ return {
+ "latest": {
+ "time": latest.get("created_at") or "",
+ "title": latest.get("title") or "暂无策略迭代",
+ "decision": latest.get("release_decision") or "hold",
+ "reason": latest.get("release_reason") or "",
+ "strategy_version": latest.get("strategy_version") or "",
+ "metrics": latest.get("metrics") or {},
+ },
+ "upgraded": upgraded[:8],
+ "downgraded": downgraded[:8],
+ "gray": gray[:8],
+ "released": released[:8],
+ "active_count": len(active),
+ "timeline": reviewed[:6],
+ }
+
+
def get_review_center_dashboard(days=30):
days = max(1, min(_safe_int(days, 30), 365))
since = _since(days)
diff --git a/app/db/strategy_insights.py b/app/db/strategy_insights.py
index c607da9..1701aff 100644
--- a/app/db/strategy_insights.py
+++ b/app/db/strategy_insights.py
@@ -44,12 +44,18 @@ def get_strategy_insights():
r.*,
pt.id AS paper_trade_id,
pt.status AS paper_status,
+ pt.side AS paper_side,
+ pt.source_status AS paper_source_status,
+ pt.source_action AS paper_source_action,
pt.realized_pnl_pct AS paper_realized_pnl_pct,
pt.realized_pnl_usdt AS paper_realized_pnl_usdt,
pt.pnl_pct AS paper_pnl_pct,
- pt.exit_reason AS paper_exit_reason
+ pt.exit_reason AS paper_exit_reason,
+ po.id AS paper_order_id,
+ po.status AS paper_order_status
FROM recommendation r
LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id
+ LEFT JOIN paper_orders po ON po.recommendation_id = r.id
ORDER BY r.rec_time DESC, r.id DESC
"""
).fetchall()
@@ -110,6 +116,12 @@ def get_strategy_insights():
env_map = {}
version_map = {}
evidence_map = {}
+ trade_factor_map = {}
+ trade_entry_map = {}
+ trade_exit_map = {}
+ trade_env_map = {}
+ trade_evidence_map = {}
+ trade_version_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"))
@@ -129,6 +141,24 @@ def get_strategy_insights():
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("paper_status") == "closed":
+ for factor in labels:
+ add_trade_bucket(trade_factor_map, str(factor).strip(), 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)
+ for bucket in env_buckets_from_market_context(mc):
+ add_trade_bucket(trade_env_map, bucket, item)
+ for code in codes:
+ text = str(code or "").strip()
+ if text.startswith(("sentiment_", "listing_", "ecosystem_")):
+ add_trade_bucket(trade_evidence_map, "舆情:" + text, item)
+ elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_", "onchain_")):
+ add_trade_bucket(trade_evidence_map, "链上:" + text, item)
+ if item.get("strategy_version"):
+ add_trade_bucket(trade_version_map, str(item.get("strategy_version")).strip(), item)
return {
"overview": overview,
@@ -142,9 +172,55 @@ def get_strategy_insights():
"market_environment": serialize_buckets("environment", env_map)[:20],
"evidence_attribution": serialize_buckets("evidence", evidence_map)[:20],
"version_performance": serialize_buckets("strategy_version", version_map, sort_by_version=True)[:20],
+ "trade_attribution": {
+ "definition": "交易级归因只统计已平仓策略交易,用 realized_pnl_usdt / realized_pnl_pct 衡量因子、入场路径、退出原因和环境的真实账本表现。",
+ "factor": serialize_trade_buckets("factor", trade_factor_map)[:30],
+ "entry_path": serialize_trade_buckets("entry_path", trade_entry_map)[:20],
+ "exit_reason": serialize_trade_buckets("exit_reason", trade_exit_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],
+ },
}
+def add_trade_bucket(bucket_map, key, item):
+ if not key:
+ return
+ b = bucket_map.setdefault(key, {
+ "closed_trade_count": 0,
+ "win_count": 0,
+ "loss_count": 0,
+ "realized_pnl_usdt": 0.0,
+ "pnl_pct_values": [],
+ "best_pnl_pct": None,
+ "worst_pnl_pct": None,
+ })
+ pnl_pct = float(item.get("paper_realized_pnl_pct") or 0)
+ pnl_usdt = float(item.get("paper_realized_pnl_usdt") or 0)
+ b["closed_trade_count"] += 1
+ b["realized_pnl_usdt"] += pnl_usdt
+ b["pnl_pct_values"].append(pnl_pct)
+ if pnl_pct > 0:
+ b["win_count"] += 1
+ elif pnl_pct < 0:
+ b["loss_count"] += 1
+ b["best_pnl_pct"] = pnl_pct if b["best_pnl_pct"] is None else max(b["best_pnl_pct"], pnl_pct)
+ b["worst_pnl_pct"] = pnl_pct if b["worst_pnl_pct"] is None else min(b["worst_pnl_pct"], pnl_pct)
+
+
+def trade_entry_bucket(item):
+ source = str(item.get("paper_source_status") or item.get("execution_status") or "").strip()
+ action = str(item.get("paper_source_action") or item.get("action_status") or "").strip()
+ if source == "wait_pullback" or action == "等回踩":
+ return "入场:回踩挂单成交"
+ if source == "buy_now" or action == "可即刻买入":
+ return "入场:现价确认"
+ if source:
+ return f"入场:{source}"
+ return "入场:未标记"
+
+
def env_buckets_from_market_context(mc):
"""Convert market_context_json numeric fields into attribution buckets."""
buckets = []
@@ -217,6 +293,29 @@ def serialize_buckets(name_key, bucket_map, sort_by_version=False):
return rows
+def serialize_trade_buckets(name_key, bucket_map, sort_by_version=False):
+ rows = []
+ for key, bucket in bucket_map.items():
+ pnl_values = bucket["pnl_pct_values"]
+ closed = bucket["closed_trade_count"]
+ rows.append({
+ name_key: key,
+ "closed_trade_count": closed,
+ "win_count": bucket["win_count"],
+ "loss_count": bucket["loss_count"],
+ "win_rate_pct": round(bucket["win_count"] / closed * 100, 1) if closed else 0,
+ "realized_pnl_usdt": round(bucket["realized_pnl_usdt"], 4),
+ "avg_realized_pnl_pct": round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else 0,
+ "best_pnl_pct": round(bucket["best_pnl_pct"] or 0, 2),
+ "worst_pnl_pct": round(bucket["worst_pnl_pct"] or 0, 2),
+ })
+ if sort_by_version:
+ rows.sort(key=lambda x: (version_sort_key(x[name_key]), x["closed_trade_count"]), reverse=True)
+ else:
+ rows.sort(key=lambda x: (-x["closed_trade_count"], -x["realized_pnl_usdt"], x[name_key]))
+ return rows
+
+
def version_sort_key(version: str):
text = str(version or '').strip()
if text.startswith('v') or text.startswith('V'):
diff --git a/app/services/review_engine.py b/app/services/review_engine.py
index e936abd..b4fe154 100644
--- a/app/services/review_engine.py
+++ b/app/services/review_engine.py
@@ -595,6 +595,61 @@ def adjust_signal_weights():
return adjustments
+def _apply_daily_factor_weight_governance():
+ """Promote good factors and suppress weak factors after each review run.
+
+ This is intentionally conservative: it only uses accumulated clean
+ ``signal_performance`` rows, respects minimum sample thresholds, and writes
+ the resulting weight through ``update_signal_weight`` so the screener reads
+ it on the next run.
+ """
+ thresholds = _get_thresholds()
+ weights = get_signal_weights()
+ min_samples = max(3, int(thresholds.get("min_samples_for_weight", 3) or 3))
+ kill_min_samples = max(min_samples, int(thresholds.get("kill_min_samples", 5) or 5))
+ kill_hit_rate = float(thresholds.get("hit_rate_kill_threshold", 0.10) or 0.10) * 100
+ warn_hit_rate = float((thresholds.get("signal_deprecation") or {}).get("hit_rate_warn_threshold", 0.20) or 0.20) * 100
+ category_base = thresholds.get("category_base_weights") or {"前瞻": 2.0, "PA": 1.5, "滞后": 0.5}
+ changes = []
+
+ for sig_type, data in sorted(weights.items()):
+ total = int(data.get("total_count") or 0)
+ if total < min_samples:
+ continue
+ hit_rate = float(data.get("hit_rate") or 0)
+ avg_pnl = float(data.get("avg_pnl") or 0)
+ old_weight = float(data.get("weight") or 0)
+ category = data.get("category") or "未知"
+ base = float(category_base.get(category, 1.0) or 1.0)
+ new_weight = old_weight
+ action = ""
+
+ if total >= kill_min_samples and hit_rate < kill_hit_rate:
+ new_weight = 0.0
+ action = "淘汰"
+ elif hit_rate < warn_hit_rate or avg_pnl <= -3:
+ new_weight = round(max(0.0, old_weight * 0.5), 3)
+ action = "降权"
+ elif hit_rate >= 55 and avg_pnl > 0:
+ target = round(min(4.0, max(old_weight, hit_rate / 50 * base)), 3)
+ if target > old_weight:
+ new_weight = target
+ action = "升权"
+
+ if action and abs(new_weight - old_weight) >= 0.001:
+ update_signal_weight(sig_type, new_weight)
+ changes.append({
+ "signal": sig_type,
+ "action": action,
+ "old_weight": round(old_weight, 4),
+ "new_weight": round(new_weight, 4),
+ "sample_size": total,
+ "hit_rate": round(hit_rate, 2),
+ "avg_pnl": round(avg_pnl, 2),
+ })
+ return changes
+
+
# ==================== 2.5 信号淘汰机制 ====================
def _deprecate_low_performance_signals():
@@ -1240,6 +1295,17 @@ def _build_iteration_log(results, current_meta, now, config_diff=None, effect_su
if len(version_change_parts) < 4:
version_change_parts.append(f"权重调整:{adj}")
+ for upd in results.get("factor_weight_updates", [])[:8]:
+ detail = (
+ f"{upd.get('signal')}: {upd.get('action')} "
+ f"{upd.get('old_weight')}→{upd.get('new_weight')} "
+ f"(样本{upd.get('sample_size')}, 命中{upd.get('hit_rate')}%, 均值{upd.get('avg_pnl')}%)"
+ )
+ actions.append(detail)
+ changed_rules.append({"type": "factor_weight_governance", **upd})
+ if len(version_change_parts) < 4:
+ version_change_parts.append(f"因子{upd.get('action')}:{upd.get('signal')}")
+
for rule in results.get("new_learned_rules", [])[:6]:
desc = rule.get("description", "")
if desc:
@@ -1320,6 +1386,7 @@ def _build_iteration_log(results, current_meta, now, config_diff=None, effect_su
"insufficient_tracking_count": insufficient_count,
"missed_explosions": len(results.get("missed_explosions", [])),
"weight_adjustments": len(results.get("weight_adjustments", [])),
+ "factor_weight_updates": len(results.get("factor_weight_updates", [])),
"signal_deprecations": len(results.get("signal_deprecations", [])),
"candidate_rules": len(results.get("candidate_rules", [])),
"new_learned_rules": 0,
@@ -1427,6 +1494,7 @@ def run_review(push_enabled: bool = True, compact: bool = False):
"reviews_done": 0,
"review_details": [],
"weight_adjustments": [],
+ "factor_weight_updates": [],
"signal_deprecations": [],
"missed_explosions": [],
"new_learned_rules": [],
@@ -1444,6 +1512,7 @@ def run_review(push_enabled: bool = True, compact: bool = False):
# 2. 信号权重调整
results["weight_adjustments"] = adjust_signal_weights()
+ results["factor_weight_updates"] = _apply_daily_factor_weight_governance()
# 2.5 信号淘汰机制(低命中率信号自动淘汰/降权)
results["signal_deprecations"] = _deprecate_low_performance_signals()
@@ -1487,6 +1556,7 @@ def run_review(push_enabled: bool = True, compact: bool = False):
f"爆发{hit_count} 横盘{flat_count} 失败{fail_count} | "
f"漏选爆发{len(results['missed_explosions'])}只 | "
f"权重调整{len(results['weight_adjustments'])}项 | "
+ f"因子生效调整{len(results['factor_weight_updates'])}项 | "
f"信号淘汰{len(results['signal_deprecations'])}项 | "
f"候选规则{len(results.get('candidate_rules', []))}条\n"
f"信号绩效排名: {', '.join(sig_summary[:5])}"
diff --git a/static/base.html b/static/base.html
index ed35f6f..4c377c9 100644
--- a/static/base.html
+++ b/static/base.html
@@ -187,8 +187,6 @@ a { color: inherit; text-decoration: none; }
-
-
diff --git a/static/review_center.html b/static/review_center.html
index e8cc1c9..9b0637e 100644
--- a/static/review_center.html
+++ b/static/review_center.html
@@ -2,7 +2,7 @@
{% block title %}复盘中心 — AlphaX Agent{% endblock %}
{% block extra_head_css %}
{% endblock %}
{% block content %}
@@ -22,6 +22,7 @@
刷新
+
@@ -52,13 +53,15 @@
var API='';function $(id){return document.getElementById(id)}function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}function num(v,d){return Number(v||0).toFixed(d==null?0:d)}function pct(v){return (Number(v||0)>=0?'+':'')+Number(v||0).toFixed(2)+'%'}function usd(v){v=Number(v||0);return (v>=0?'+':'-')+'$'+Math.abs(v).toFixed(2)}function time(t){if(!t)return '--';var d=new Date(t);if(isNaN(d.getTime()))return t;return (d.getMonth()+1)+'/'+d.getDate()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)}
function kpis(items){return ''+items.map(function(x){return '
'+esc(x[0])+' '+esc(x[1])+'
'}).join('')+'
'}
function rows(items,label,value,sub){items=items||[];if(!items.length)return '暂无数据
';return ''+items.map(function(x){return '
'+esc(label(x))+'
'+esc(sub?sub(x):'')+'
'+esc(value?value(x):'')+'
'}).join('')+'
'}
+function digestItems(items,label,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||{};$('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})+'
'}
+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 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||'')})+'
'}
-function render(d){$('principles').innerHTML=(d.principles||[]).map(function(x){return ''+esc(x)+'
'}).join('');renderOpportunity(d.opportunity||{});renderPaper(d.paper_trading||{});renderEvidence(d.evidence||{});renderIteration(d.iteration||{});renderRecent(d)}
-async function loadAll(){try{var days=$('daysSel').value;var d=await (await fetch(API+'/api/review-center/dashboard?days='+days+'&_ts='+Date.now(),{cache:'no-store'})).json();render(d)}catch(e){['principles','opportunityPanel','paperPanel','evidencePanel','iterationPanel','recentPanel'].forEach(function(id){$(id).innerHTML='加载失败
'})}}
+function render(d){renderStrategyDigest(d);$('principles').innerHTML=(d.principles||[]).map(function(x){return ''+esc(x)+'
'}).join('');renderOpportunity(d.opportunity||{});renderPaper(d.paper_trading||{});renderEvidence(d.evidence||{});renderIteration(d.iteration||{});renderRecent(d)}
+async function loadAll(){try{var days=$('daysSel').value;var d=await (await fetch(API+'/api/review-center/dashboard?days='+days+'&_ts='+Date.now(),{cache:'no-store'})).json();render(d)}catch(e){['strategyDigest','principles','opportunityPanel','paperPanel','evidencePanel','iterationPanel','recentPanel'].forEach(function(id){$(id).innerHTML='加载失败
'})}}
loadAll();
{% endblock %}
diff --git a/static/strategy.html b/static/strategy.html
index e600f67..72271fb 100644
--- a/static/strategy.html
+++ b/static/strategy.html
@@ -27,6 +27,7 @@
+ 交易级因子归因
只统计已平仓策略交易,用真实账本收益评价因子、入场路径、退出原因和环境。
{% endblock %}
@@ -35,6 +36,8 @@
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
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 ''+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)+' '}).join('')+'
'}
-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')}catch(e){metrics.innerHTML='加载失败
'}}load();
+function tradeTable(title,rows,key){if(!rows||!rows.length)return '';return ''+esc(title)+'
归因项 平仓交易 胜率 已实现收益 平均收益率 最好 最差 '+rows.map(function(r){return ''+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)+' '}).join('')+'
'}
+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();
{% endblock %}
diff --git a/tests/test_personalization_strategy_stage2_3.py b/tests/test_personalization_strategy_stage2_3.py
index cfc9df9..2ae275a 100644
--- a/tests/test_personalization_strategy_stage2_3.py
+++ b/tests/test_personalization_strategy_stage2_3.py
@@ -145,6 +145,15 @@ class PersonalizationAndStrategyInsightTests(unittest.TestCase):
self.assertEqual(factor['paper_trade_count'], 1)
self.assertAlmostEqual(factor['paper_realized_pnl_usdt'], 240)
self.assertNotIn('avg_pnl_pct', factor)
+ trade_factor = next(x for x in insight['trade_attribution']['factor'] if x['factor'] == '底部抬高')
+ self.assertEqual(trade_factor['closed_trade_count'], 1)
+ self.assertEqual(trade_factor['win_count'], 1)
+ self.assertEqual(trade_factor['win_rate_pct'], 100)
+ self.assertAlmostEqual(trade_factor['realized_pnl_usdt'], 240)
+ self.assertAlmostEqual(trade_factor['avg_realized_pnl_pct'], 5)
+ entry_path = next(x for x in insight['trade_attribution']['entry_path'] if x['entry_path'] == '入场:现价确认')
+ self.assertEqual(entry_path['closed_trade_count'], 1)
+ self.assertAlmostEqual(entry_path['realized_pnl_usdt'], 240)
env = next(x for x in insight['market_environment'] if x['environment'] == 'btc_trend:up')
self.assertEqual(env['opportunity_count'], 3)
diff --git a/tests/test_pipeline_runs_api.py b/tests/test_pipeline_runs_api.py
index 4b34c6c..3ffc948 100644
--- a/tests/test_pipeline_runs_api.py
+++ b/tests/test_pipeline_runs_api.py
@@ -335,17 +335,17 @@ def test_pipeline_page_nav_hides_watchlist_entry_and_watchlist_route_survives(te
assert watch_resp.status_code == 200
-def test_user_nav_keeps_research_pages_in_admin_section(temp_db):
+def test_user_nav_keeps_review_center_but_hides_legacy_research_pages(temp_db):
client = TestClient(web_server.app)
resp = client.get("/app")
assert resp.status_code == 200
html = resp.text
assert 'href="/watchlist"' not in html
- assert 'href="/pipeline" style="display:none"' in html
- assert 'href="/strategy" style="display:none"' in html
- assert 'href="/iteration" style="display:none"' in html
- assert "研发" in html
+ assert 'href="/logs" style="display:none"' in html
+ assert 'href="/review-center" style="display:none"' in html
+ assert 'href="/strategy"' not in html
+ assert 'href="/iteration"' not in html
def test_confirm_candidates_prefer_recent_fine_screened_state(temp_db):
diff --git a/tests/test_review_accuracy_pipeline.py b/tests/test_review_accuracy_pipeline.py
index 43adc8d..d14590b 100644
--- a/tests/test_review_accuracy_pipeline.py
+++ b/tests/test_review_accuracy_pipeline.py
@@ -156,6 +156,25 @@ def test_review_updates_signal_performance_by_code_not_label(temp_review_env):
assert "1H 量价齐飞K(量3.7x)" not in stats
+def test_daily_factor_weight_governance_promotes_and_eliminates(monkeypatch, temp_review_env):
+ changes = []
+ monkeypatch.setattr(review_engine, "update_signal_weight", lambda signal, weight: changes.append((signal, weight)))
+ monkeypatch.setattr(review_engine, "get_signal_weights", lambda: {
+ "good_factor": {"category": "前瞻", "total_count": 12, "hit_rate": 70, "avg_pnl": 4.2, "weight": 1.0},
+ "bad_factor": {"category": "前瞻", "total_count": 12, "hit_rate": 5, "avg_pnl": -2.0, "weight": 1.2},
+ "thin_factor": {"category": "PA", "total_count": 2, "hit_rate": 100, "avg_pnl": 6.0, "weight": 1.0},
+ })
+
+ result = review_engine._apply_daily_factor_weight_governance()
+
+ assert ("good_factor", 2.8) in changes
+ assert ("bad_factor", 0.0) in changes
+ assert all(signal != "thin_factor" for signal, _ in changes)
+ actions = {item["signal"]: item["action"] for item in result}
+ assert actions["good_factor"] == "升权"
+ assert actions["bad_factor"] == "淘汰"
+
+
def test_review_without_tracking_is_not_counted_as_signal_performance(temp_review_env):
db_path = str(temp_review_env)
rec_id = _insert_rec(db_path)
diff --git a/tests/test_review_center.py b/tests/test_review_center.py
index 58b5847..25fea46 100644
--- a/tests/test_review_center.py
+++ b/tests/test_review_center.py
@@ -1,4 +1,6 @@
from fastapi.testclient import TestClient
+import json
+from datetime import datetime
from app.db import auth_db
from app.db.review_center import get_review_center_dashboard
@@ -80,3 +82,69 @@ def test_review_center_separates_opportunity_and_paper_pnl(pg_conn):
assert "total_pnl_usdt" not in data["opportunity"]["summary"]
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
+
+
+def test_review_center_iteration_digest_summarizes_actions(pg_conn):
+ now = datetime.now().isoformat(timespec="seconds")
+ today = now[:10]
+ changed_rules = [
+ {
+ "type": "factor_weight_governance",
+ "signal": "breakout_pullback_d1",
+ "action": "升权",
+ "old_weight": 1.0,
+ "new_weight": 1.8,
+ "sample_size": 12,
+ "hit_rate": 70,
+ "avg_pnl": 4.2,
+ },
+ {
+ "type": "factor_weight_governance",
+ "signal": "unknown",
+ "action": "降权",
+ "old_weight": 0.3,
+ "new_weight": 0.15,
+ "sample_size": 13,
+ "hit_rate": 15.4,
+ "avg_pnl": 0.9,
+ },
+ ]
+ pg_conn.execute(
+ """
+ INSERT INTO strategy_iteration_log (
+ run_date, created_at, title, changed_rules_json, metrics_json,
+ release_decision, release_reason, strategy_version
+ ) VALUES (
+ %s, %s, '第1轮复盘迭代',
+ %s, %s, 'hold', '继续观察', 'v1.7.11'
+ )
+ """,
+ (
+ today,
+ now,
+ json.dumps(changed_rules, ensure_ascii=False),
+ json.dumps({"factor_weight_updates": 2, "effective_review_count": 3}, ensure_ascii=False),
+ ),
+ )
+ pg_conn.execute(
+ """
+ INSERT INTO strategy_rule_candidate (
+ created_at, source, rule_type, signal_name, rule_description,
+ sample_size, confidence_score, status
+ ) VALUES (
+ %s, 'dual_attribution_failure', 'penalty',
+ '前瞻信号不足', '失败模式进入灰度', 10, 95, 'gray'
+ )
+ """,
+ (now,),
+ )
+ pg_conn.commit()
+
+ data = get_review_center_dashboard(days=30)
+ digest = data["iteration"]["digest"]
+
+ assert digest["latest"]["metrics"]["factor_weight_updates"] == 2
+ assert digest["upgraded"][0]["signal"] == "breakout_pullback_d1"
+ assert digest["downgraded"][0]["signal"] == "unknown"
+ assert digest["gray"][0]["signal"] == "前瞻信号不足"