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 ''}).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)+'
'} -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 '
'+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(); {% 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"] == "前瞻信号不足"