This commit is contained in:
aaron 2026-05-22 16:24:54 +08:00
parent c781dfef08
commit c8f59e3561
11 changed files with 399 additions and 15 deletions

View File

@ -25,10 +25,38 @@ def record_review(rec_id, symbol, outcome, pnl_48h, max_pnl_48h,
conn.close() conn.close()
def update_signal_performance(signal_type, category, is_hit, pnl): def update_signal_performance(signal_type, category, is_hit, pnl, weight_override=None):
"""Update rolling signal performance stats after review.""" """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() conn = get_conn()
row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=%s", (signal_type,)).fetchone() 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: if row:
total = row["total_count"] + 1 total = row["total_count"] + 1

View File

@ -13,6 +13,7 @@ from datetime import datetime, timedelta
from app.db.paper_trading import get_paper_trading_summary from app.db.paper_trading import get_paper_trading_summary
from app.db.schema import get_conn from app.db.schema import get_conn
from app.db.strategy_insights import get_strategy_insights
def _safe_int(value, default=0): def _safe_int(value, default=0):
@ -139,6 +140,7 @@ def _opportunity_review(conn, since):
def _paper_review(conn, since, days): def _paper_review(conn, since, days):
summary = get_paper_trading_summary(days=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( trades = [dict(r) for r in conn.execute(
""" """
SELECT * SELECT *
@ -166,6 +168,7 @@ def _paper_review(conn, since, days):
"summary": summary, "summary": summary,
"exit_reasons": exit_reasons, "exit_reasons": exit_reasons,
"event_types": event_types, "event_types": event_types,
"trade_attribution": trade_attribution,
"recent_trades": trades, "recent_trades": trades,
"recent_events": events, "recent_events": events,
} }
@ -269,9 +272,11 @@ def _iteration_review(conn, since):
("findings_json", []), ("findings_json", []),
("problems_json", []), ("problems_json", []),
("actions_json", []), ("actions_json", []),
("changed_rules_json", []),
("candidate_rules_json", []), ("candidate_rules_json", []),
): ):
item[field.replace("_json", "")] = _loads(item.get(field), fallback) item[field.replace("_json", "")] = _loads(item.get(field), fallback)
digest = _iteration_digest(logs, candidates)
return { return {
"definition": "策略迭代只产生候选假设和发布闸门结论,不直接等于收益提升。", "definition": "策略迭代只产生候选假设和发布闸门结论,不直接等于收益提升。",
"summary": { "summary": {
@ -284,11 +289,93 @@ def _iteration_review(conn, since):
}, },
"release_decisions": _bucket_count(logs, "release_decision", "unknown"), "release_decisions": _bucket_count(logs, "release_decision", "unknown"),
"candidate_status": _bucket_count(candidates, "status", "candidate"), "candidate_status": _bucket_count(candidates, "status", "candidate"),
"digest": digest,
"recent_logs": logs, "recent_logs": logs,
"recent_candidates": candidates[:12], "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): def get_review_center_dashboard(days=30):
days = max(1, min(_safe_int(days, 30), 365)) days = max(1, min(_safe_int(days, 30), 365))
since = _since(days) since = _since(days)

View File

@ -44,12 +44,18 @@ def get_strategy_insights():
r.*, r.*,
pt.id AS paper_trade_id, pt.id AS paper_trade_id,
pt.status AS paper_status, 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_pct AS paper_realized_pnl_pct,
pt.realized_pnl_usdt AS paper_realized_pnl_usdt, pt.realized_pnl_usdt AS paper_realized_pnl_usdt,
pt.pnl_pct AS paper_pnl_pct, 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 FROM recommendation r
LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id 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 ORDER BY r.rec_time DESC, r.id DESC
""" """
).fetchall() ).fetchall()
@ -110,6 +116,12 @@ def get_strategy_insights():
env_map = {} env_map = {}
version_map = {} version_map = {}
evidence_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: for item in items:
labels = safe_list_json(item.get("signal_labels_json")) or safe_list_json(item.get("signals")) 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")) codes = safe_list_json(item.get("signal_codes_json"))
@ -129,6 +141,24 @@ def get_strategy_insights():
add_bucket(env_map, bucket, item) add_bucket(env_map, bucket, item)
if item.get("strategy_version"): if item.get("strategy_version"):
add_bucket(version_map, str(item.get("strategy_version")).strip(), item) 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 { return {
"overview": overview, "overview": overview,
@ -142,9 +172,55 @@ def get_strategy_insights():
"market_environment": serialize_buckets("environment", env_map)[:20], "market_environment": serialize_buckets("environment", env_map)[:20],
"evidence_attribution": serialize_buckets("evidence", evidence_map)[:20], "evidence_attribution": serialize_buckets("evidence", evidence_map)[:20],
"version_performance": serialize_buckets("strategy_version", version_map, sort_by_version=True)[: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): def env_buckets_from_market_context(mc):
"""Convert market_context_json numeric fields into attribution buckets.""" """Convert market_context_json numeric fields into attribution buckets."""
buckets = [] buckets = []
@ -217,6 +293,29 @@ def serialize_buckets(name_key, bucket_map, sort_by_version=False):
return rows 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): def version_sort_key(version: str):
text = str(version or '').strip() text = str(version or '').strip()
if text.startswith('v') or text.startswith('V'): if text.startswith('v') or text.startswith('V'):

View File

@ -595,6 +595,61 @@ def adjust_signal_weights():
return adjustments 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 信号淘汰机制 ==================== # ==================== 2.5 信号淘汰机制 ====================
def _deprecate_low_performance_signals(): 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: if len(version_change_parts) < 4:
version_change_parts.append(f"权重调整:{adj}") 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]: for rule in results.get("new_learned_rules", [])[:6]:
desc = rule.get("description", "") desc = rule.get("description", "")
if desc: if desc:
@ -1320,6 +1386,7 @@ def _build_iteration_log(results, current_meta, now, config_diff=None, effect_su
"insufficient_tracking_count": insufficient_count, "insufficient_tracking_count": insufficient_count,
"missed_explosions": len(results.get("missed_explosions", [])), "missed_explosions": len(results.get("missed_explosions", [])),
"weight_adjustments": len(results.get("weight_adjustments", [])), "weight_adjustments": len(results.get("weight_adjustments", [])),
"factor_weight_updates": len(results.get("factor_weight_updates", [])),
"signal_deprecations": len(results.get("signal_deprecations", [])), "signal_deprecations": len(results.get("signal_deprecations", [])),
"candidate_rules": len(results.get("candidate_rules", [])), "candidate_rules": len(results.get("candidate_rules", [])),
"new_learned_rules": 0, "new_learned_rules": 0,
@ -1427,6 +1494,7 @@ def run_review(push_enabled: bool = True, compact: bool = False):
"reviews_done": 0, "reviews_done": 0,
"review_details": [], "review_details": [],
"weight_adjustments": [], "weight_adjustments": [],
"factor_weight_updates": [],
"signal_deprecations": [], "signal_deprecations": [],
"missed_explosions": [], "missed_explosions": [],
"new_learned_rules": [], "new_learned_rules": [],
@ -1444,6 +1512,7 @@ def run_review(push_enabled: bool = True, compact: bool = False):
# 2. 信号权重调整 # 2. 信号权重调整
results["weight_adjustments"] = adjust_signal_weights() results["weight_adjustments"] = adjust_signal_weights()
results["factor_weight_updates"] = _apply_daily_factor_weight_governance()
# 2.5 信号淘汰机制(低命中率信号自动淘汰/降权) # 2.5 信号淘汰机制(低命中率信号自动淘汰/降权)
results["signal_deprecations"] = _deprecate_low_performance_signals() 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"爆发{hit_count} 横盘{flat_count} 失败{fail_count} | "
f"漏选爆发{len(results['missed_explosions'])}只 | " f"漏选爆发{len(results['missed_explosions'])}只 | "
f"权重调整{len(results['weight_adjustments'])}项 | " f"权重调整{len(results['weight_adjustments'])}项 | "
f"因子生效调整{len(results['factor_weight_updates'])}项 | "
f"信号淘汰{len(results['signal_deprecations'])}项 | " f"信号淘汰{len(results['signal_deprecations'])}项 | "
f"候选规则{len(results.get('candidate_rules', []))}\n" f"候选规则{len(results.get('candidate_rules', []))}\n"
f"信号绩效排名: {', '.join(sig_summary[:5])}" f"信号绩效排名: {', '.join(sig_summary[:5])}"

View File

@ -187,8 +187,6 @@ a { color: inherit; text-decoration: none; }
<a class="sidebar-link admin-link {% if active_nav == 'review_center' %}active{% endif %}" href="/review-center" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>复盘中心</a> <a class="sidebar-link admin-link {% if active_nav == 'review_center' %}active{% endif %}" href="/review-center" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>复盘中心</a>
<a class="sidebar-link admin-link {% if active_nav in ['logs','pipeline','system_logs','chat_logs'] %}active{% endif %}" href="/logs" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>日志中心</a> <a class="sidebar-link admin-link {% if active_nav in ['logs','pipeline','system_logs','chat_logs'] %}active{% endif %}" href="/logs" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>日志中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a> <a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
<a class="sidebar-link admin-link {% if active_nav == 'strategy' %}active{% endif %}" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略归因</a>
<a class="sidebar-link admin-link {% if active_nav == 'iteration' %}active{% endif %}" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>策略迭代</a>
<a class="sidebar-link admin-link {% if active_nav == 'data_export' %}active{% endif %}" href="/data-export" style="display:none"><svg class="link-icon"><use href="#svg-export"/></svg>数据导出</a> <a class="sidebar-link admin-link {% if active_nav == 'data_export' %}active{% endif %}" href="/data-export" style="display:none"><svg class="link-icon"><use href="#svg-export"/></svg>数据导出</a>
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a> <a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a> <a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>

View File

@ -2,7 +2,7 @@
{% block title %}复盘中心 — AlphaX Agent{% endblock %} {% block title %}复盘中心 — AlphaX Agent{% endblock %}
{% block extra_head_css %} {% block extra_head_css %}
<style> <style>
.shell{width:min(100% - 40px,1320px);margin:0 auto;padding:24px 0 48px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:0}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:840px}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.select,.btn{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.btn{cursor:pointer}.principles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:14px}.principle{border:1px solid rgba(66,98,255,.14);background:rgba(66,98,255,.045);border-radius:var(--radius-md);padding:11px 12px;color:var(--slate);font-size:12px;line-height:1.55}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden;min-width:0}.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:15px;font-weight:950;color:var(--ink)}.panel-desc{margin-top:4px;color:var(--stone);font-size:11px;line-height:1.45;max-width:720px}.panel-body{padding:14px}.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:12px}.kpi{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:10px;font-weight:950}.kpi b{display:block;margin-top:5px;color:var(--ink);font-size:20px;line-height:1;font-weight:950;letter-spacing:0}.kpi b.green{color:var(--green)}.kpi b.red{color:var(--red)}.kpi b.blue{color:var(--blue)}.list{display:grid;gap:8px}.row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:9px 10px}.row-main{min-width:0}.row-title{font-size:12px;font-weight:950;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.row-sub{margin-top:3px;color:var(--stone);font-size:11px;line-height:1.45;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.value{font-size:12px;font-weight:950;color:var(--slate);white-space:nowrap}.value.green{color:var(--green)}.value.red{color:var(--red)}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}.mini-title{font-size:12px;font-weight:950;color:var(--ink);margin-bottom:8px}.empty,.loading{padding:30px 14px;text-align:center;color:var(--stone);font-size:13px}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;border:1px solid var(--hairline-soft);background:var(--surface);padding:0 8px;font-size:11px;font-weight:900;color:var(--slate);white-space:nowrap}.badge.ok{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.warn{background:var(--yellow-light);border-color:rgba(252,185,0,.22);color:var(--yellow-dark)}.badge.bad{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}.full{grid-column:1/-1}@media(max-width:1100px){.grid,.principles{grid-template-columns:1fr}.split{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:620px){.shell{width:min(100% - 24px,1320px)}.page-head h1{font-size:22px}.kpis{grid-template-columns:1fr}} .shell{width:min(100% - 40px,1320px);margin:0 auto;padding:24px 0 48px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:0}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:840px}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.select,.btn{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.btn{cursor:pointer}.strategy-digest{border:1px solid rgba(28,28,30,.1);background:linear-gradient(135deg,#fffdf7 0%,#f7fbff 56%,#f8fff8 100%);border-radius:18px;padding:16px;margin-bottom:14px;box-shadow:0 18px 50px rgba(15,23,42,.06)}.digest-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:12px}.digest-title{font-size:18px;font-weight:950;color:var(--ink)}.digest-sub{margin-top:4px;font-size:12px;color:var(--stone);line-height:1.55}.digest-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr;gap:10px}.digest-card{border:1px solid var(--hairline-soft);background:rgba(255,255,255,.72);border-radius:14px;padding:12px;min-width:0}.digest-card h3{font-size:12px;font-weight:950;color:var(--ink);margin-bottom:8px}.digest-item{border-top:1px solid var(--hairline-soft);padding:8px 0}.digest-item:first-of-type{border-top:0}.digest-item b{display:block;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.digest-item span{display:block;margin-top:3px;font-size:11px;color:var(--stone);line-height:1.45}.digest-empty{font-size:12px;color:var(--stone);padding:10px 0}.principles{display:none}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden;min-width:0}.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:15px;font-weight:950;color:var(--ink)}.panel-desc{margin-top:4px;color:var(--stone);font-size:11px;line-height:1.45;max-width:720px}.panel-body{padding:14px}.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:12px}.kpi{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:10px;font-weight:950}.kpi b{display:block;margin-top:5px;color:var(--ink);font-size:20px;line-height:1;font-weight:950;letter-spacing:0}.kpi b.green{color:var(--green)}.kpi b.red{color:var(--red)}.kpi b.blue{color:var(--blue)}.list{display:grid;gap:8px}.row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:9px 10px}.row-main{min-width:0}.row-title{font-size:12px;font-weight:950;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.row-sub{margin-top:3px;color:var(--stone);font-size:11px;line-height:1.45;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.value{font-size:12px;font-weight:950;color:var(--slate);white-space:nowrap}.value.green{color:var(--green)}.value.red{color:var(--red)}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}.mini-title{font-size:12px;font-weight:950;color:var(--ink);margin-bottom:8px}.empty,.loading{padding:30px 14px;text-align:center;color:var(--stone);font-size:13px}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;border:1px solid var(--hairline-soft);background:var(--surface);padding:0 8px;font-size:11px;font-weight:900;color:var(--slate);white-space:nowrap}.badge.ok{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.warn{background:var(--yellow-light);border-color:rgba(252,185,0,.22);color:var(--yellow-dark)}.badge.bad{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}.full{grid-column:1/-1}@media(max-width:1100px){.grid,.principles,.digest-grid{grid-template-columns:1fr}.split{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:620px){.shell{width:min(100% - 24px,1320px)}.page-head h1{font-size:22px}.kpis{grid-template-columns:1fr}.digest-head{display:block}.digest-head .badge{margin-top:8px}}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -22,6 +22,7 @@
<button class="btn" onclick="loadAll()">刷新</button> <button class="btn" onclick="loadAll()">刷新</button>
</div> </div>
</div> </div>
<section class="strategy-digest" id="strategyDigest"><div class="loading">加载中...</div></section>
<div class="principles" id="principles"><div class="loading">加载中...</div></div> <div class="principles" id="principles"><div class="loading">加载中...</div></div>
<div class="grid"> <div class="grid">
<section class="panel"> <section class="panel">
@ -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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)} var API='';function $(id){return document.getElementById(id)}function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 '<div class="kpis">'+items.map(function(x){return '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('')+'</div>'} function kpis(items){return '<div class="kpis">'+items.map(function(x){return '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('')+'</div>'}
function rows(items,label,value,sub){items=items||[];if(!items.length)return '<div class="empty">暂无数据</div>';return '<div class="list">'+items.map(function(x){return '<div class="row"><div class="row-main"><div class="row-title">'+esc(label(x))+'</div><div class="row-sub">'+esc(sub?sub(x):'')+'</div></div><div class="value">'+esc(value?value(x):'')+'</div></div>'}).join('')+'</div>'} function rows(items,label,value,sub){items=items||[];if(!items.length)return '<div class="empty">暂无数据</div>';return '<div class="list">'+items.map(function(x){return '<div class="row"><div class="row-main"><div class="row-title">'+esc(label(x))+'</div><div class="row-sub">'+esc(sub?sub(x):'')+'</div></div><div class="value">'+esc(value?value(x):'')+'</div></div>'}).join('')+'</div>'}
function digestItems(items,label,sub){items=items||[];if(!items.length)return '<div class="digest-empty">暂无动作</div>';return items.slice(0,4).map(function(x){return '<div class="digest-item"><b>'+esc(label(x))+'</b><span>'+esc(sub?sub(x):'')+'</span></div>'}).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='<div class="digest-head"><div><div class="digest-title">策略迭代摘要</div><div class="digest-sub">'+esc(latest.title||'暂无复盘')+' · '+time(latest.time)+' · 版本 '+esc(latest.strategy_version||'--')+'</div></div><span class="badge '+badgeCls+'">'+esc(decision)+'</span></div><div class="kpis">'+[['因子生效调整',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 '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('')+'</div><div class="digest-grid"><div class="digest-card"><h3>升权了什么</h3>'+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)+'%'})+'</div><div class="digest-card"><h3>降权 / 淘汰</h3>'+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||'')})+'</div><div class="digest-card"><h3>灰度观察</h3>'+digestItems(dig.gray,function(x){return x.signal||('候选 #'+x.id)},function(x){return '样本 '+(x.sample_size||0)+' · 置信 '+(x.confidence||0)+'% · '+(x.description||'')})+'</div><div class="digest-card"><h3>最近迭代</h3>'+digestItems(dig.timeline,function(x){return time(x.time)+' · '+(x.decision||'hold')},function(x){return x.reason||x.title||''})+'</div></div>'}
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']])+'<div class="split"><div><div class="mini-title">状态分布</div>'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">复盘结果</div>'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'} 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']])+'<div class="split"><div><div class="mini-title">状态分布</div>'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">复盘结果</div>'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
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','']])+'<div class="split"><div><div class="mini-title">退出原因</div>'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">执行事件</div>'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'} 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','']])+'<div class="split"><div><div class="mini-title">退出原因</div>'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">执行事件</div>'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">真实交易因子</div>'+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)})+'</div><div><div class="mini-title">入场路径表现</div>'+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)+'%'})+'</div></div>'}
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']])+'<div class="split"><div><div class="mini-title">链上信号</div>'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">舆情决策</div>'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'</div></div>'} 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']])+'<div class="split"><div><div class="mini-title">链上信号</div>'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">舆情决策</div>'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'</div></div>'}
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']])+'<div class="row" style="margin-bottom:10px"><div class="row-main"><div class="row-title">最新发布结论:'+esc(s.latest_release_decision||'hold')+'</div><div class="row-sub">'+esc(s.latest_release_reason||'暂无发布说明')+'</div></div><div class="value">闸门</div></div><div class="split"><div><div class="mini-title">发布决策</div>'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">候选状态</div>'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'} 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']])+'<div class="row" style="margin-bottom:10px"><div class="row-main"><div class="row-title">最新发布结论:'+esc(s.latest_release_decision||'hold')+'</div><div class="row-sub">'+esc(s.latest_release_reason||'暂无发布说明')+'</div></div><div class="value">闸门</div></div><div class="split"><div><div class="mini-title">发布决策</div>'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">候选状态</div>'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
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='<div class="split"><div><div class="mini-title">最近策略交易</div>'+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||'')})+'</div><div><div class="mini-title">漏选爆发</div>'+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||'')})+'</div><div><div class="mini-title">舆情事件</div>'+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||'未处理')})+'</div><div><div class="mini-title">链上信号</div>'+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||'')})+'</div></div>'} 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='<div class="split"><div><div class="mini-title">最近策略交易</div>'+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||'')})+'</div><div><div class="mini-title">漏选爆发</div>'+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||'')})+'</div><div><div class="mini-title">舆情事件</div>'+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||'未处理')})+'</div><div><div class="mini-title">链上信号</div>'+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||'')})+'</div></div>'}
function render(d){$('principles').innerHTML=(d.principles||[]).map(function(x){return '<div class="principle">'+esc(x)+'</div>'}).join('');renderOpportunity(d.opportunity||{});renderPaper(d.paper_trading||{});renderEvidence(d.evidence||{});renderIteration(d.iteration||{});renderRecent(d)} function render(d){renderStrategyDigest(d);$('principles').innerHTML=(d.principles||[]).map(function(x){return '<div class="principle">'+esc(x)+'</div>'}).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='<div class="empty">加载失败</div>'})}} 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='<div class="empty">加载失败</div>'})}}
loadAll(); loadAll();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -27,6 +27,7 @@
<section class="panel"><div class="panel-head"><div class="panel-title">市场环境归因</div><div class="panel-note">环境 -> 转化</div></div><div class="panel-body" id="envPerf"><div class="loading">加载中...</div></div></section> <section class="panel"><div class="panel-head"><div class="panel-title">市场环境归因</div><div class="panel-note">环境 -> 转化</div></div><div class="panel-body" id="envPerf"><div class="loading">加载中...</div></div></section>
<section class="panel"><div class="panel-head"><div class="panel-title">证据源归因</div><div class="panel-note">链上 / 舆情</div></div><div class="panel-body" id="evidencePerf"><div class="loading">加载中...</div></div></section> <section class="panel"><div class="panel-head"><div class="panel-title">证据源归因</div><div class="panel-note">链上 / 舆情</div></div><div class="panel-body" id="evidencePerf"><div class="loading">加载中...</div></div></section>
<section class="panel"><div class="panel-head"><div class="panel-title">版本归因</div><div class="panel-note">版本 -> 转化</div></div><div class="panel-body" id="versionPerf"><div class="loading">加载中...</div></div></section> <section class="panel"><div class="panel-head"><div class="panel-title">版本归因</div><div class="panel-note">版本 -> 转化</div></div><div class="panel-body" id="versionPerf"><div class="loading">加载中...</div></div></section>
<section class="panel full"><div class="panel-head"><div><div class="panel-title">交易级因子归因</div><div class="panel-note" id="tradeDef">只统计已平仓策略交易,用真实账本收益评价因子、入场路径、退出原因和环境。</div></div></div><div class="panel-body" id="tradePerf"><div class="loading">加载中...</div></div></section>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@ -35,6 +36,8 @@
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})} function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 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'<div class="empty">暂无数据</div>';return '<div class="table-wrap"><table class="table"><thead><tr><th>归因项</th><th>机会</th><th>可执行</th><th>现买</th><th>策略执行</th><th>执行转化</th><th>策略胜率</th><th>策略已实现</th></tr></thead><tbody>'+rows.map(function(r){return '<tr><td><span class="tag">'+esc(r[key]||'--')+'</span></td><td class="num">'+esc(r.opportunity_count||0)+'</td><td>'+esc(r.actionable_count||0)+'</td><td>'+esc(r.buy_now_count||0)+'</td><td>'+esc(r.paper_trade_count||0)+'</td><td class="num">'+pct(r.actionable_conversion_pct)+'</td><td class="num">'+pct(r.paper_win_rate_pct)+'</td><td class="'+(Number(r.paper_realized_pnl_usdt||0)>=0?'green':'red')+'">'+usd(r.paper_realized_pnl_usdt)+'</td></tr>'}).join('')+'</tbody></table></div>'} function table(rows,key){if(!rows||!rows.length)return'<div class="empty">暂无数据</div>';return '<div class="table-wrap"><table class="table"><thead><tr><th>归因项</th><th>机会</th><th>可执行</th><th>现买</th><th>策略执行</th><th>执行转化</th><th>策略胜率</th><th>策略已实现</th></tr></thead><tbody>'+rows.map(function(r){return '<tr><td><span class="tag">'+esc(r[key]||'--')+'</span></td><td class="num">'+esc(r.opportunity_count||0)+'</td><td>'+esc(r.actionable_count||0)+'</td><td>'+esc(r.buy_now_count||0)+'</td><td>'+esc(r.paper_trade_count||0)+'</td><td class="num">'+pct(r.actionable_conversion_pct)+'</td><td class="num">'+pct(r.paper_win_rate_pct)+'</td><td class="'+(Number(r.paper_realized_pnl_usdt||0)>=0?'green':'red')+'">'+usd(r.paper_realized_pnl_usdt)+'</td></tr>'}).join('')+'</tbody></table></div>'}
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 '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).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='<div class="empty">加载失败</div>'}}load(); function tradeTable(title,rows,key){if(!rows||!rows.length)return '<div><div class="panel-note" style="margin:8px 0">'+esc(title)+'</div><div class="empty">暂无已平仓交易样本</div></div>';return '<div><div class="panel-note" style="margin:8px 0">'+esc(title)+'</div><div class="table-wrap"><table class="table"><thead><tr><th>归因项</th><th>平仓交易</th><th>胜率</th><th>已实现收益</th><th>平均收益率</th><th>最好</th><th>最差</th></tr></thead><tbody>'+rows.map(function(r){return '<tr><td><span class="tag">'+esc(r[key]||'--')+'</span></td><td class="num">'+esc(r.closed_trade_count||0)+'</td><td class="num">'+pct(r.win_rate_pct)+'</td><td class="'+(Number(r.realized_pnl_usdt||0)>=0?'green':'red')+'">'+usd(r.realized_pnl_usdt)+'</td><td class="'+(Number(r.avg_realized_pnl_pct||0)>=0?'green':'red')+'">'+pct(r.avg_realized_pnl_pct)+'</td><td class="green">'+pct(r.best_pnl_pct)+'</td><td class="red">'+pct(r.worst_pnl_pct)+'</td></tr>'}).join('')+'</tbody></table></div></div>'}
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 '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).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='<div class="empty">加载失败</div>'}}load();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -145,6 +145,15 @@ class PersonalizationAndStrategyInsightTests(unittest.TestCase):
self.assertEqual(factor['paper_trade_count'], 1) self.assertEqual(factor['paper_trade_count'], 1)
self.assertAlmostEqual(factor['paper_realized_pnl_usdt'], 240) self.assertAlmostEqual(factor['paper_realized_pnl_usdt'], 240)
self.assertNotIn('avg_pnl_pct', factor) 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') env = next(x for x in insight['market_environment'] if x['environment'] == 'btc_trend:up')
self.assertEqual(env['opportunity_count'], 3) self.assertEqual(env['opportunity_count'], 3)

View File

@ -335,17 +335,17 @@ def test_pipeline_page_nav_hides_watchlist_entry_and_watchlist_route_survives(te
assert watch_resp.status_code == 200 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) client = TestClient(web_server.app)
resp = client.get("/app") resp = client.get("/app")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'href="/watchlist"' not in html assert 'href="/watchlist"' not in html
assert 'href="/pipeline" style="display:none"' in html assert 'href="/logs" style="display:none"' in html
assert 'href="/strategy" style="display:none"' in html assert 'href="/review-center" style="display:none"' in html
assert 'href="/iteration" style="display:none"' in html assert 'href="/strategy"' not in html
assert "研发" in html assert 'href="/iteration"' not in html
def test_confirm_candidates_prefer_recent_fine_screened_state(temp_db): def test_confirm_candidates_prefer_recent_fine_screened_state(temp_db):

View File

@ -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 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): def test_review_without_tracking_is_not_counted_as_signal_performance(temp_review_env):
db_path = str(temp_review_env) db_path = str(temp_review_env)
rec_id = _insert_rec(db_path) rec_id = _insert_rec(db_path)

View File

@ -1,4 +1,6 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import json
from datetime import datetime
from app.db import auth_db from app.db import auth_db
from app.db.review_center import get_review_center_dashboard 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 "total_pnl_usdt" not in data["opportunity"]["summary"]
assert data["paper_trading"]["summary"]["closed_count"] == 1 assert data["paper_trading"]["summary"]["closed_count"] == 1
assert data["paper_trading"]["summary"]["realized_pnl_usdt"] == 490 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"] == "前瞻信号不足"