update
This commit is contained in:
parent
7304e41440
commit
e63344b632
@ -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],
|
||||
}
|
||||
|
||||
304
app/db/review_center.py
Normal file
304
app/db/review_center.py
Normal file
@ -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"]
|
||||
@ -118,6 +118,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
|
||||
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", 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"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", 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)
|
||||
|
||||
13
app/web/routes_review_center.py
Normal file
13
app/web/routes_review_center.py
Normal file
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -183,10 +183,11 @@ a { color: inherit; text-decoration: none; }
|
||||
<a class="sidebar-link {% if active_nav == 'referral' %}active{% endif %}" href="/referral"><svg class="link-icon"><use href="#svg-referral"/></svg>邀请</a>
|
||||
<div class="sidebar-section-label admin-link" style="display:none">管理员菜单</div>
|
||||
<a class="sidebar-link admin-link {% if active_nav == 'paper_trading' %}active{% endif %}" href="/paper-trading" style="display:none"><svg class="link-icon"><use href="#svg-paper"/></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 == 'pipeline' %}active{% endif %}" href="/pipeline" 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 == '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 == '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 == 'chat_logs' %}active{% endif %}" href="/chat-logs" style="display:none"><svg class="link-icon"><use href="#svg-chat"/></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>
|
||||
|
||||
@ -114,14 +114,14 @@ h2 { font-size:26px; font-weight:900; margin:0 0 8px; color:var(--ink); }
|
||||
<button class="tab" data-tab="candidates" onclick="switchTab('candidates')">发现的规律</button>
|
||||
<button class="tab" data-tab="dryrun" onclick="switchTab('dryrun')">发布预演</button>
|
||||
<button class="tab" data-tab="failures" onclick="switchTab('failures')">错误复盘</button>
|
||||
<button class="tab" data-tab="versions" onclick="switchTab('versions')">版本表现</button>
|
||||
<button class="tab" data-tab="versions" onclick="switchTab('versions')">版本机会表现</button>
|
||||
</div>
|
||||
|
||||
<div class="panel active" id="panel-timeline"><div class="timeline" id="timeline"><div class="loading">加载中…</div></div></div>
|
||||
<div class="panel" id="panel-candidates"><div class="card"><div class="card-title">发现的规律 <span class="badge hold">未达标不发布</span></div><div class="user-tabs-note">这些是系统复盘后发现的可能规律。只有样本、成功率、收益和稳定性都达标,才会进入线上策略。</div><div id="candidates"></div></div></div>
|
||||
<div class="panel" id="panel-candidates"><div class="card"><div class="card-title">发现的规律 <span class="badge hold">未达标不发布</span></div><div class="user-tabs-note">这些是系统复盘后发现的可能规律。只有样本、机会表现和稳定性都达标,才会进入线上策略;交易收益以模拟交易页为准。</div><div id="candidates"></div></div></div>
|
||||
<div class="panel" id="panel-dryrun"><div class="card"><div class="card-title">发布预演 <span class="badge hold">只读评估,不改线上策略</span></div><div id="dryrun"></div></div></div>
|
||||
<div class="panel" id="panel-failures"><div class="board"><div class="card"><div class="card-title">主要失败原因</div><div id="failureSummary"></div></div><div class="card"><div class="card-title">失败样本</div><div id="failures"></div></div></div></div>
|
||||
<div class="panel" id="panel-versions"><div class="card"><div class="card-title">版本表现</div><div id="versions"></div></div></div>
|
||||
<div class="panel" id="panel-versions"><div class="card"><div class="card-title">版本机会表现</div><div id="versions"></div></div></div>
|
||||
</div>
|
||||
{% 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 '<div class="report-item"><b>'+esc(name)+'</b><span><span class="rule-quality '+q+'">'+qtxt+'</span> 样本 '+esc(c.sample_size||0)+' · 置信 '+esc(c.confidence_score||0)+' · 平均表现 '+esc(c.avg_pnl||0)+'</span></div>';
|
||||
return '<div class="report-item"><b>'+esc(name)+'</b><span><span class="rule-quality '+q+'">'+qtxt+'</span> 样本 '+esc(c.sample_size||0)+' · 置信 '+esc(c.confidence_score||0)+' · 机会表现 '+esc(c.avg_pnl||0)+'</span></div>';
|
||||
}).join(''):'<div class="report-item"><b>暂无新规律</b><span>当前没有足够证据支持策略改动。</span></div>';
|
||||
var failHtml=failures.length?failures.map(function(f){return '<div class="report-item"><b>'+esc(f.type||'失败模式')+'</b><span>出现 '+esc(f.count||0)+' 次,后续复盘会重点观察是否重复发生。</span></div>';}).join(''):'<div class="report-item"><b>暂无集中失败模式</b><span>当前失败样本不足,先继续观察。</span></div>';
|
||||
var aiHtml = aiMemo ? '<div class="ai-memo"><div class="label">AI 复盘摘要</div><div class="text">'+esc(aiMemo.summary || aiMemo.memo || aiMemo.why_it_matters || '暂无摘要')+'</div></div>' : '';
|
||||
@ -173,10 +173,10 @@ function renderTimeline(items){ if(!items.length){$('timeline').innerHTML='<div
|
||||
function renderSection(label,items,cls){ if(!items||!items.length)return ''; return '<div class="section"><div class="section-label">'+label+'</div>'+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 '<div class="item '+cls+'">'+esc(t+c)+'</div>'; }).join('')+'</div>'; }
|
||||
function renderCandidateMini(items){ if(!items.length)return ''; return '<div class="section"><div class="section-label">本轮候选规则</div>'+items.slice(0,8).map(function(x){return '<div class="item">'+esc(x.description||x.signal||'候选规则')+' · 置信 '+esc(x.confidence_score||0)+' · 样本 '+esc(x.sample_size||0)+' · '+esc(x.status||'candidate')+'</div>';}).join('')+'</div>'; }
|
||||
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='<div class="empty">暂无待验证规律</div>';return;} var dryMap={}; (dry.evaluated_candidates||[]).forEach(function(x){dryMap[x.id]=x;}); $('candidates').innerHTML='<table class="table"><thead><tr><th>来源</th><th>当前阶段</th><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>平均表现</th><th>为什么还没发布</th></tr></thead><tbody>'+items.map(function(c){var d=dryMap[c.id]||{};return '<tr><td><span class="tag">'+esc(sourceLabel(c))+'</span></td><td>'+badge(c.status||'candidate')+'</td><td>'+badge(d.dry_run_status||c.status||'candidate')+'</td><td class="rule-name">'+esc(c.rule_description||c.signal_name||'--')+'</td><td>'+esc(d.sample_size!=null?d.sample_size:(c.sample_size||0))+'</td><td>'+esc(d.success_count!=null?d.success_count:(c.success_count||0))+' / '+esc(d.fail_count!=null?d.fail_count:(c.fail_count||0))+'</td><td class="score">'+esc(d.confidence_score!=null?d.confidence_score:(c.confidence_score||0))+'</td><td>'+esc(d.avg_pnl!=null?d.avg_pnl:(c.avg_pnl||0))+'</td><td class="reason">'+esc(d.gate_reason||'等待样本验证')+'</td></tr>';}).join('')+'</tbody></table>'; }
|
||||
function renderDryRun(dry){ var items=dry.evaluated_candidates||[]; if(!items.length){$('dryrun').innerHTML='<div class="empty">暂无待验证规律可评估</div>';return;} $('dryrun').innerHTML='<div class="gate-text">当前版本 '+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?'是':'否')+'。</div><div class="gate-text"><b>灰度标准:</b>'+esc((dry.gate_policy&&dry.gate_policy.gray)||'--')+'</div><table class="table"><thead><tr><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>平均表现</th><th>原因</th></tr></thead><tbody>'+items.map(function(x){return '<tr><td>'+badge(x.dry_run_status||'candidate')+'</td><td class="rule-name">'+esc(x.rule_description||x.signal_name||'--')+'</td><td>'+esc(x.sample_size||0)+'</td><td>'+esc(x.success_count||0)+' / '+esc(x.fail_count||0)+'</td><td class="score">'+esc(x.confidence_score||0)+'</td><td>'+esc(x.avg_pnl||0)+'</td><td class="reason">'+esc(x.gate_reason||'--')+'</td></tr>';}).join('')+'</tbody></table>'; }
|
||||
function renderFailures(d){ var fs=(d.overview&&d.overview.failure_type_counts)||[]; $('failureSummary').innerHTML=fs.length?fs.map(function(f){return '<span class="failure-chip">'+esc(f.type)+' · '+esc(f.count)+'</span>';}).join(''):'<div class="empty">暂无失败模式</div>'; var items=d.failures||[]; $('failures').innerHTML=items.length?items.slice(0,30).map(function(f){return '<div class="item warn"><b>'+esc(f.symbol||'--')+'</b> · '+esc(f.failure_type||'未分类')+' · '+esc((f.failure_reason||'').slice(0,90))+' · PnL '+esc(f.pnl_pct||0)+'</div>';}).join(''):'<div class="empty">暂无失败样本</div>'; }
|
||||
function renderVersions(items){ if(!items.length){$('versions').innerHTML='<div class="empty">暂无版本表现</div>';return;} $('versions').innerHTML='<table class="table"><thead><tr><th>版本</th><th>推荐数</th><th>成功</th><th>失败</th><th>待观察</th><th>成功率</th><th>均值收益</th></tr></thead><tbody>'+items.map(function(v){return '<tr><td class="rule-name">'+esc(v.strategy_version)+'</td><td>'+esc(v.recommendation_count)+'</td><td>'+esc(v.success_count)+'</td><td>'+esc(v.failed_count)+'</td><td>'+esc(v.pending_count)+'</td><td class="score">'+esc(v.success_rate_pct)+'</td><td>'+esc(v.avg_pnl_pct)+'</td></tr>';}).join('')+'</tbody></table>'; }
|
||||
function renderCandidates(items,dry){ if(!items.length){$('candidates').innerHTML='<div class="empty">暂无待验证规律</div>';return;} var dryMap={}; (dry.evaluated_candidates||[]).forEach(function(x){dryMap[x.id]=x;}); $('candidates').innerHTML='<table class="table"><thead><tr><th>来源</th><th>当前阶段</th><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>机会表现</th><th>为什么还没发布</th></tr></thead><tbody>'+items.map(function(c){var d=dryMap[c.id]||{};return '<tr><td><span class="tag">'+esc(sourceLabel(c))+'</span></td><td>'+badge(c.status||'candidate')+'</td><td>'+badge(d.dry_run_status||c.status||'candidate')+'</td><td class="rule-name">'+esc(c.rule_description||c.signal_name||'--')+'</td><td>'+esc(d.sample_size!=null?d.sample_size:(c.sample_size||0))+'</td><td>'+esc(d.success_count!=null?d.success_count:(c.success_count||0))+' / '+esc(d.fail_count!=null?d.fail_count:(c.fail_count||0))+'</td><td class="score">'+esc(d.confidence_score!=null?d.confidence_score:(c.confidence_score||0))+'</td><td>'+esc(d.avg_pnl!=null?d.avg_pnl:(c.avg_pnl||0))+'</td><td class="reason">'+esc(d.gate_reason||'等待样本验证')+'</td></tr>';}).join('')+'</tbody></table>'; }
|
||||
function renderDryRun(dry){ var items=dry.evaluated_candidates||[]; if(!items.length){$('dryrun').innerHTML='<div class="empty">暂无待验证规律可评估</div>';return;} $('dryrun').innerHTML='<div class="gate-text">当前版本 '+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?'是':'否')+'。</div><div class="gate-text"><b>灰度标准:</b>'+esc((dry.gate_policy&&dry.gate_policy.gray)||'--')+'</div><table class="table"><thead><tr><th>预演结论</th><th>规律</th><th>样本</th><th>成功/失败</th><th>可信度</th><th>机会表现</th><th>原因</th></tr></thead><tbody>'+items.map(function(x){return '<tr><td>'+badge(x.dry_run_status||'candidate')+'</td><td class="rule-name">'+esc(x.rule_description||x.signal_name||'--')+'</td><td>'+esc(x.sample_size||0)+'</td><td>'+esc(x.success_count||0)+' / '+esc(x.fail_count||0)+'</td><td class="score">'+esc(x.confidence_score||0)+'</td><td>'+esc(x.avg_pnl||0)+'</td><td class="reason">'+esc(x.gate_reason||'--')+'</td></tr>';}).join('')+'</tbody></table>'; }
|
||||
function renderFailures(d){ var fs=(d.overview&&d.overview.failure_type_counts)||[]; $('failureSummary').innerHTML=fs.length?fs.map(function(f){return '<span class="failure-chip">'+esc(f.type)+' · '+esc(f.count)+'</span>';}).join(''):'<div class="empty">暂无失败模式</div>'; var items=d.failures||[]; $('failures').innerHTML=items.length?items.slice(0,30).map(function(f){return '<div class="item warn"><b>'+esc(f.symbol||'--')+'</b> · '+esc(f.failure_type||'未分类')+' · '+esc((f.failure_reason||'').slice(0,90))+' · 机会表现 '+esc(f.pnl_pct||0)+'</div>';}).join(''):'<div class="empty">暂无失败样本</div>'; }
|
||||
function renderVersions(items){ if(!items.length){$('versions').innerHTML='<div class="empty">暂无版本机会表现</div>';return;} $('versions').innerHTML='<table class="table"><thead><tr><th>版本</th><th>机会数</th><th>成功</th><th>失败</th><th>待观察</th><th>机会成功率</th><th>机会均值</th></tr></thead><tbody>'+items.map(function(v){return '<tr><td class="rule-name">'+esc(v.strategy_version)+'</td><td>'+esc(v.recommendation_count)+'</td><td>'+esc(v.success_count)+'</td><td>'+esc(v.failed_count)+'</td><td>'+esc(v.pending_count)+'</td><td class="score">'+esc(v.success_rate_pct)+'</td><td>'+esc(v.avg_pnl_pct)+'</td></tr>';}).join('')+'</tbody></table>'; }
|
||||
loadAll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
64
static/review_center.html
Normal file
64
static/review_center.html
Normal file
@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}复盘中心 — AlphaX Agent{% endblock %}
|
||||
{% block extra_head_css %}
|
||||
<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}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>复盘中心</h1>
|
||||
<p>把机会发现、模拟交易收益、多源证据和策略迭代拆开看。收益只看模拟交易;机会归档只看发现和确认质量。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<select class="select" id="daysSel" onchange="loadAll()">
|
||||
<option value="7">近 7 天</option>
|
||||
<option value="30" selected>近 30 天</option>
|
||||
<option value="60">近 60 天</option>
|
||||
<option value="120">近 120 天</option>
|
||||
</select>
|
||||
<button class="btn" onclick="loadAll()">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="principles" id="principles"><div class="loading">加载中...</div></div>
|
||||
<div class="grid">
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><div class="panel-title">机会复盘</div><div class="panel-desc" id="oppDef">--</div></div><span class="badge warn">不算收益</span></div>
|
||||
<div class="panel-body" id="opportunityPanel"><div class="loading">加载中...</div></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><div class="panel-title">模拟交易复盘</div><div class="panel-desc" id="paperDef">--</div></div><span class="badge ok">唯一收益口径</span></div>
|
||||
<div class="panel-body" id="paperPanel"><div class="loading">加载中...</div></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><div class="panel-title">多源证据归因</div><div class="panel-desc" id="evidenceDef">--</div></div><span class="badge warn">只做证据</span></div>
|
||||
<div class="panel-body" id="evidencePanel"><div class="loading">加载中...</div></div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div><div class="panel-title">策略迭代闸门</div><div class="panel-desc" id="iterationDef">--</div></div><span class="badge warn">候选假设</span></div>
|
||||
<div class="panel-body" id="iterationPanel"><div class="loading">加载中...</div></div>
|
||||
</section>
|
||||
<section class="panel full">
|
||||
<div class="panel-head"><div><div class="panel-title">最近关键样本</div><div class="panel-desc">方便快速定位是机会问题、执行问题,还是证据源问题。</div></div></div>
|
||||
<div class="panel-body" id="recentPanel"><div class="loading">加载中...</div></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
var API='';function $(id){return document.getElementById(id)}function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}function num(v,d){return Number(v||0).toFixed(d==null?0:d)}function pct(v){return (Number(v||0)>=0?'+':'')+Number(v||0).toFixed(2)+'%'}function usd(v){v=Number(v||0);return (v>=0?'+':'-')+'$'+Math.abs(v).toFixed(2)}function time(t){if(!t)return '--';var d=new Date(t);if(isNaN(d.getTime()))return t;return (d.getMonth()+1)+'/'+d.getDate()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)}
|
||||
function kpis(items){return '<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 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 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 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)}
|
||||
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>'})}}
|
||||
loadAll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,32 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}策略 — AlphaX Agent{% endblock %}
|
||||
{% block title %}策略归因 — AlphaX Agent{% endblock %}
|
||||
{% block extra_head_css %}
|
||||
<style>
|
||||
.shell{width:min(100% - 40px,1180px);margin:0 auto;padding:24px 0 48px}.page-head{margin-bottom:20px}.page-head h1{font-size:28px;letter-spacing:-.8px}.page-head p{color:var(--stone);font-size:14px;margin-top:4px}.metrics{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin-bottom:12px}.metric{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-xl);padding:18px;text-align:center}.metric .num{font-size:30px;font-weight:900;letter-spacing:-.8px}.metric .lbl{font-size:12px;color:var(--stone);font-weight:700;margin-top:4px}.disclaimer{font-size:12px;color:var(--stone);background:var(--surface);border:1px solid var(--hairline-soft);border-radius:var(--radius-lg);padding:10px 14px;margin-bottom:16px}.flow{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px}.flow-step{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-lg);padding:12px}.flow-step b{display:block;color:var(--ink);font-size:13px;margin-bottom:5px}.flow-step span{display:block;color:var(--stone);font-size:12px;line-height:1.55}.flow-link{color:var(--primary);font-weight:900;text-decoration:none}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-xl);padding:18px;margin-bottom:16px}.panel h2{font-size:16px;margin-bottom:12px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:9px 8px;border-bottom:1px solid var(--hairline-soft);font-size:12px;text-align:left}.table th{color:var(--stone);font-weight:800}.pos{color:var(--green);font-weight:800}.neg{color:var(--red);font-weight:800}.tag{display:inline-flex;border-radius:var(--radius-full);background:var(--surface);padding:3px 8px;font-weight:800}.empty{color:var(--stone);font-size:13px;padding:16px;background:var(--surface);border-radius:var(--radius-lg)}@media(max-width:980px){.metrics{grid-template-columns:repeat(2,1fr)}.flow{grid-template-columns:repeat(2,1fr)}}@media(max-width:820px){.grid{grid-template-columns:1fr}.shell{width:min(100% - 24px,1180px)}}@media(max-width:520px){.flow{grid-template-columns:1fr}}
|
||||
.shell{width:min(100% - 40px,1280px);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;letter-spacing:0;color:var(--ink)}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.6;max-width:850px}.pill{display:inline-flex;align-items:center;height:30px;border-radius:999px;border:1px solid rgba(66,98,255,.16);background:rgba(66,98,255,.06);padding:0 10px;color:var(--blue);font-size:12px;font-weight:950}.kpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-bottom:14px}.kpi{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);padding:13px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:10px;font-weight:950}.kpi b{display:block;margin-top:6px;color:var(--ink);font-size:22px;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)}.note{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.6;margin-bottom:14px}.flow{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:14px}.flow-step{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);padding:12px}.flow-step b{display:block;color:var(--ink);font-size:13px;font-weight:950}.flow-step span{display:block;margin-top:4px;color:var(--stone);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.full{grid-column:1/-1}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:950;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);font-weight:850}.panel-body{padding:12px}.table-wrap{overflow:auto}.table{width:100%;border-collapse:collapse;min-width:760px}.table th,.table td{padding:10px 9px;border-bottom:1px solid var(--hairline-soft);text-align:left;font-size:12px;vertical-align:middle}.table th{font-size:11px;color:var(--stone);font-weight:950;background:var(--surface)}.table tr:last-child td{border-bottom:0}.tag{display:inline-flex;align-items:center;max-width:320px;border-radius:999px;border:1px solid var(--hairline-soft);background:var(--surface);padding:4px 8px;color:var(--ink);font-size:11px;font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.num{font-weight:950;color:var(--ink)}.green{color:var(--green)}.red{color:var(--red)}.muted{color:var(--stone)}.empty,.loading{padding:30px 14px;text-align:center;color:var(--stone);font-size:13px}@media(max-width:1100px){.kpis{grid-template-columns:repeat(3,minmax(0,1fr))}.grid,.flow{grid-template-columns:1fr}}@media(max-width:620px){.shell{width:min(100% - 24px,1280px)}.page-head h1{font-size:22px}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="shell">
|
||||
<div class="page-head"><h1>策略</h1><p>系统可信度、版本表现、因子归因与市场环境归因。</p></div>
|
||||
<div class="metrics" id="metrics"></div>
|
||||
<div class="disclaimer">数据基于历史信号跟踪,仅用于策略研究与模型评估,不构成收益承诺或投资建议。</div>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>策略归因</h1>
|
||||
<p>看清哪些因子、环境和证据源更容易把机会推进到可执行,以及是否真的进入模拟交易。这里不把机会价格波动当作交易收益。</p>
|
||||
</div>
|
||||
<span class="pill">收益只来自模拟交易</span>
|
||||
</div>
|
||||
<div class="kpis" id="metrics"><div class="loading">加载中...</div></div>
|
||||
<div class="note" id="definition">策略归因只看机会转化和模拟交易转化;收益只来自 paper_trades。</div>
|
||||
<div class="flow">
|
||||
<div class="flow-step"><b>发现</b><span>粗筛只进观察/候选,不直接计交易绩效。</span></div>
|
||||
<div class="flow-step"><b>确认</b><span>只有新鲜触发和入场计划达标才进入可交易口径。</span></div>
|
||||
<div class="flow-step"><b>复盘</b><span>只用48h窗口样本更新有效表现。</span></div>
|
||||
<div class="flow-step"><b>迭代</b><span>候选规则经发布闸门后才生效。<a class="flow-link" href="/iteration">查看迭代</a></span></div>
|
||||
<div class="flow-step"><b>机会样本</b><span>系统发现并入库的机会,不等于交易。</span></div>
|
||||
<div class="flow-step"><b>可执行转化</b><span>确认层输出现在可买或等回踩。</span></div>
|
||||
<div class="flow-step"><b>模拟执行</b><span>只有 buy_now 被 paper trading 开仓才进入收益账本。</span></div>
|
||||
<div class="flow-step"><b>证据归因</b><span>链上、舆情、技术因子只做贡献分析。</span></div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<section class="panel"><h2>版本表现</h2><div id="versionPerf"></div></section>
|
||||
<section class="panel"><h2>市场环境归因</h2><div id="envPerf"></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="factorPerf"><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="versionPerf"><div class="loading">加载中...</div></div></section>
|
||||
</div>
|
||||
<section class="panel"><h2>因子归因</h2><div id="factorPerf"></div></section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_script %}
|
||||
<script>
|
||||
function pct(x){x=Number(x||0);return (x>=0?'+':'')+x.toFixed(2)+'%'}function cls(x){return Number(x||0)>=0?'pos':'neg'}
|
||||
function table(rows,key){if(!rows||!rows.length)return'<div class="empty">暂无数据</div>';return '<table class="table"><thead><tr><th>名称</th><th>次数</th><th>命中率</th><th>平均表现</th><th>最大表现</th><th>最大回撤</th></tr></thead><tbody>'+rows.map(r=>'<tr><td><span class="tag">'+(r[key]||'--')+'</span></td><td>'+r.total_count+'</td><td>'+Number(r.win_rate_pct||0).toFixed(1)+'%</td><td class="'+cls(r.avg_pnl_pct)+'">'+pct(r.avg_pnl_pct)+'</td><td class="pos">'+pct(r.max_gain_pct)+'</td><td class="neg">'+Number(r.max_drawdown_pct||0).toFixed(2)+'%</td></tr>').join('')+'</tbody></table>'}
|
||||
async function load(){try{var d=await (await fetch('/api/strategy/insights')).json();var o=d.overview||{};var active=await (await fetch('/api/recommendations/active?actionable_only=false&hours=12')).json();metrics.innerHTML=['总信号|'+(o.total_signals||0)+'|','已验证|'+(o.resolved_count||0)+'|','信号命中率|'+Number(o.win_rate_pct||0).toFixed(1)+'%|green','当前有效机会|'+(Array.isArray(active)?active.length:0)+'|blue','最大表现|'+pct(o.max_gain_pct)+'|green','最大回撤|'+Number(o.max_drawdown_pct||0).toFixed(1)+'%|red'].map(s=>{var a=s.split('|');return '<div class="metric"><div class="num" '+(a[2]?'style="color:var(--'+a[2]+')"':'')+'>'+a[1]+'</div><div class="lbl">'+a[0]+'</div></div>'}).join('');versionPerf.innerHTML=table(d.version_performance,'strategy_version');envPerf.innerHTML=table(d.market_environment,'environment');factorPerf.innerHTML=table(d.factor_attribution,'factor')}catch(e){metrics.innerHTML='<div class="empty">加载失败</div>'}}load();
|
||||
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
||||
function pct(x){return Number(x||0).toFixed(1)+'%'}function usd(x){x=Number(x||0);return (x>=0?'+':'-')+'$'+Math.abs(x).toFixed(2)}
|
||||
function table(rows,key){if(!rows||!rows.length)return'<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();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -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__':
|
||||
|
||||
82
tests/test_review_center.py
Normal file
82
tests/test_review_center.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user