diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index f789653..0a384c4 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -2359,63 +2359,80 @@ def _safe_dict_json(value): def get_strategy_insights(): - """阶段3:策略可信度看板数据 — 总体表现、因子归因、市场环境归因。 + """Strategy attribution based on opportunity and paper-trading conversion. - 只统计已出结果的样本,避免策略页把仍在实时看板里的 active 浮亏/回撤 - 与历史推荐页的已完成样本混在一起,造成口径不一致。 + Recommendation rows are opportunities/signals, not an execution ledger. + Therefore this read model does not use recommendation.pnl_pct as strategy + PnL. Paper-trading PnL is exposed only as an execution-conversion metric. """ conn = get_conn() - rows = conn.execute("SELECT * FROM recommendation ORDER BY rec_time DESC").fetchall() + rows = conn.execute( + """ + SELECT + r.*, + pt.id AS paper_trade_id, + pt.status AS paper_status, + 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 + FROM recommendation r + LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id + ORDER BY r.rec_time DESC, r.id DESC + """ + ).fetchall() conn.close() - raw_items = [dict(r) for r in rows] - - def outcome(item): - status = item.get("status") or "" - if status in ("hit_tp1", "hit_tp2"): - return "success" - if status == "stopped_out": - return "failed" - if (item.get("max_pnl_pct") or 0) >= 5: - return "success" - if (item.get("pnl_pct") or 0) <= -3 or (item.get("max_drawdown_pct") or 0) <= -5: - return "failed" - return "pending" - - items = [x for x in raw_items if outcome(x) in ("success", "failed")] + items = [dict(r) for r in rows] + actionable_statuses = {"buy_now", "wait_pullback"} total = len(items) - success = sum(1 for x in items if outcome(x) == "success") - failed = sum(1 for x in items if outcome(x) == "failed") - resolved = success + failed - pnl_values = [float(x.get("pnl_pct") or 0) for x in items] - gains = [p for p in pnl_values if p > 0] - losses = [p for p in pnl_values if p < 0] + actionable = [x for x in items if (x.get("execution_status") or "") in actionable_statuses] + buy_now = [x for x in items if (x.get("execution_status") or "") == "buy_now"] + paper_items = [x for x in items if x.get("paper_trade_id")] + closed_paper = [x for x in paper_items if x.get("paper_status") == "closed"] + paper_wins = [x for x in closed_paper if float(x.get("paper_realized_pnl_pct") or 0) > 0] + paper_realized_usdt = round(sum(float(x.get("paper_realized_pnl_usdt") or 0) for x in closed_paper), 4) overview = { - "total_signals": total, - "resolved_count": resolved, - "success_count": success, - "failed_count": failed, - "pending_count": total - resolved, - "win_rate_pct": round(success / resolved * 100, 1) if resolved else 0, - "avg_pnl_pct": round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else 0, - "avg_gain_pct": round(sum(gains) / len(gains), 2) if gains else 0, - "avg_loss_pct": round(sum(losses) / len(losses), 2) if losses else 0, - "max_gain_pct": round(max([float(x.get("max_pnl_pct") or x.get("pnl_pct") or 0) for x in items] or [0]), 2), - "max_drawdown_pct": round(min([float(x.get("max_drawdown_pct") or 0) for x in items] or [0]), 2), + "total_opportunities": total, + "actionable_count": len(actionable), + "buy_now_count": len(buy_now), + "paper_trade_count": len(paper_items), + "closed_paper_trade_count": len(closed_paper), + "paper_win_count": len(paper_wins), + "paper_win_rate_pct": round(len(paper_wins) / len(closed_paper) * 100, 1) if closed_paper else 0, + "paper_realized_pnl_usdt": paper_realized_usdt, + "actionable_conversion_pct": round(len(actionable) / total * 100, 1) if total else 0, + "paper_conversion_pct": round(len(paper_items) / len(buy_now) * 100, 1) if buy_now else 0, + "definition": "策略归因只看机会转化和模拟交易转化;收益只来自 paper_trades,不读取 recommendation.pnl_pct。", } def add_bucket(bucket_map, key, item): if not key: return - b = bucket_map.setdefault(key, {"total_count": 0, "success_count": 0, "failed_count": 0, "pending_count": 0, "pnl_values": [], "max_gains": [], "drawdowns": []}) - b["total_count"] += 1 - oc = outcome(item) - if oc == "success": b["success_count"] += 1 - elif oc == "failed": b["failed_count"] += 1 - else: b["pending_count"] += 1 - b["pnl_values"].append(float(item.get("pnl_pct") or 0)) - b["max_gains"].append(float(item.get("max_pnl_pct") or item.get("pnl_pct") or 0)) - b["drawdowns"].append(float(item.get("max_drawdown_pct") or 0)) + b = bucket_map.setdefault(key, { + "opportunity_count": 0, + "actionable_count": 0, + "buy_now_count": 0, + "paper_trade_count": 0, + "closed_paper_trade_count": 0, + "paper_win_count": 0, + "paper_realized_pnl_usdt": 0.0, + }) + execution_status = item.get("execution_status") or "" + paper_status = item.get("paper_status") or "" + b["opportunity_count"] += 1 + if execution_status in actionable_statuses: + b["actionable_count"] += 1 + if execution_status == "buy_now": + b["buy_now_count"] += 1 + if item.get("paper_trade_id"): + b["paper_trade_count"] += 1 + if paper_status == "closed": + b["closed_paper_trade_count"] += 1 + pnl_pct = float(item.get("paper_realized_pnl_pct") or 0) + if pnl_pct > 0: + b["paper_win_count"] += 1 + b["paper_realized_pnl_usdt"] += float(item.get("paper_realized_pnl_usdt") or 0) def env_buckets_from_market_context(mc): """把当前实际存在的 market_context_json 数值字段转成可归因桶。 @@ -2472,18 +2489,24 @@ def get_strategy_insights(): factor_map = {} env_map = {} version_map = {} + evidence_map = {} for item in items: - for factor in _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")) + for factor in labels: add_bucket(factor_map, str(factor).strip(), item) + for code in codes: + text = str(code or "").strip() + if text.startswith(("sentiment_", "listing_", "ecosystem_")): + add_bucket(evidence_map, "舆情:" + text, item) + elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_")): + add_bucket(evidence_map, "链上:" + text, item) mc = _safe_dict_json(item.get("market_context_json")) - added_env = False for key in ("btc_trend", "market_regime", "altcoin_regime", "sentiment"): if mc.get(key): add_bucket(env_map, f"{key}:{mc.get(key)}", item) - added_env = True for bucket in env_buckets_from_market_context(mc): add_bucket(env_map, bucket, item) - added_env = True if item.get("strategy_version"): add_bucket(version_map, str(item.get("strategy_version")).strip(), item) @@ -2506,27 +2529,35 @@ def get_strategy_insights(): def serialize(name_key, bucket_map, sort_by_version=False): rows = [] for key, b in bucket_map.items(): - resolved_count = b["success_count"] + b["failed_count"] rows.append({ name_key: key, - "total_count": b["total_count"], - "success_count": b["success_count"], - "failed_count": b["failed_count"], - "pending_count": b["pending_count"], - "win_rate_pct": round(b["success_count"] / resolved_count * 100, 1) if resolved_count else 0, - "avg_pnl_pct": round(sum(b["pnl_values"]) / len(b["pnl_values"]), 2) if b["pnl_values"] else 0, - "max_gain_pct": round(max(b["max_gains"] or [0]), 2), - "max_drawdown_pct": round(min(b["drawdowns"] or [0]), 2), + "opportunity_count": b["opportunity_count"], + "actionable_count": b["actionable_count"], + "buy_now_count": b["buy_now_count"], + "paper_trade_count": b["paper_trade_count"], + "closed_paper_trade_count": b["closed_paper_trade_count"], + "paper_win_count": b["paper_win_count"], + "actionable_conversion_pct": round(b["actionable_count"] / b["opportunity_count"] * 100, 1) if b["opportunity_count"] else 0, + "paper_conversion_pct": round(b["paper_trade_count"] / b["buy_now_count"] * 100, 1) if b["buy_now_count"] else 0, + "paper_win_rate_pct": round(b["paper_win_count"] / b["closed_paper_trade_count"] * 100, 1) if b["closed_paper_trade_count"] else 0, + "paper_realized_pnl_usdt": round(b["paper_realized_pnl_usdt"], 4), }) if sort_by_version: - rows.sort(key=lambda x: (version_sort_key(x[name_key]), x["total_count"], x["win_rate_pct"]), reverse=True) + rows.sort(key=lambda x: (version_sort_key(x[name_key]), x["opportunity_count"], x["actionable_conversion_pct"]), reverse=True) else: - rows.sort(key=lambda x: (-x["total_count"], -x["win_rate_pct"], x[name_key])) + rows.sort(key=lambda x: (-x["opportunity_count"], -x["actionable_conversion_pct"], x[name_key])) return rows return { "overview": overview, + "metric_definition": { + "opportunity_count": "进入 opportunity/recommendation 表的机会样本数,不代表交易。", + "actionable_count": "确认层输出 buy_now 或 wait_pullback 的样本数。", + "paper_trade_count": "已经被模拟交易账本执行的样本数。", + "paper_realized_pnl_usdt": "仅来自 paper_trades 的已平仓模拟收益。", + }, "factor_attribution": serialize("factor", factor_map)[:30], "market_environment": serialize("environment", env_map)[:20], + "evidence_attribution": serialize("evidence", evidence_map)[:20], "version_performance": serialize("strategy_version", version_map, sort_by_version=True)[:20], } diff --git a/app/db/review_center.py b/app/db/review_center.py new file mode 100644 index 0000000..f226e65 --- /dev/null +++ b/app/db/review_center.py @@ -0,0 +1,304 @@ +"""Review center read models. + +This module keeps the new review/iteration semantics explicit: +- opportunity review describes whether the system found useful opportunities; +- paper trading review is the only place where PnL is treated as execution PnL; +- evidence attribution describes whether onchain/sentiment/LLM evidence helped. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +from app.db.paper_trading import get_paper_trading_summary +from app.db.schema import get_conn + + +def _safe_int(value, default=0): + try: + return int(value or 0) + except Exception: + return default + + +def _safe_float(value, default=0.0): + try: + return float(value or 0) + except Exception: + return default + + +def _loads(value, default=None): + try: + if isinstance(value, (dict, list)): + return value + if isinstance(value, str) and value.strip(): + return json.loads(value) + except Exception: + pass + return default if default is not None else {} + + +def _since(days): + return (datetime.now() - timedelta(days=max(1, min(_safe_int(days, 30), 365)))).isoformat() + + +def _bucket_count(rows, key, fallback="unknown"): + counts = {} + for row in rows: + value = (row.get(key) or fallback) if isinstance(row, dict) else fallback + counts[value] = counts.get(value, 0) + 1 + return [{"name": k, "count": v} for k, v in sorted(counts.items(), key=lambda x: (-x[1], x[0]))] + + +def _opportunity_review(conn, since): + rec_rows = [dict(r) for r in conn.execute( + """ + SELECT id, symbol, rec_time, status, display_bucket, execution_status, action_status, + entry_triggered, strategy_version, signal_codes_json, signal_labels_json, + market_context_json, derivatives_context_json, sector_context_json + FROM recommendation + WHERE rec_time >= %s + ORDER BY rec_time DESC, id DESC + """, + (since,), + ).fetchall()] + review_rows = [dict(r) for r in conn.execute( + """ + SELECT * + FROM review_log + WHERE review_time >= %s + ORDER BY review_time DESC, id DESC + """, + (since,), + ).fetchall()] + missed_rows = [dict(r) for r in conn.execute( + """ + SELECT * + FROM missed_explosions + WHERE detect_time >= %s + ORDER BY gain_pct DESC, detect_time DESC + LIMIT 20 + """, + (since,), + ).fetchall()] + + total = len(rec_rows) + executed_ids = { + int(r["recommendation_id"]) + for r in conn.execute("SELECT recommendation_id FROM paper_trades WHERE opened_at >= %s", (since,)).fetchall() + if r.get("recommendation_id") + } + buy_now = [r for r in rec_rows if r.get("execution_status") == "buy_now"] + wait_pullback = [r for r in rec_rows if r.get("execution_status") == "wait_pullback"] + observe = [r for r in rec_rows if r.get("execution_status") == "observe" or r.get("display_bucket") == "watch_pool"] + invalid = [r for r in rec_rows if r.get("execution_status") == "invalid" or r.get("status") in ("expired", "invalid", "archived")] + executed = [r for r in rec_rows if int(r.get("id") or 0) in executed_ids] + + outcomes = _bucket_count(review_rows, "outcome", "未复盘") + reviewed_effective = [r for r in review_rows if r.get("outcome") in ("爆发", "失败", "横盘")] + hit_count = sum(1 for r in reviewed_effective if r.get("outcome") == "爆发") + + return { + "definition": "机会复盘只评价机会发现、确认和漏选,不代表交易收益。", + "summary": { + "total_opportunities": total, + "buy_now_count": len(buy_now), + "wait_pullback_count": len(wait_pullback), + "observe_count": len(observe), + "invalid_count": len(invalid), + "paper_executed_count": len(executed), + "reviewed_count": len(review_rows), + "effective_review_count": len(reviewed_effective), + "opportunity_hit_rate": round(hit_count / len(reviewed_effective) * 100, 2) if reviewed_effective else 0, + "missed_explosion_count": len(missed_rows), + }, + "status_distribution": _bucket_count(rec_rows, "execution_status", "unknown"), + "outcome_distribution": outcomes, + "missed_explosions": missed_rows[:10], + "recent_reviews": review_rows[:12], + } + + +def _paper_review(conn, since, days): + summary = get_paper_trading_summary(days=days) + trades = [dict(r) for r in conn.execute( + """ + SELECT * + FROM paper_trades + WHERE opened_at >= %s + ORDER BY opened_at DESC, id DESC + LIMIT 20 + """, + (since,), + ).fetchall()] + events = [dict(r) for r in conn.execute( + """ + SELECT * + FROM paper_trade_events + WHERE event_time >= %s + ORDER BY event_time DESC, id DESC + LIMIT 30 + """, + (since,), + ).fetchall()] + exit_reasons = _bucket_count([t for t in trades if t.get("status") == "closed"], "exit_reason", "unknown") + event_types = _bucket_count(events, "event_type", "unknown") + return { + "definition": "模拟交易复盘是唯一收益口径,基于 paper_trades 的开仓、平仓、移动止盈事件。", + "summary": summary, + "exit_reasons": exit_reasons, + "event_types": event_types, + "recent_trades": trades, + "recent_events": events, + } + + +def _evidence_review(conn, since): + news_rows = [dict(r) for r in conn.execute( + """ + SELECT source, symbol, importance, event_type, decision, tech_score, processed, detected_at, title + FROM event_news + WHERE detected_at >= %s + ORDER BY detected_at DESC, id DESC + LIMIT 80 + """, + (since,), + ).fetchall()] + onchain_rows = [dict(r) for r in conn.execute( + """ + SELECT source, chain, symbol, signal_code, signal_label, direction, value_usd, + confidence, severity, detected_at + FROM onchain_events + WHERE detected_at >= %s + ORDER BY detected_at DESC, id DESC + LIMIT 80 + """, + (since,), + ).fetchall()] + raw_onchain_rows = [dict(r) for r in conn.execute( + """ + SELECT source, chain, event_type, symbol_guess, mapped_symbol, mapping_status, + importance, detected_at, title + FROM onchain_raw_events + WHERE detected_at >= %s + ORDER BY importance DESC, detected_at DESC, id DESC + LIMIT 80 + """, + (since,), + ).fetchall()] + llm_rows = [dict(r) for r in conn.execute( + """ + SELECT target_type, insight_type, status, model, prompt_version, updated_at + FROM llm_insights + WHERE updated_at >= %s + ORDER BY updated_at DESC, id DESC + LIMIT 80 + """, + (since,), + ).fetchall()] + + mapped_raw = [r for r in raw_onchain_rows if r.get("mapping_status") == "mapped" or r.get("mapped_symbol")] + high_onchain = [r for r in onchain_rows if _safe_int(r.get("confidence")) >= 70 or str(r.get("severity") or "").upper() in ("A", "S")] + actionable_news = [r for r in news_rows if r.get("decision") in ("recommend", "observe", "risk") or str(r.get("importance") or "").upper() in ("A", "S")] + llm_success = [r for r in llm_rows if r.get("status") == "success"] + + return { + "definition": "多源归因只判断证据贡献:舆情、链上、LLM 是否帮助发现/解释机会,不直接生成交易收益。", + "summary": { + "news_count": len(news_rows), + "actionable_news_count": len(actionable_news), + "onchain_signal_count": len(onchain_rows), + "high_confidence_onchain_count": len(high_onchain), + "raw_onchain_count": len(raw_onchain_rows), + "mapped_raw_onchain_count": len(mapped_raw), + "llm_runs": len(llm_rows), + "llm_success_count": len(llm_success), + }, + "news_sources": _bucket_count(news_rows, "source", "unknown"), + "news_decisions": _bucket_count(news_rows, "decision", "unprocessed"), + "onchain_sources": _bucket_count(onchain_rows, "source", "unknown"), + "onchain_signals": _bucket_count(onchain_rows, "signal_code", "unknown")[:12], + "raw_mapping": _bucket_count(raw_onchain_rows, "mapping_status", "unknown"), + "llm_status": _bucket_count(llm_rows, "status", "unknown"), + "recent_news": news_rows[:8], + "recent_onchain": onchain_rows[:8], + "recent_llm": llm_rows[:8], + } + + +def _iteration_review(conn, since): + logs = [dict(r) for r in conn.execute( + """ + SELECT * + FROM strategy_iteration_log + WHERE created_at >= %s + ORDER BY created_at DESC, id DESC + LIMIT 12 + """, + (since,), + ).fetchall()] + candidates = [dict(r) for r in conn.execute( + """ + SELECT * + FROM strategy_rule_candidate + ORDER BY created_at DESC, id DESC + LIMIT 30 + """ + ).fetchall()] + for item in logs: + for field, fallback in ( + ("metrics_json", {}), + ("findings_json", []), + ("problems_json", []), + ("actions_json", []), + ("candidate_rules_json", []), + ): + item[field.replace("_json", "")] = _loads(item.get(field), fallback) + return { + "definition": "策略迭代只产生候选假设和发布闸门结论,不直接等于收益提升。", + "summary": { + "iteration_count": len(logs), + "candidate_count": len(candidates), + "gray_count": sum(1 for c in candidates if c.get("status") == "gray"), + "active_count": sum(1 for c in candidates if c.get("status") == "active"), + "latest_release_decision": (logs[0].get("release_decision") if logs else "") or "hold", + "latest_release_reason": (logs[0].get("release_reason") if logs else "") or "", + }, + "release_decisions": _bucket_count(logs, "release_decision", "unknown"), + "candidate_status": _bucket_count(candidates, "status", "candidate"), + "recent_logs": logs, + "recent_candidates": candidates[:12], + } + + +def get_review_center_dashboard(days=30): + days = max(1, min(_safe_int(days, 30), 365)) + since = _since(days) + conn = get_conn() + try: + opportunity = _opportunity_review(conn, since) + paper = _paper_review(conn, since, days) + evidence = _evidence_review(conn, since) + iteration = _iteration_review(conn, since) + finally: + conn.close() + + return { + "days": days, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "principles": [ + "机会归档不计算交易收益,只记录发现、确认、失效和漏选。", + "真实收益口径只来自模拟交易或未来真实交易账本。", + "链上、舆情、LLM 属于证据层,只做发现和解释,不直接改变推荐状态。", + "策略迭代只发布经过样本约束和灰度闸门验证的规则。", + ], + "opportunity": opportunity, + "paper_trading": paper, + "evidence": evidence, + "iteration": iteration, + } + + +__all__ = ["get_review_center_dashboard"] diff --git a/app/web/routes_pages.py b/app/web/routes_pages.py index 4cff7c3..caadcd0 100644 --- a/app/web/routes_pages.py +++ b/app/web/routes_pages.py @@ -118,6 +118,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str): return HTMLResponse(content=f"

需要管理员权限

{exc.detail}

返回看板", status_code=exc.status_code) return render_page("paper_trading.html", request, active_nav="paper_trading") + @router.get("/review-center", response_class=HTMLResponse) + async def review_center_page(request: Request): + user, redirect = require_page_user(request) + if redirect: + return redirect + try: + require_admin(request.cookies.get("altcoin_session", "")) + except HTTPException as exc: + return HTMLResponse(content=f"

需要管理员权限

{exc.detail}

返回看板", status_code=exc.status_code) + return render_page("review_center.html", request, active_nav="review_center") + @router.get("/strategy", response_class=HTMLResponse) async def strategy_page(request: Request): user, redirect = require_page_user(request) diff --git a/app/web/routes_review_center.py b/app/web/routes_review_center.py new file mode 100644 index 0000000..77e57b7 --- /dev/null +++ b/app/web/routes_review_center.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter, Cookie + +from app.db.review_center import get_review_center_dashboard +from app.web.shared import require_admin + + +router = APIRouter() + + +@router.get("/api/review-center/dashboard") +async def api_review_center_dashboard(days: int = 30, altcoin_session: str = Cookie(default="")): + require_admin(altcoin_session) + return get_review_center_dashboard(days=days) diff --git a/app/web/web_server.py b/app/web/web_server.py index 179fca7..f4c074a 100644 --- a/app/web/web_server.py +++ b/app/web/web_server.py @@ -23,6 +23,7 @@ from app.web.routes_onchain import router as onchain_router from app.web.routes_paper_trading import router as paper_trading_router from app.web.routes_pages import build_router as build_pages_router from app.web.routes_recommendations import router as recommendations_router +from app.web.routes_review_center import router as review_center_router from app.web.routes_strategy import router as strategy_router from app.web.shared import current_request from app.web.shared import require_active_subscription as _require_active_subscription @@ -47,6 +48,7 @@ templates = Jinja2Templates(directory=str(REPO_ROOT / "static")) app.include_router(auth_router) app.include_router(chat_router) app.include_router(recommendations_router) +app.include_router(review_center_router) app.include_router(strategy_router) app.include_router(onchain_router) app.include_router(paper_trading_router) diff --git a/static/base.html b/static/base.html index cbc513d..a374b85 100644 --- a/static/base.html +++ b/static/base.html @@ -183,10 +183,11 @@ a { color: inherit; text-decoration: none; } 邀请 + - - + + diff --git a/static/iteration.html b/static/iteration.html index 7a6b7b2..642972b 100644 --- a/static/iteration.html +++ b/static/iteration.html @@ -114,14 +114,14 @@ h2 { font-size:26px; font-weight:900; margin:0 0 8px; color:var(--ink); } - +
加载中…
-
发现的规律 未达标不发布
这些是系统复盘后发现的可能规律。只有样本、成功率、收益和稳定性都达标,才会进入线上策略。
+
发现的规律 未达标不发布
这些是系统复盘后发现的可能规律。只有样本、机会表现和稳定性都达标,才会进入线上策略;交易收益以模拟交易页为准。
发布预演 只读评估,不改线上策略
主要失败原因
失败样本
-
版本表现
+
版本机会表现
{% endblock %} @@ -153,7 +153,7 @@ function renderUserReport(d){ var conf=Number(c.confidence_score||0); var q=conf>=70?'good':conf>=40?'wait':'bad'; var qtxt=conf>=70?'接近可用':conf>=40?'继续观察':'证据不足'; - return '
'+esc(name)+''+qtxt+' 样本 '+esc(c.sample_size||0)+' · 置信 '+esc(c.confidence_score||0)+' · 平均表现 '+esc(c.avg_pnl||0)+'
'; + return '
'+esc(name)+''+qtxt+' 样本 '+esc(c.sample_size||0)+' · 置信 '+esc(c.confidence_score||0)+' · 机会表现 '+esc(c.avg_pnl||0)+'
'; }).join(''):'
暂无新规律当前没有足够证据支持策略改动。
'; var failHtml=failures.length?failures.map(function(f){return '
'+esc(f.type||'失败模式')+'出现 '+esc(f.count||0)+' 次,后续复盘会重点观察是否重复发生。
';}).join(''):'
暂无集中失败模式当前失败样本不足,先继续观察。
'; var aiHtml = aiMemo ? '
AI 复盘摘要
'+esc(aiMemo.summary || aiMemo.memo || aiMemo.why_it_matters || '暂无摘要')+'
' : ''; @@ -173,10 +173,10 @@ function renderTimeline(items){ if(!items.length){$('timeline').innerHTML='
'+label+'
'+items.slice(0,10).map(function(x){ var t=typeof x==='string'?x:(x.label||x.type||x.signal||x.description||JSON.stringify(x)); var c=x.count?(' · '+x.count):''; return '
'+esc(t+c)+'
'; }).join('')+'
'; } function renderCandidateMini(items){ if(!items.length)return ''; return '
本轮候选规则
'+items.slice(0,8).map(function(x){return '
'+esc(x.description||x.signal||'候选规则')+' · 置信 '+esc(x.confidence_score||0)+' · 样本 '+esc(x.sample_size||0)+' · '+esc(x.status||'candidate')+'
';}).join('')+'
'; } function sourceLabel(c){ var s=String(c.source||''); if(s==='reverse_analysis')return '涨幅榜逆向'; if(s.indexOf('dual_attribution_success')===0)return '成功复盘'; if(s.indexOf('dual_attribution_failure')===0)return '失败复盘'; if(s.indexOf('signal_deprecation')===0)return '低绩效信号'; if(s.indexOf('dirty_history')===0)return '历史参考'; return s||'研究池'; } -function renderCandidates(items,dry){ if(!items.length){$('candidates').innerHTML='
暂无待验证规律
';return;} var dryMap={}; (dry.evaluated_candidates||[]).forEach(function(x){dryMap[x.id]=x;}); $('candidates').innerHTML=''+items.map(function(c){var d=dryMap[c.id]||{};return '';}).join('')+'
来源当前阶段预演结论规律样本成功/失败可信度平均表现为什么还没发布
'+esc(sourceLabel(c))+''+badge(c.status||'candidate')+''+badge(d.dry_run_status||c.status||'candidate')+''+esc(c.rule_description||c.signal_name||'--')+''+esc(d.sample_size!=null?d.sample_size:(c.sample_size||0))+''+esc(d.success_count!=null?d.success_count:(c.success_count||0))+' / '+esc(d.fail_count!=null?d.fail_count:(c.fail_count||0))+''+esc(d.confidence_score!=null?d.confidence_score:(c.confidence_score||0))+''+esc(d.avg_pnl!=null?d.avg_pnl:(c.avg_pnl||0))+''+esc(d.gate_reason||'等待样本验证')+'
'; } -function renderDryRun(dry){ var items=dry.evaluated_candidates||[]; if(!items.length){$('dryrun').innerHTML='
暂无待验证规律可评估
';return;} $('dryrun').innerHTML='
当前版本 '+esc(dry.current_version||'--')+';干净样本起点 '+esc(dry.clean_started_at||'未设置')+';干净复盘样本 '+esc(dry.review_sample_count||0)+';污染历史候选 '+esc(dry.dirty_history_candidate_count||0)+';可灰度 '+esc(dry.gray_ready_count||0)+';是否发布:'+(dry.would_bump_version?'是':'否')+'。
灰度标准:'+esc((dry.gate_policy&&dry.gate_policy.gray)||'--')+'
'+items.map(function(x){return '';}).join('')+'
预演结论规律样本成功/失败可信度平均表现原因
'+badge(x.dry_run_status||'candidate')+''+esc(x.rule_description||x.signal_name||'--')+''+esc(x.sample_size||0)+''+esc(x.success_count||0)+' / '+esc(x.fail_count||0)+''+esc(x.confidence_score||0)+''+esc(x.avg_pnl||0)+''+esc(x.gate_reason||'--')+'
'; } -function renderFailures(d){ var fs=(d.overview&&d.overview.failure_type_counts)||[]; $('failureSummary').innerHTML=fs.length?fs.map(function(f){return ''+esc(f.type)+' · '+esc(f.count)+'';}).join(''):'
暂无失败模式
'; var items=d.failures||[]; $('failures').innerHTML=items.length?items.slice(0,30).map(function(f){return '
'+esc(f.symbol||'--')+' · '+esc(f.failure_type||'未分类')+' · '+esc((f.failure_reason||'').slice(0,90))+' · PnL '+esc(f.pnl_pct||0)+'
';}).join(''):'
暂无失败样本
'; } -function renderVersions(items){ if(!items.length){$('versions').innerHTML='
暂无版本表现
';return;} $('versions').innerHTML=''+items.map(function(v){return '';}).join('')+'
版本推荐数成功失败待观察成功率均值收益
'+esc(v.strategy_version)+''+esc(v.recommendation_count)+''+esc(v.success_count)+''+esc(v.failed_count)+''+esc(v.pending_count)+''+esc(v.success_rate_pct)+''+esc(v.avg_pnl_pct)+'
'; } +function renderCandidates(items,dry){ if(!items.length){$('candidates').innerHTML='
暂无待验证规律
';return;} var dryMap={}; (dry.evaluated_candidates||[]).forEach(function(x){dryMap[x.id]=x;}); $('candidates').innerHTML=''+items.map(function(c){var d=dryMap[c.id]||{};return '';}).join('')+'
来源当前阶段预演结论规律样本成功/失败可信度机会表现为什么还没发布
'+esc(sourceLabel(c))+''+badge(c.status||'candidate')+''+badge(d.dry_run_status||c.status||'candidate')+''+esc(c.rule_description||c.signal_name||'--')+''+esc(d.sample_size!=null?d.sample_size:(c.sample_size||0))+''+esc(d.success_count!=null?d.success_count:(c.success_count||0))+' / '+esc(d.fail_count!=null?d.fail_count:(c.fail_count||0))+''+esc(d.confidence_score!=null?d.confidence_score:(c.confidence_score||0))+''+esc(d.avg_pnl!=null?d.avg_pnl:(c.avg_pnl||0))+''+esc(d.gate_reason||'等待样本验证')+'
'; } +function renderDryRun(dry){ var items=dry.evaluated_candidates||[]; if(!items.length){$('dryrun').innerHTML='
暂无待验证规律可评估
';return;} $('dryrun').innerHTML='
当前版本 '+esc(dry.current_version||'--')+';干净样本起点 '+esc(dry.clean_started_at||'未设置')+';干净复盘样本 '+esc(dry.review_sample_count||0)+';污染历史候选 '+esc(dry.dirty_history_candidate_count||0)+';可灰度 '+esc(dry.gray_ready_count||0)+';是否发布:'+(dry.would_bump_version?'是':'否')+'。
灰度标准:'+esc((dry.gate_policy&&dry.gate_policy.gray)||'--')+'
'+items.map(function(x){return '';}).join('')+'
预演结论规律样本成功/失败可信度机会表现原因
'+badge(x.dry_run_status||'candidate')+''+esc(x.rule_description||x.signal_name||'--')+''+esc(x.sample_size||0)+''+esc(x.success_count||0)+' / '+esc(x.fail_count||0)+''+esc(x.confidence_score||0)+''+esc(x.avg_pnl||0)+''+esc(x.gate_reason||'--')+'
'; } +function renderFailures(d){ var fs=(d.overview&&d.overview.failure_type_counts)||[]; $('failureSummary').innerHTML=fs.length?fs.map(function(f){return ''+esc(f.type)+' · '+esc(f.count)+'';}).join(''):'
暂无失败模式
'; var items=d.failures||[]; $('failures').innerHTML=items.length?items.slice(0,30).map(function(f){return '
'+esc(f.symbol||'--')+' · '+esc(f.failure_type||'未分类')+' · '+esc((f.failure_reason||'').slice(0,90))+' · 机会表现 '+esc(f.pnl_pct||0)+'
';}).join(''):'
暂无失败样本
'; } +function renderVersions(items){ if(!items.length){$('versions').innerHTML='
暂无版本机会表现
';return;} $('versions').innerHTML=''+items.map(function(v){return '';}).join('')+'
版本机会数成功失败待观察机会成功率机会均值
'+esc(v.strategy_version)+''+esc(v.recommendation_count)+''+esc(v.success_count)+''+esc(v.failed_count)+''+esc(v.pending_count)+''+esc(v.success_rate_pct)+''+esc(v.avg_pnl_pct)+'
'; } loadAll(); {% endblock %} diff --git a/static/review_center.html b/static/review_center.html new file mode 100644 index 0000000..a8be902 --- /dev/null +++ b/static/review_center.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}复盘中心 — AlphaX Agent{% endblock %} +{% block extra_head_css %} + +{% endblock %} +{% block content %} +
+
+
+

复盘中心

+

把机会发现、模拟交易收益、多源证据和策略迭代拆开看。收益只看模拟交易;机会归档只看发现和确认质量。

+
+
+ + +
+
+
加载中...
+
+
+
机会复盘
--
不算收益
+
加载中...
+
+
+
模拟交易复盘
--
唯一收益口径
+
加载中...
+
+
+
多源证据归因
--
只做证据
+
加载中...
+
+
+
策略迭代闸门
--
候选假设
+
加载中...
+
+
+
最近关键样本
方便快速定位是机会问题、执行问题,还是证据源问题。
+
加载中...
+
+
+
+{% endblock %} +{% block extra_script %} + +{% endblock %} diff --git a/static/strategy.html b/static/strategy.html index fd43022..3f366c1 100644 --- a/static/strategy.html +++ b/static/strategy.html @@ -1,32 +1,40 @@ {% extends "base.html" %} -{% block title %}策略 — AlphaX Agent{% endblock %} +{% block title %}策略归因 — AlphaX Agent{% endblock %} {% block extra_head_css %} {% endblock %} {% block content %}
-

策略

系统可信度、版本表现、因子归因与市场环境归因。

-
-
数据基于历史信号跟踪,仅用于策略研究与模型评估,不构成收益承诺或投资建议。
+
+
+

策略归因

+

看清哪些因子、环境和证据源更容易把机会推进到可执行,以及是否真的进入模拟交易。这里不把机会价格波动当作交易收益。

+
+ 收益只来自模拟交易 +
+
加载中...
+
策略归因只看机会转化和模拟交易转化;收益只来自 paper_trades。
-
发现粗筛只进观察/候选,不直接计交易绩效。
-
确认只有新鲜触发和入场计划达标才进入可交易口径。
-
复盘只用48h窗口样本更新有效表现。
-
迭代候选规则经发布闸门后才生效。查看迭代
+
机会样本系统发现并入库的机会,不等于交易。
+
可执行转化确认层输出现在可买或等回踩。
+
模拟执行只有 buy_now 被 paper trading 开仓才进入收益账本。
+
证据归因链上、舆情、技术因子只做贡献分析。
-

版本表现

-

市场环境归因

+
因子归因
信号 -> 转化
加载中...
+
市场环境归因
环境 -> 转化
加载中...
+
证据源归因
链上 / 舆情
加载中...
+
版本归因
版本 -> 转化
加载中...
-

因子归因

{% endblock %} {% block extra_script %} {% endblock %} diff --git a/tests/test_personalization_strategy_stage2_3.py b/tests/test_personalization_strategy_stage2_3.py index b1d6c44..cfc9df9 100644 --- a/tests/test_personalization_strategy_stage2_3.py +++ b/tests/test_personalization_strategy_stage2_3.py @@ -95,22 +95,58 @@ class PersonalizationAndStrategyInsightTests(unittest.TestCase): self.assertFalse(rules['push_wait_pullback']) self.assertEqual(rules['quiet_start'], '01:00') - def test_strategy_performance_and_factor_attribution(self): - self._insert_rec(symbol='ENS/USDT', signals=json.dumps(['底部抬高','放量突破'], ensure_ascii=False), status='hit_tp1', pnl_pct=8.0, max_drawdown_pct=-1.0) - self._insert_rec(symbol='SOL/USDT', signals=json.dumps(['放量突破'], ensure_ascii=False), status='stopped_out', pnl_pct=-3.0, max_drawdown_pct=-4.0) + def test_strategy_factor_attribution_uses_conversion_not_recommendation_pnl(self): + rec1 = self._insert_rec( + symbol='ENS/USDT', + signals=json.dumps(['底部抬高','放量突破'], ensure_ascii=False), + signal_labels_json=json.dumps(['底部抬高','放量突破'], ensure_ascii=False), + status='active', + execution_status='buy_now', + action_status='可即刻买入', + pnl_pct=80.0, + max_drawdown_pct=-1.0, + ) + self._insert_rec( + symbol='SOL/USDT', + signals=json.dumps(['放量突破'], ensure_ascii=False), + signal_labels_json=json.dumps(['放量突破'], ensure_ascii=False), + status='active', + execution_status='observe', + pnl_pct=-30.0, + max_drawdown_pct=-4.0, + ) self._insert_rec(symbol='BTC/USDT', signals=json.dumps(['底部抬高'], ensure_ascii=False), status='active', pnl_pct=1.0, max_pnl_pct=2.0) + conn = sqlite3.connect(self.alt_path) + conn.execute( + """ + INSERT INTO paper_trades ( + recommendation_id, symbol, side, status, opened_at, closed_at, + entry_price, exit_price, qty, notional_usdt, margin_usdt, leverage, + current_price, pnl_pct, realized_pnl_pct, realized_pnl_usdt, + source_status, source_action, created_at, updated_at + ) VALUES (?, 'ENS/USDT', 'long', 'closed', '2026-05-09T20:01:00', '2026-05-09T21:00:00', + 10, 10.5, 500, 5000, 1000, 5, 10.5, 5, 5, 240, + 'buy_now', '可即刻买入', '2026-05-09T20:01:00', '2026-05-09T21:00:00') + """, + (rec1,), + ) + conn.commit() + conn.close() insight = altcoin_db.get_strategy_insights() - self.assertEqual(insight['overview']['total_signals'], 2) - self.assertEqual(insight['overview']['resolved_count'], 2) - self.assertEqual(insight['overview']['win_rate_pct'], 50.0) + self.assertEqual(insight['overview']['total_opportunities'], 3) + self.assertEqual(insight['overview']['buy_now_count'], 1) + self.assertEqual(insight['overview']['paper_trade_count'], 1) + self.assertEqual(insight['overview']['paper_realized_pnl_usdt'], 240) factor = next(x for x in insight['factor_attribution'] if x['factor'] == '底部抬高') - self.assertEqual(factor['total_count'], 1) - self.assertEqual(factor['success_count'], 1) - self.assertAlmostEqual(factor['avg_pnl_pct'], 8.0) + self.assertEqual(factor['opportunity_count'], 2) + self.assertEqual(factor['buy_now_count'], 1) + self.assertEqual(factor['paper_trade_count'], 1) + self.assertAlmostEqual(factor['paper_realized_pnl_usdt'], 240) + self.assertNotIn('avg_pnl_pct', factor) env = next(x for x in insight['market_environment'] if x['environment'] == 'btc_trend:up') - self.assertEqual(env['total_count'], 2) + self.assertEqual(env['opportunity_count'], 3) if __name__ == '__main__': diff --git a/tests/test_review_center.py b/tests/test_review_center.py new file mode 100644 index 0000000..58b5847 --- /dev/null +++ b/tests/test_review_center.py @@ -0,0 +1,82 @@ +from fastapi.testclient import TestClient + +from app.db import auth_db +from app.db.review_center import get_review_center_dashboard +from app.web import web_server + + +def _login_user(email: str, admin: bool = False) -> str: + reg = auth_db.register_user(email, "StrongPass123") + auth_db.verify_email(email, reg["verification_code"]) + user = auth_db.get_user_by_email(email) + auth_db.claim_free_trial(user["id"]) + if admin: + auth_db.set_user_admin(email, True) + return auth_db.login_user(email, "StrongPass123")["token"] + + +def test_review_center_page_and_api_require_admin(): + token = _login_user("normal-review-center@example.com") + client = TestClient(web_server.app) + client.cookies.set("altcoin_session", token) + + page = client.get("/review-center") + api = client.get("/api/review-center/dashboard") + + assert page.status_code == 403 + assert api.status_code == 403 + + +def test_review_center_admin_can_access_page_and_api(): + token = _login_user("admin-review-center@example.com", admin=True) + client = TestClient(web_server.app) + client.cookies.set("altcoin_session", token) + + page = client.get("/review-center") + api = client.get("/api/review-center/dashboard") + + assert page.status_code == 200 + assert "复盘中心" in page.text + assert api.status_code == 200 + data = api.json() + assert "opportunity" in data + assert "paper_trading" in data + assert "evidence" in data + assert "iteration" in data + + +def test_review_center_separates_opportunity_and_paper_pnl(pg_conn): + pg_conn.execute( + """ + INSERT INTO recommendation ( + symbol, rec_time, rec_state, rec_score, entry_price, + status, execution_status, action_status, display_bucket, entry_triggered + ) VALUES + ('OBS/USDT', '2026-05-16T10:00:00', '观察', 10, 1, 'active', 'observe', '观察', 'watch_pool', 0), + ('BUY/USDT', '2026-05-16T11:00:00', '爆发', 30, 10, 'active', 'buy_now', '可即刻买入', 'realtime', 1) + """ + ) + pg_conn.execute( + """ + INSERT INTO paper_trades ( + recommendation_id, symbol, side, status, opened_at, closed_at, + entry_price, exit_price, qty, notional_usdt, margin_usdt, leverage, + current_price, pnl_pct, realized_pnl_pct, realized_pnl_usdt, + source_status, source_action, created_at, updated_at + ) VALUES ( + 2, 'BUY/USDT', 'long', 'closed', '2026-05-16T11:01:00', '2026-05-16T12:00:00', + 10, 11, 500, 5000, 1000, 5, + 11, 10, 10, 490, + 'buy_now', '可即刻买入', '2026-05-16T11:01:00', '2026-05-16T12:00:00' + ) + """ + ) + pg_conn.commit() + + data = get_review_center_dashboard(days=30) + + assert data["opportunity"]["summary"]["total_opportunities"] == 2 + assert data["opportunity"]["summary"]["paper_executed_count"] == 1 + 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