alphax/app/db/review_center.py
2026-05-20 00:57:46 +08:00

321 lines
12 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 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 _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)
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"]