alphax/app/db/review_center.py
2026-06-07 20:58:35 +08:00

384 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 CEX events/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.intraday_frequency import get_intraday_frequency_health
from app.db.schema import get_conn
from app.db.strategy_insights import get_strategy_evaluation, get_strategy_insights
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 _dedupe_by_symbol(rows, limit=0):
items = []
seen = set()
for row in rows:
symbol = str(row.get("symbol") or "").strip().upper()
key = symbol or f"row:{row.get('id')}"
if key in seen:
continue
seen.add(key)
items.append(row)
if limit and len(items) >= int(limit):
break
return items
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_raw = [dict(r) for r in conn.execute(
"""
SELECT *
FROM missed_explosions
WHERE detect_time >= %s
ORDER BY gain_pct DESC, detect_time DESC
LIMIT 200
""",
(since,),
).fetchall()]
missed_rows = _dedupe_by_symbol(missed_rows_raw, limit=20)
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)
strategy_insights = get_strategy_insights(days=days)
trade_attribution = (strategy_insights.get("trade_attribution") or {})
watch_order_attribution = (strategy_insights.get("watch_order_attribution") or {})
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": "策略交易复盘是唯一收益口径,基于交易账本的开仓、平仓、移动止盈事件。",
"summary": summary,
"exit_reasons": exit_reasons,
"event_types": event_types,
"trade_attribution": trade_attribution,
"watch_order_attribution": watch_order_attribution,
"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()]
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()]
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": "多源归因只判断证据贡献CEX 事件/舆情、LLM 是否帮助发现/解释机会,不直接生成交易收益。",
"summary": {
"news_count": len(news_rows),
"actionable_news_count": len(actionable_news),
"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"),
"llm_status": _bucket_count(llm_rows, "status", "unknown"),
"recent_news": news_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", []),
("changed_rules_json", []),
("candidate_rules_json", []),
):
item[field.replace("_json", "")] = _loads(item.get(field), fallback)
digest = _iteration_digest(logs, candidates)
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"),
"digest": digest,
"recent_logs": logs,
"recent_candidates": candidates[:12],
}
def _iteration_digest(logs, candidates):
latest = logs[0] if logs else {}
upgraded = []
downgraded = []
released = []
reviewed = []
for log in logs[:8]:
for rule in log.get("changed_rules") or []:
if not isinstance(rule, dict):
continue
rtype = rule.get("type") or ""
action = rule.get("action") or ""
signal = rule.get("signal") or rule.get("signal_name") or ""
if rtype == "factor_weight_governance":
item = {
"time": log.get("created_at") or "",
"signal": signal,
"action": action,
"old_weight": rule.get("old_weight"),
"new_weight": rule.get("new_weight"),
"sample_size": rule.get("sample_size"),
"hit_rate": rule.get("hit_rate"),
"avg_pnl": rule.get("avg_pnl"),
}
if action == "升权":
upgraded.append(item)
else:
downgraded.append(item)
elif rtype == "signal_deprecation":
downgraded.append({
"time": log.get("created_at") or "",
"signal": signal or "低绩效因子",
"action": "淘汰/降权候选",
"detail": rule.get("detail") or "",
})
elif rtype == "candidate_release":
released.append({
"time": log.get("created_at") or "",
"candidate_id": rule.get("candidate_id"),
"rule_id": rule.get("rule_id"),
"description": rule.get("description") or "",
"version": rule.get("new_version") or log.get("strategy_version") or "",
})
reviewed.append({
"time": log.get("created_at") or "",
"title": log.get("title") or "",
"decision": log.get("release_decision") or "hold",
"reason": log.get("release_reason") or log.get("summary") or "",
"metrics": log.get("metrics") or {},
})
gray = [
{
"id": c.get("id"),
"signal": c.get("signal_name") or "",
"type": c.get("rule_type") or "",
"description": c.get("rule_description") or "",
"sample_size": c.get("sample_size") or 0,
"confidence": c.get("confidence_score") or 0,
}
for c in candidates
if c.get("status") == "gray"
]
active = [c for c in candidates if c.get("status") == "active"]
return {
"latest": {
"time": latest.get("created_at") or "",
"title": latest.get("title") or "暂无策略迭代",
"decision": latest.get("release_decision") or "hold",
"reason": latest.get("release_reason") or "",
"strategy_version": latest.get("strategy_version") or "",
"metrics": latest.get("metrics") or {},
},
"upgraded": upgraded[:8],
"downgraded": downgraded[:8],
"gray": gray[:8],
"released": released[:8],
"active_count": len(active),
"timeline": reviewed[:6],
}
def get_review_center_dashboard(days=30):
days = max(1, min(_safe_int(days, 30), 365))
since = _since(days)
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)
strategy_evaluation = get_strategy_evaluation(days=days)
intraday_frequency = get_intraday_frequency_health(days=min(days, 7))
finally:
conn.close()
return {
"days": days,
"generated_at": datetime.now().isoformat(timespec="seconds"),
"principles": [
"机会归档不计算交易收益,只记录发现、确认、失效和漏选。",
"真实收益口径只来自策略交易或未来真实交易账本。",
"CEX 事件/舆情、LLM 属于证据层,只做发现和解释,不直接改变推荐状态。",
"策略迭代只发布经过样本约束和灰度闸门验证的规则。",
],
"opportunity": opportunity,
"strategy_evaluation": strategy_evaluation,
"intraday_frequency": intraday_frequency,
"paper_trading": paper,
"evidence": evidence,
"iteration": iteration,
}
__all__ = ["get_review_center_dashboard"]