1
This commit is contained in:
parent
c781dfef08
commit
c8f59e3561
@ -25,10 +25,38 @@ def record_review(rec_id, symbol, outcome, pnl_48h, max_pnl_48h,
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def update_signal_performance(signal_type, category, is_hit, pnl):
|
def update_signal_performance(signal_type, category, is_hit, pnl, weight_override=None):
|
||||||
"""Update rolling signal performance stats after review."""
|
"""Update rolling signal performance stats after review.
|
||||||
|
|
||||||
|
``weight_override`` is used by the daily review governance step to make
|
||||||
|
reviewed factor weights actually affect the next screening run.
|
||||||
|
"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=%s", (signal_type,)).fetchone()
|
row = conn.execute("SELECT * FROM signal_performance WHERE signal_type=%s", (signal_type,)).fetchone()
|
||||||
|
if weight_override is not None:
|
||||||
|
weight = max(0.0, float(weight_override or 0))
|
||||||
|
if row:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE signal_performance
|
||||||
|
SET weight=%s, last_updated=%s
|
||||||
|
WHERE signal_type=%s
|
||||||
|
""",
|
||||||
|
(weight, datetime.now().isoformat(), signal_type),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO signal_performance (
|
||||||
|
signal_type, category, total_count, hit_count, miss_count,
|
||||||
|
hit_rate, avg_pnl, weight, last_updated
|
||||||
|
) VALUES (%s, %s, 0, 0, 0, 0, 0, %s, %s)
|
||||||
|
""",
|
||||||
|
(signal_type, category or "", weight, datetime.now().isoformat()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
total = row["total_count"] + 1
|
total = row["total_count"] + 1
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from app.db.paper_trading import get_paper_trading_summary
|
from app.db.paper_trading import get_paper_trading_summary
|
||||||
from app.db.schema import get_conn
|
from app.db.schema import get_conn
|
||||||
|
from app.db.strategy_insights import get_strategy_insights
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(value, default=0):
|
def _safe_int(value, default=0):
|
||||||
@ -139,6 +140,7 @@ def _opportunity_review(conn, since):
|
|||||||
|
|
||||||
def _paper_review(conn, since, days):
|
def _paper_review(conn, since, days):
|
||||||
summary = get_paper_trading_summary(days=days)
|
summary = get_paper_trading_summary(days=days)
|
||||||
|
trade_attribution = (get_strategy_insights().get("trade_attribution") or {})
|
||||||
trades = [dict(r) for r in conn.execute(
|
trades = [dict(r) for r in conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT *
|
SELECT *
|
||||||
@ -166,6 +168,7 @@ def _paper_review(conn, since, days):
|
|||||||
"summary": summary,
|
"summary": summary,
|
||||||
"exit_reasons": exit_reasons,
|
"exit_reasons": exit_reasons,
|
||||||
"event_types": event_types,
|
"event_types": event_types,
|
||||||
|
"trade_attribution": trade_attribution,
|
||||||
"recent_trades": trades,
|
"recent_trades": trades,
|
||||||
"recent_events": events,
|
"recent_events": events,
|
||||||
}
|
}
|
||||||
@ -269,9 +272,11 @@ def _iteration_review(conn, since):
|
|||||||
("findings_json", []),
|
("findings_json", []),
|
||||||
("problems_json", []),
|
("problems_json", []),
|
||||||
("actions_json", []),
|
("actions_json", []),
|
||||||
|
("changed_rules_json", []),
|
||||||
("candidate_rules_json", []),
|
("candidate_rules_json", []),
|
||||||
):
|
):
|
||||||
item[field.replace("_json", "")] = _loads(item.get(field), fallback)
|
item[field.replace("_json", "")] = _loads(item.get(field), fallback)
|
||||||
|
digest = _iteration_digest(logs, candidates)
|
||||||
return {
|
return {
|
||||||
"definition": "策略迭代只产生候选假设和发布闸门结论,不直接等于收益提升。",
|
"definition": "策略迭代只产生候选假设和发布闸门结论,不直接等于收益提升。",
|
||||||
"summary": {
|
"summary": {
|
||||||
@ -284,11 +289,93 @@ def _iteration_review(conn, since):
|
|||||||
},
|
},
|
||||||
"release_decisions": _bucket_count(logs, "release_decision", "unknown"),
|
"release_decisions": _bucket_count(logs, "release_decision", "unknown"),
|
||||||
"candidate_status": _bucket_count(candidates, "status", "candidate"),
|
"candidate_status": _bucket_count(candidates, "status", "candidate"),
|
||||||
|
"digest": digest,
|
||||||
"recent_logs": logs,
|
"recent_logs": logs,
|
||||||
"recent_candidates": candidates[:12],
|
"recent_candidates": candidates[:12],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _iteration_digest(logs, candidates):
|
||||||
|
latest = logs[0] if logs else {}
|
||||||
|
upgraded = []
|
||||||
|
downgraded = []
|
||||||
|
released = []
|
||||||
|
reviewed = []
|
||||||
|
for log in logs[:8]:
|
||||||
|
for rule in log.get("changed_rules") or []:
|
||||||
|
if not isinstance(rule, dict):
|
||||||
|
continue
|
||||||
|
rtype = rule.get("type") or ""
|
||||||
|
action = rule.get("action") or ""
|
||||||
|
signal = rule.get("signal") or rule.get("signal_name") or ""
|
||||||
|
if rtype == "factor_weight_governance":
|
||||||
|
item = {
|
||||||
|
"time": log.get("created_at") or "",
|
||||||
|
"signal": signal,
|
||||||
|
"action": action,
|
||||||
|
"old_weight": rule.get("old_weight"),
|
||||||
|
"new_weight": rule.get("new_weight"),
|
||||||
|
"sample_size": rule.get("sample_size"),
|
||||||
|
"hit_rate": rule.get("hit_rate"),
|
||||||
|
"avg_pnl": rule.get("avg_pnl"),
|
||||||
|
}
|
||||||
|
if action == "升权":
|
||||||
|
upgraded.append(item)
|
||||||
|
else:
|
||||||
|
downgraded.append(item)
|
||||||
|
elif rtype == "signal_deprecation":
|
||||||
|
downgraded.append({
|
||||||
|
"time": log.get("created_at") or "",
|
||||||
|
"signal": signal or "低绩效因子",
|
||||||
|
"action": "淘汰/降权候选",
|
||||||
|
"detail": rule.get("detail") or "",
|
||||||
|
})
|
||||||
|
elif rtype == "candidate_release":
|
||||||
|
released.append({
|
||||||
|
"time": log.get("created_at") or "",
|
||||||
|
"candidate_id": rule.get("candidate_id"),
|
||||||
|
"rule_id": rule.get("rule_id"),
|
||||||
|
"description": rule.get("description") or "",
|
||||||
|
"version": rule.get("new_version") or log.get("strategy_version") or "",
|
||||||
|
})
|
||||||
|
reviewed.append({
|
||||||
|
"time": log.get("created_at") or "",
|
||||||
|
"title": log.get("title") or "",
|
||||||
|
"decision": log.get("release_decision") or "hold",
|
||||||
|
"reason": log.get("release_reason") or log.get("summary") or "",
|
||||||
|
"metrics": log.get("metrics") or {},
|
||||||
|
})
|
||||||
|
gray = [
|
||||||
|
{
|
||||||
|
"id": c.get("id"),
|
||||||
|
"signal": c.get("signal_name") or "",
|
||||||
|
"type": c.get("rule_type") or "",
|
||||||
|
"description": c.get("rule_description") or "",
|
||||||
|
"sample_size": c.get("sample_size") or 0,
|
||||||
|
"confidence": c.get("confidence_score") or 0,
|
||||||
|
}
|
||||||
|
for c in candidates
|
||||||
|
if c.get("status") == "gray"
|
||||||
|
]
|
||||||
|
active = [c for c in candidates if c.get("status") == "active"]
|
||||||
|
return {
|
||||||
|
"latest": {
|
||||||
|
"time": latest.get("created_at") or "",
|
||||||
|
"title": latest.get("title") or "暂无策略迭代",
|
||||||
|
"decision": latest.get("release_decision") or "hold",
|
||||||
|
"reason": latest.get("release_reason") or "",
|
||||||
|
"strategy_version": latest.get("strategy_version") or "",
|
||||||
|
"metrics": latest.get("metrics") or {},
|
||||||
|
},
|
||||||
|
"upgraded": upgraded[:8],
|
||||||
|
"downgraded": downgraded[:8],
|
||||||
|
"gray": gray[:8],
|
||||||
|
"released": released[:8],
|
||||||
|
"active_count": len(active),
|
||||||
|
"timeline": reviewed[:6],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_review_center_dashboard(days=30):
|
def get_review_center_dashboard(days=30):
|
||||||
days = max(1, min(_safe_int(days, 30), 365))
|
days = max(1, min(_safe_int(days, 30), 365))
|
||||||
since = _since(days)
|
since = _since(days)
|
||||||
|
|||||||
@ -44,12 +44,18 @@ def get_strategy_insights():
|
|||||||
r.*,
|
r.*,
|
||||||
pt.id AS paper_trade_id,
|
pt.id AS paper_trade_id,
|
||||||
pt.status AS paper_status,
|
pt.status AS paper_status,
|
||||||
|
pt.side AS paper_side,
|
||||||
|
pt.source_status AS paper_source_status,
|
||||||
|
pt.source_action AS paper_source_action,
|
||||||
pt.realized_pnl_pct AS paper_realized_pnl_pct,
|
pt.realized_pnl_pct AS paper_realized_pnl_pct,
|
||||||
pt.realized_pnl_usdt AS paper_realized_pnl_usdt,
|
pt.realized_pnl_usdt AS paper_realized_pnl_usdt,
|
||||||
pt.pnl_pct AS paper_pnl_pct,
|
pt.pnl_pct AS paper_pnl_pct,
|
||||||
pt.exit_reason AS paper_exit_reason
|
pt.exit_reason AS paper_exit_reason,
|
||||||
|
po.id AS paper_order_id,
|
||||||
|
po.status AS paper_order_status
|
||||||
FROM recommendation r
|
FROM recommendation r
|
||||||
LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id
|
LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id
|
||||||
|
LEFT JOIN paper_orders po ON po.recommendation_id = r.id
|
||||||
ORDER BY r.rec_time DESC, r.id DESC
|
ORDER BY r.rec_time DESC, r.id DESC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@ -110,6 +116,12 @@ def get_strategy_insights():
|
|||||||
env_map = {}
|
env_map = {}
|
||||||
version_map = {}
|
version_map = {}
|
||||||
evidence_map = {}
|
evidence_map = {}
|
||||||
|
trade_factor_map = {}
|
||||||
|
trade_entry_map = {}
|
||||||
|
trade_exit_map = {}
|
||||||
|
trade_env_map = {}
|
||||||
|
trade_evidence_map = {}
|
||||||
|
trade_version_map = {}
|
||||||
for item in items:
|
for item in items:
|
||||||
labels = safe_list_json(item.get("signal_labels_json")) or safe_list_json(item.get("signals"))
|
labels = safe_list_json(item.get("signal_labels_json")) or safe_list_json(item.get("signals"))
|
||||||
codes = safe_list_json(item.get("signal_codes_json"))
|
codes = safe_list_json(item.get("signal_codes_json"))
|
||||||
@ -129,6 +141,24 @@ def get_strategy_insights():
|
|||||||
add_bucket(env_map, bucket, item)
|
add_bucket(env_map, bucket, item)
|
||||||
if item.get("strategy_version"):
|
if item.get("strategy_version"):
|
||||||
add_bucket(version_map, str(item.get("strategy_version")).strip(), item)
|
add_bucket(version_map, str(item.get("strategy_version")).strip(), item)
|
||||||
|
if item.get("paper_status") == "closed":
|
||||||
|
for factor in labels:
|
||||||
|
add_trade_bucket(trade_factor_map, str(factor).strip(), item)
|
||||||
|
add_trade_bucket(trade_entry_map, trade_entry_bucket(item), item)
|
||||||
|
add_trade_bucket(trade_exit_map, item.get("paper_exit_reason") or "未记录退出原因", item)
|
||||||
|
add_trade_bucket(trade_entry_map, f"方向:{item.get('paper_side') or item.get('side') or 'long'}", item)
|
||||||
|
if item.get("paper_order_id"):
|
||||||
|
add_trade_bucket(trade_entry_map, f"挂单路径:{item.get('paper_order_status') or 'filled'}", item)
|
||||||
|
for bucket in env_buckets_from_market_context(mc):
|
||||||
|
add_trade_bucket(trade_env_map, bucket, item)
|
||||||
|
for code in codes:
|
||||||
|
text = str(code or "").strip()
|
||||||
|
if text.startswith(("sentiment_", "listing_", "ecosystem_")):
|
||||||
|
add_trade_bucket(trade_evidence_map, "舆情:" + text, item)
|
||||||
|
elif text.startswith(("dex_", "liquidity_", "exchange_", "whale_", "smart_money", "holder_", "onchain_")):
|
||||||
|
add_trade_bucket(trade_evidence_map, "链上:" + text, item)
|
||||||
|
if item.get("strategy_version"):
|
||||||
|
add_trade_bucket(trade_version_map, str(item.get("strategy_version")).strip(), item)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"overview": overview,
|
"overview": overview,
|
||||||
@ -142,9 +172,55 @@ def get_strategy_insights():
|
|||||||
"market_environment": serialize_buckets("environment", env_map)[:20],
|
"market_environment": serialize_buckets("environment", env_map)[:20],
|
||||||
"evidence_attribution": serialize_buckets("evidence", evidence_map)[:20],
|
"evidence_attribution": serialize_buckets("evidence", evidence_map)[:20],
|
||||||
"version_performance": serialize_buckets("strategy_version", version_map, sort_by_version=True)[:20],
|
"version_performance": serialize_buckets("strategy_version", version_map, sort_by_version=True)[:20],
|
||||||
|
"trade_attribution": {
|
||||||
|
"definition": "交易级归因只统计已平仓策略交易,用 realized_pnl_usdt / realized_pnl_pct 衡量因子、入场路径、退出原因和环境的真实账本表现。",
|
||||||
|
"factor": serialize_trade_buckets("factor", trade_factor_map)[:30],
|
||||||
|
"entry_path": serialize_trade_buckets("entry_path", trade_entry_map)[:20],
|
||||||
|
"exit_reason": serialize_trade_buckets("exit_reason", trade_exit_map)[:20],
|
||||||
|
"market_environment": serialize_trade_buckets("environment", trade_env_map)[:20],
|
||||||
|
"evidence": serialize_trade_buckets("evidence", trade_evidence_map)[:20],
|
||||||
|
"strategy_version": serialize_trade_buckets("strategy_version", trade_version_map, sort_by_version=True)[:20],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_trade_bucket(bucket_map, key, item):
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
b = bucket_map.setdefault(key, {
|
||||||
|
"closed_trade_count": 0,
|
||||||
|
"win_count": 0,
|
||||||
|
"loss_count": 0,
|
||||||
|
"realized_pnl_usdt": 0.0,
|
||||||
|
"pnl_pct_values": [],
|
||||||
|
"best_pnl_pct": None,
|
||||||
|
"worst_pnl_pct": None,
|
||||||
|
})
|
||||||
|
pnl_pct = float(item.get("paper_realized_pnl_pct") or 0)
|
||||||
|
pnl_usdt = float(item.get("paper_realized_pnl_usdt") or 0)
|
||||||
|
b["closed_trade_count"] += 1
|
||||||
|
b["realized_pnl_usdt"] += pnl_usdt
|
||||||
|
b["pnl_pct_values"].append(pnl_pct)
|
||||||
|
if pnl_pct > 0:
|
||||||
|
b["win_count"] += 1
|
||||||
|
elif pnl_pct < 0:
|
||||||
|
b["loss_count"] += 1
|
||||||
|
b["best_pnl_pct"] = pnl_pct if b["best_pnl_pct"] is None else max(b["best_pnl_pct"], pnl_pct)
|
||||||
|
b["worst_pnl_pct"] = pnl_pct if b["worst_pnl_pct"] is None else min(b["worst_pnl_pct"], pnl_pct)
|
||||||
|
|
||||||
|
|
||||||
|
def trade_entry_bucket(item):
|
||||||
|
source = str(item.get("paper_source_status") or item.get("execution_status") or "").strip()
|
||||||
|
action = str(item.get("paper_source_action") or item.get("action_status") or "").strip()
|
||||||
|
if source == "wait_pullback" or action == "等回踩":
|
||||||
|
return "入场:回踩挂单成交"
|
||||||
|
if source == "buy_now" or action == "可即刻买入":
|
||||||
|
return "入场:现价确认"
|
||||||
|
if source:
|
||||||
|
return f"入场:{source}"
|
||||||
|
return "入场:未标记"
|
||||||
|
|
||||||
|
|
||||||
def env_buckets_from_market_context(mc):
|
def env_buckets_from_market_context(mc):
|
||||||
"""Convert market_context_json numeric fields into attribution buckets."""
|
"""Convert market_context_json numeric fields into attribution buckets."""
|
||||||
buckets = []
|
buckets = []
|
||||||
@ -217,6 +293,29 @@ def serialize_buckets(name_key, bucket_map, sort_by_version=False):
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_trade_buckets(name_key, bucket_map, sort_by_version=False):
|
||||||
|
rows = []
|
||||||
|
for key, bucket in bucket_map.items():
|
||||||
|
pnl_values = bucket["pnl_pct_values"]
|
||||||
|
closed = bucket["closed_trade_count"]
|
||||||
|
rows.append({
|
||||||
|
name_key: key,
|
||||||
|
"closed_trade_count": closed,
|
||||||
|
"win_count": bucket["win_count"],
|
||||||
|
"loss_count": bucket["loss_count"],
|
||||||
|
"win_rate_pct": round(bucket["win_count"] / closed * 100, 1) if closed else 0,
|
||||||
|
"realized_pnl_usdt": round(bucket["realized_pnl_usdt"], 4),
|
||||||
|
"avg_realized_pnl_pct": round(sum(pnl_values) / len(pnl_values), 2) if pnl_values else 0,
|
||||||
|
"best_pnl_pct": round(bucket["best_pnl_pct"] or 0, 2),
|
||||||
|
"worst_pnl_pct": round(bucket["worst_pnl_pct"] or 0, 2),
|
||||||
|
})
|
||||||
|
if sort_by_version:
|
||||||
|
rows.sort(key=lambda x: (version_sort_key(x[name_key]), x["closed_trade_count"]), reverse=True)
|
||||||
|
else:
|
||||||
|
rows.sort(key=lambda x: (-x["closed_trade_count"], -x["realized_pnl_usdt"], x[name_key]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def version_sort_key(version: str):
|
def version_sort_key(version: str):
|
||||||
text = str(version or '').strip()
|
text = str(version or '').strip()
|
||||||
if text.startswith('v') or text.startswith('V'):
|
if text.startswith('v') or text.startswith('V'):
|
||||||
|
|||||||
@ -595,6 +595,61 @@ def adjust_signal_weights():
|
|||||||
return adjustments
|
return adjustments
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_daily_factor_weight_governance():
|
||||||
|
"""Promote good factors and suppress weak factors after each review run.
|
||||||
|
|
||||||
|
This is intentionally conservative: it only uses accumulated clean
|
||||||
|
``signal_performance`` rows, respects minimum sample thresholds, and writes
|
||||||
|
the resulting weight through ``update_signal_weight`` so the screener reads
|
||||||
|
it on the next run.
|
||||||
|
"""
|
||||||
|
thresholds = _get_thresholds()
|
||||||
|
weights = get_signal_weights()
|
||||||
|
min_samples = max(3, int(thresholds.get("min_samples_for_weight", 3) or 3))
|
||||||
|
kill_min_samples = max(min_samples, int(thresholds.get("kill_min_samples", 5) or 5))
|
||||||
|
kill_hit_rate = float(thresholds.get("hit_rate_kill_threshold", 0.10) or 0.10) * 100
|
||||||
|
warn_hit_rate = float((thresholds.get("signal_deprecation") or {}).get("hit_rate_warn_threshold", 0.20) or 0.20) * 100
|
||||||
|
category_base = thresholds.get("category_base_weights") or {"前瞻": 2.0, "PA": 1.5, "滞后": 0.5}
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for sig_type, data in sorted(weights.items()):
|
||||||
|
total = int(data.get("total_count") or 0)
|
||||||
|
if total < min_samples:
|
||||||
|
continue
|
||||||
|
hit_rate = float(data.get("hit_rate") or 0)
|
||||||
|
avg_pnl = float(data.get("avg_pnl") or 0)
|
||||||
|
old_weight = float(data.get("weight") or 0)
|
||||||
|
category = data.get("category") or "未知"
|
||||||
|
base = float(category_base.get(category, 1.0) or 1.0)
|
||||||
|
new_weight = old_weight
|
||||||
|
action = ""
|
||||||
|
|
||||||
|
if total >= kill_min_samples and hit_rate < kill_hit_rate:
|
||||||
|
new_weight = 0.0
|
||||||
|
action = "淘汰"
|
||||||
|
elif hit_rate < warn_hit_rate or avg_pnl <= -3:
|
||||||
|
new_weight = round(max(0.0, old_weight * 0.5), 3)
|
||||||
|
action = "降权"
|
||||||
|
elif hit_rate >= 55 and avg_pnl > 0:
|
||||||
|
target = round(min(4.0, max(old_weight, hit_rate / 50 * base)), 3)
|
||||||
|
if target > old_weight:
|
||||||
|
new_weight = target
|
||||||
|
action = "升权"
|
||||||
|
|
||||||
|
if action and abs(new_weight - old_weight) >= 0.001:
|
||||||
|
update_signal_weight(sig_type, new_weight)
|
||||||
|
changes.append({
|
||||||
|
"signal": sig_type,
|
||||||
|
"action": action,
|
||||||
|
"old_weight": round(old_weight, 4),
|
||||||
|
"new_weight": round(new_weight, 4),
|
||||||
|
"sample_size": total,
|
||||||
|
"hit_rate": round(hit_rate, 2),
|
||||||
|
"avg_pnl": round(avg_pnl, 2),
|
||||||
|
})
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
# ==================== 2.5 信号淘汰机制 ====================
|
# ==================== 2.5 信号淘汰机制 ====================
|
||||||
|
|
||||||
def _deprecate_low_performance_signals():
|
def _deprecate_low_performance_signals():
|
||||||
@ -1240,6 +1295,17 @@ def _build_iteration_log(results, current_meta, now, config_diff=None, effect_su
|
|||||||
if len(version_change_parts) < 4:
|
if len(version_change_parts) < 4:
|
||||||
version_change_parts.append(f"权重调整:{adj}")
|
version_change_parts.append(f"权重调整:{adj}")
|
||||||
|
|
||||||
|
for upd in results.get("factor_weight_updates", [])[:8]:
|
||||||
|
detail = (
|
||||||
|
f"{upd.get('signal')}: {upd.get('action')} "
|
||||||
|
f"{upd.get('old_weight')}→{upd.get('new_weight')} "
|
||||||
|
f"(样本{upd.get('sample_size')}, 命中{upd.get('hit_rate')}%, 均值{upd.get('avg_pnl')}%)"
|
||||||
|
)
|
||||||
|
actions.append(detail)
|
||||||
|
changed_rules.append({"type": "factor_weight_governance", **upd})
|
||||||
|
if len(version_change_parts) < 4:
|
||||||
|
version_change_parts.append(f"因子{upd.get('action')}:{upd.get('signal')}")
|
||||||
|
|
||||||
for rule in results.get("new_learned_rules", [])[:6]:
|
for rule in results.get("new_learned_rules", [])[:6]:
|
||||||
desc = rule.get("description", "")
|
desc = rule.get("description", "")
|
||||||
if desc:
|
if desc:
|
||||||
@ -1320,6 +1386,7 @@ def _build_iteration_log(results, current_meta, now, config_diff=None, effect_su
|
|||||||
"insufficient_tracking_count": insufficient_count,
|
"insufficient_tracking_count": insufficient_count,
|
||||||
"missed_explosions": len(results.get("missed_explosions", [])),
|
"missed_explosions": len(results.get("missed_explosions", [])),
|
||||||
"weight_adjustments": len(results.get("weight_adjustments", [])),
|
"weight_adjustments": len(results.get("weight_adjustments", [])),
|
||||||
|
"factor_weight_updates": len(results.get("factor_weight_updates", [])),
|
||||||
"signal_deprecations": len(results.get("signal_deprecations", [])),
|
"signal_deprecations": len(results.get("signal_deprecations", [])),
|
||||||
"candidate_rules": len(results.get("candidate_rules", [])),
|
"candidate_rules": len(results.get("candidate_rules", [])),
|
||||||
"new_learned_rules": 0,
|
"new_learned_rules": 0,
|
||||||
@ -1427,6 +1494,7 @@ def run_review(push_enabled: bool = True, compact: bool = False):
|
|||||||
"reviews_done": 0,
|
"reviews_done": 0,
|
||||||
"review_details": [],
|
"review_details": [],
|
||||||
"weight_adjustments": [],
|
"weight_adjustments": [],
|
||||||
|
"factor_weight_updates": [],
|
||||||
"signal_deprecations": [],
|
"signal_deprecations": [],
|
||||||
"missed_explosions": [],
|
"missed_explosions": [],
|
||||||
"new_learned_rules": [],
|
"new_learned_rules": [],
|
||||||
@ -1444,6 +1512,7 @@ def run_review(push_enabled: bool = True, compact: bool = False):
|
|||||||
|
|
||||||
# 2. 信号权重调整
|
# 2. 信号权重调整
|
||||||
results["weight_adjustments"] = adjust_signal_weights()
|
results["weight_adjustments"] = adjust_signal_weights()
|
||||||
|
results["factor_weight_updates"] = _apply_daily_factor_weight_governance()
|
||||||
|
|
||||||
# 2.5 信号淘汰机制(低命中率信号自动淘汰/降权)
|
# 2.5 信号淘汰机制(低命中率信号自动淘汰/降权)
|
||||||
results["signal_deprecations"] = _deprecate_low_performance_signals()
|
results["signal_deprecations"] = _deprecate_low_performance_signals()
|
||||||
@ -1487,6 +1556,7 @@ def run_review(push_enabled: bool = True, compact: bool = False):
|
|||||||
f"爆发{hit_count} 横盘{flat_count} 失败{fail_count} | "
|
f"爆发{hit_count} 横盘{flat_count} 失败{fail_count} | "
|
||||||
f"漏选爆发{len(results['missed_explosions'])}只 | "
|
f"漏选爆发{len(results['missed_explosions'])}只 | "
|
||||||
f"权重调整{len(results['weight_adjustments'])}项 | "
|
f"权重调整{len(results['weight_adjustments'])}项 | "
|
||||||
|
f"因子生效调整{len(results['factor_weight_updates'])}项 | "
|
||||||
f"信号淘汰{len(results['signal_deprecations'])}项 | "
|
f"信号淘汰{len(results['signal_deprecations'])}项 | "
|
||||||
f"候选规则{len(results.get('candidate_rules', []))}条\n"
|
f"候选规则{len(results.get('candidate_rules', []))}条\n"
|
||||||
f"信号绩效排名: {', '.join(sig_summary[:5])}"
|
f"信号绩效排名: {', '.join(sig_summary[:5])}"
|
||||||
|
|||||||
@ -187,8 +187,6 @@ a { color: inherit; text-decoration: none; }
|
|||||||
<a class="sidebar-link admin-link {% if active_nav == 'review_center' %}active{% endif %}" href="/review-center" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>复盘中心</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'review_center' %}active{% endif %}" href="/review-center" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>复盘中心</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav in ['logs','pipeline','system_logs','chat_logs'] %}active{% endif %}" href="/logs" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>日志中心</a>
|
<a class="sidebar-link admin-link {% if active_nav in ['logs','pipeline','system_logs','chat_logs'] %}active{% endif %}" href="/logs" style="display:none"><svg class="link-icon"><use href="#svg-pipeline"/></svg>日志中心</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'strategy' %}active{% endif %}" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略归因</a>
|
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'iteration' %}active{% endif %}" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>策略迭代</a>
|
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'data_export' %}active{% endif %}" href="/data-export" style="display:none"><svg class="link-icon"><use href="#svg-export"/></svg>数据导出</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'data_export' %}active{% endif %}" href="/data-export" style="display:none"><svg class="link-icon"><use href="#svg-export"/></svg>数据导出</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
|
||||||
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
{% block title %}复盘中心 — AlphaX Agent{% endblock %}
|
{% block title %}复盘中心 — AlphaX Agent{% endblock %}
|
||||||
{% block extra_head_css %}
|
{% block extra_head_css %}
|
||||||
<style>
|
<style>
|
||||||
.shell{width:min(100% - 40px,1320px);margin:0 auto;padding:24px 0 48px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:0}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:840px}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.select,.btn{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.btn{cursor:pointer}.principles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:14px}.principle{border:1px solid rgba(66,98,255,.14);background:rgba(66,98,255,.045);border-radius:var(--radius-md);padding:11px 12px;color:var(--slate);font-size:12px;line-height:1.55}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden;min-width:0}.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:15px;font-weight:950;color:var(--ink)}.panel-desc{margin-top:4px;color:var(--stone);font-size:11px;line-height:1.45;max-width:720px}.panel-body{padding:14px}.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:12px}.kpi{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:10px;font-weight:950}.kpi b{display:block;margin-top:5px;color:var(--ink);font-size:20px;line-height:1;font-weight:950;letter-spacing:0}.kpi b.green{color:var(--green)}.kpi b.red{color:var(--red)}.kpi b.blue{color:var(--blue)}.list{display:grid;gap:8px}.row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:9px 10px}.row-main{min-width:0}.row-title{font-size:12px;font-weight:950;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.row-sub{margin-top:3px;color:var(--stone);font-size:11px;line-height:1.45;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.value{font-size:12px;font-weight:950;color:var(--slate);white-space:nowrap}.value.green{color:var(--green)}.value.red{color:var(--red)}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}.mini-title{font-size:12px;font-weight:950;color:var(--ink);margin-bottom:8px}.empty,.loading{padding:30px 14px;text-align:center;color:var(--stone);font-size:13px}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;border:1px solid var(--hairline-soft);background:var(--surface);padding:0 8px;font-size:11px;font-weight:900;color:var(--slate);white-space:nowrap}.badge.ok{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.warn{background:var(--yellow-light);border-color:rgba(252,185,0,.22);color:var(--yellow-dark)}.badge.bad{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}.full{grid-column:1/-1}@media(max-width:1100px){.grid,.principles{grid-template-columns:1fr}.split{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:620px){.shell{width:min(100% - 24px,1320px)}.page-head h1{font-size:22px}.kpis{grid-template-columns:1fr}}
|
.shell{width:min(100% - 40px,1320px);margin:0 auto;padding:24px 0 48px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:16px}.page-head h1{font-size:28px;font-weight:950;color:var(--ink);letter-spacing:0}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:840px}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.select,.btn{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.btn{cursor:pointer}.strategy-digest{border:1px solid rgba(28,28,30,.1);background:linear-gradient(135deg,#fffdf7 0%,#f7fbff 56%,#f8fff8 100%);border-radius:18px;padding:16px;margin-bottom:14px;box-shadow:0 18px 50px rgba(15,23,42,.06)}.digest-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:12px}.digest-title{font-size:18px;font-weight:950;color:var(--ink)}.digest-sub{margin-top:4px;font-size:12px;color:var(--stone);line-height:1.55}.digest-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr 1fr;gap:10px}.digest-card{border:1px solid var(--hairline-soft);background:rgba(255,255,255,.72);border-radius:14px;padding:12px;min-width:0}.digest-card h3{font-size:12px;font-weight:950;color:var(--ink);margin-bottom:8px}.digest-item{border-top:1px solid var(--hairline-soft);padding:8px 0}.digest-item:first-of-type{border-top:0}.digest-item b{display:block;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.digest-item span{display:block;margin-top:3px;font-size:11px;color:var(--stone);line-height:1.45}.digest-empty{font-size:12px;color:var(--stone);padding:10px 0}.principles{display:none}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden;min-width:0}.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:15px;font-weight:950;color:var(--ink)}.panel-desc{margin-top:4px;color:var(--stone);font-size:11px;line-height:1.45;max-width:720px}.panel-body{padding:14px}.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:12px}.kpi{border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:10px;min-width:0}.kpi span{display:block;color:var(--stone);font-size:10px;font-weight:950}.kpi b{display:block;margin-top:5px;color:var(--ink);font-size:20px;line-height:1;font-weight:950;letter-spacing:0}.kpi b.green{color:var(--green)}.kpi b.red{color:var(--red)}.kpi b.blue{color:var(--blue)}.list{display:grid;gap:8px}.row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;border:1px solid var(--hairline-soft);background:var(--surface);border-radius:var(--radius-md);padding:9px 10px}.row-main{min-width:0}.row-title{font-size:12px;font-weight:950;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.row-sub{margin-top:3px;color:var(--stone);font-size:11px;line-height:1.45;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.value{font-size:12px;font-weight:950;color:var(--slate);white-space:nowrap}.value.green{color:var(--green)}.value.red{color:var(--red)}.split{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:12px}.mini-title{font-size:12px;font-weight:950;color:var(--ink);margin-bottom:8px}.empty,.loading{padding:30px 14px;text-align:center;color:var(--stone);font-size:13px}.badge{display:inline-flex;align-items:center;height:24px;border-radius:999px;border:1px solid var(--hairline-soft);background:var(--surface);padding:0 8px;font-size:11px;font-weight:900;color:var(--slate);white-space:nowrap}.badge.ok{background:var(--green-light);border-color:rgba(0,180,115,.18);color:var(--green)}.badge.warn{background:var(--yellow-light);border-color:rgba(252,185,0,.22);color:var(--yellow-dark)}.badge.bad{background:var(--red-light);border-color:rgba(229,62,62,.18);color:var(--red)}.full{grid-column:1/-1}@media(max-width:1100px){.grid,.principles,.digest-grid{grid-template-columns:1fr}.split{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:620px){.shell{width:min(100% - 24px,1320px)}.page-head h1{font-size:22px}.kpis{grid-template-columns:1fr}.digest-head{display:block}.digest-head .badge{margin-top:8px}}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -22,6 +22,7 @@
|
|||||||
<button class="btn" onclick="loadAll()">刷新</button>
|
<button class="btn" onclick="loadAll()">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="strategy-digest" id="strategyDigest"><div class="loading">加载中...</div></section>
|
||||||
<div class="principles" id="principles"><div class="loading">加载中...</div></div>
|
<div class="principles" id="principles"><div class="loading">加载中...</div></div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@ -52,13 +53,15 @@
|
|||||||
var API='';function $(id){return document.getElementById(id)}function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}function num(v,d){return Number(v||0).toFixed(d==null?0:d)}function pct(v){return (Number(v||0)>=0?'+':'')+Number(v||0).toFixed(2)+'%'}function usd(v){v=Number(v||0);return (v>=0?'+':'-')+'$'+Math.abs(v).toFixed(2)}function time(t){if(!t)return '--';var d=new Date(t);if(isNaN(d.getTime()))return t;return (d.getMonth()+1)+'/'+d.getDate()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)}
|
var API='';function $(id){return document.getElementById(id)}function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}function num(v,d){return Number(v||0).toFixed(d==null?0:d)}function pct(v){return (Number(v||0)>=0?'+':'')+Number(v||0).toFixed(2)+'%'}function usd(v){v=Number(v||0);return (v>=0?'+':'-')+'$'+Math.abs(v).toFixed(2)}function time(t){if(!t)return '--';var d=new Date(t);if(isNaN(d.getTime()))return t;return (d.getMonth()+1)+'/'+d.getDate()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)}
|
||||||
function kpis(items){return '<div class="kpis">'+items.map(function(x){return '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('')+'</div>'}
|
function kpis(items){return '<div class="kpis">'+items.map(function(x){return '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('')+'</div>'}
|
||||||
function rows(items,label,value,sub){items=items||[];if(!items.length)return '<div class="empty">暂无数据</div>';return '<div class="list">'+items.map(function(x){return '<div class="row"><div class="row-main"><div class="row-title">'+esc(label(x))+'</div><div class="row-sub">'+esc(sub?sub(x):'')+'</div></div><div class="value">'+esc(value?value(x):'')+'</div></div>'}).join('')+'</div>'}
|
function rows(items,label,value,sub){items=items||[];if(!items.length)return '<div class="empty">暂无数据</div>';return '<div class="list">'+items.map(function(x){return '<div class="row"><div class="row-main"><div class="row-title">'+esc(label(x))+'</div><div class="row-sub">'+esc(sub?sub(x):'')+'</div></div><div class="value">'+esc(value?value(x):'')+'</div></div>'}).join('')+'</div>'}
|
||||||
|
function digestItems(items,label,sub){items=items||[];if(!items.length)return '<div class="digest-empty">暂无动作</div>';return items.slice(0,4).map(function(x){return '<div class="digest-item"><b>'+esc(label(x))+'</b><span>'+esc(sub?sub(x):'')+'</span></div>'}).join('')}
|
||||||
|
function renderStrategyDigest(d){var it=d.iteration||{},dig=it.digest||{},latest=dig.latest||{},m=latest.metrics||{},decision=latest.decision||'hold';var badgeCls=decision==='release'?'ok':decision==='gray'?'warn':'warn';$('strategyDigest').innerHTML='<div class="digest-head"><div><div class="digest-title">策略迭代摘要</div><div class="digest-sub">'+esc(latest.title||'暂无复盘')+' · '+time(latest.time)+' · 版本 '+esc(latest.strategy_version||'--')+'</div></div><span class="badge '+badgeCls+'">'+esc(decision)+'</span></div><div class="kpis">'+[['因子生效调整',m.factor_weight_updates||0,'blue'],['候选 / 灰度',(it.summary&&it.summary.candidate_count||0)+' / '+(it.summary&&it.summary.gray_count||0),''],['本轮有效复盘',m.effective_review_count||0,''],['发布状态',latest.reason||'继续观察','']].map(function(x){return '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('')+'</div><div class="digest-grid"><div class="digest-card"><h3>升权了什么</h3>'+digestItems(dig.upgraded,function(x){return x.signal||'--'},function(x){return '权重 '+x.old_weight+' → '+x.new_weight+' · 样本 '+(x.sample_size||0)+' · 命中 '+(x.hit_rate||0)+'%'})+'</div><div class="digest-card"><h3>降权 / 淘汰</h3>'+digestItems(dig.downgraded,function(x){return x.signal||'--'},function(x){return (x.action||'调整')+' · '+(x.old_weight!=null?'权重 '+x.old_weight+' → '+x.new_weight:x.detail||'')})+'</div><div class="digest-card"><h3>灰度观察</h3>'+digestItems(dig.gray,function(x){return x.signal||('候选 #'+x.id)},function(x){return '样本 '+(x.sample_size||0)+' · 置信 '+(x.confidence||0)+'% · '+(x.description||'')})+'</div><div class="digest-card"><h3>最近迭代</h3>'+digestItems(dig.timeline,function(x){return time(x.time)+' · '+(x.decision||'hold')},function(x){return x.reason||x.title||''})+'</div></div>'}
|
||||||
function renderOpportunity(o){var s=o.summary||{};$('oppDef').textContent=o.definition||'';$('opportunityPanel').innerHTML=kpis([['机会样本',s.total_opportunities||0,'blue'],['可买/等回踩',(s.buy_now_count||0)+' / '+(s.wait_pullback_count||0),''],['策略执行',s.paper_executed_count||0,'green'],['漏选爆发',s.missed_explosion_count||0,'red'],['有效复盘',s.effective_review_count||0,''],['机会命中',num(s.opportunity_hit_rate,1)+'%','green'],['观察样本',s.observe_count||0,''],['失效样本',s.invalid_count||0,'red']])+'<div class="split"><div><div class="mini-title">状态分布</div>'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">复盘结果</div>'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
|
function renderOpportunity(o){var s=o.summary||{};$('oppDef').textContent=o.definition||'';$('opportunityPanel').innerHTML=kpis([['机会样本',s.total_opportunities||0,'blue'],['可买/等回踩',(s.buy_now_count||0)+' / '+(s.wait_pullback_count||0),''],['策略执行',s.paper_executed_count||0,'green'],['漏选爆发',s.missed_explosion_count||0,'red'],['有效复盘',s.effective_review_count||0,''],['机会命中',num(s.opportunity_hit_rate,1)+'%','green'],['观察样本',s.observe_count||0,''],['失效样本',s.invalid_count||0,'red']])+'<div class="split"><div><div class="mini-title">状态分布</div>'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">复盘结果</div>'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
|
||||||
function renderPaper(p){var s=p.summary||{};$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['开仓/平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'<div class="split"><div><div class="mini-title">退出原因</div>'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">执行事件</div>'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
|
function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},tf=ta.factor||[],te=ta.entry_path||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['开仓/平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'<div class="split"><div><div class="mini-title">退出原因</div>'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">执行事件</div>'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">真实交易因子</div>'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'</div><div><div class="mini-title">入场路径表现</div>'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'</div></div>'}
|
||||||
function renderEvidence(e){var s=e.summary||{};$('evidenceDef').textContent=e.definition||'';$('evidencePanel').innerHTML=kpis([['新闻事件',s.news_count||0,'blue'],['有效舆情',s.actionable_news_count||0,''],['链上信号',s.onchain_signal_count||0,'blue'],['高置信链上',s.high_confidence_onchain_count||0,'green'],['原始链上',s.raw_onchain_count||0,''],['已映射原始',s.mapped_raw_onchain_count||0,'green'],['LLM 调用',s.llm_runs||0,''],['LLM 成功',s.llm_success_count||0,'green']])+'<div class="split"><div><div class="mini-title">链上信号</div>'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">舆情决策</div>'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'</div></div>'}
|
function renderEvidence(e){var s=e.summary||{};$('evidenceDef').textContent=e.definition||'';$('evidencePanel').innerHTML=kpis([['新闻事件',s.news_count||0,'blue'],['有效舆情',s.actionable_news_count||0,''],['链上信号',s.onchain_signal_count||0,'blue'],['高置信链上',s.high_confidence_onchain_count||0,'green'],['原始链上',s.raw_onchain_count||0,''],['已映射原始',s.mapped_raw_onchain_count||0,'green'],['LLM 调用',s.llm_runs||0,''],['LLM 成功',s.llm_success_count||0,'green']])+'<div class="split"><div><div class="mini-title">链上信号</div>'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">舆情决策</div>'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'</div></div>'}
|
||||||
function renderIteration(i){var s=i.summary||{};$('iterationDef').textContent=i.definition||'';$('iterationPanel').innerHTML=kpis([['迭代记录',s.iteration_count||0,'blue'],['候选规则',s.candidate_count||0,''],['灰度规则',s.gray_count||0,'green'],['生效规则',s.active_count||0,'green']])+'<div class="row" style="margin-bottom:10px"><div class="row-main"><div class="row-title">最新发布结论:'+esc(s.latest_release_decision||'hold')+'</div><div class="row-sub">'+esc(s.latest_release_reason||'暂无发布说明')+'</div></div><div class="value">闸门</div></div><div class="split"><div><div class="mini-title">发布决策</div>'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">候选状态</div>'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
|
function renderIteration(i){var s=i.summary||{};$('iterationDef').textContent=i.definition||'';$('iterationPanel').innerHTML=kpis([['迭代记录',s.iteration_count||0,'blue'],['候选规则',s.candidate_count||0,''],['灰度规则',s.gray_count||0,'green'],['生效规则',s.active_count||0,'green']])+'<div class="row" style="margin-bottom:10px"><div class="row-main"><div class="row-title">最新发布结论:'+esc(s.latest_release_decision||'hold')+'</div><div class="row-sub">'+esc(s.latest_release_reason||'暂无发布说明')+'</div></div><div class="value">闸门</div></div><div class="split"><div><div class="mini-title">发布决策</div>'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">候选状态</div>'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
|
||||||
function renderRecent(d){var opp=(d.opportunity&&d.opportunity.missed_explosions)||[], trades=(d.paper_trading&&d.paper_trading.recent_trades)||[], news=(d.evidence&&d.evidence.recent_news)||[], chain=(d.evidence&&d.evidence.recent_onchain)||[];$('recentPanel').innerHTML='<div class="split"><div><div class="mini-title">最近策略交易</div>'+rows(trades.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.status||'--')},function(x){return x.status==='closed'?pct(x.realized_pnl_pct):pct(x.pnl_pct)},function(x){return time(x.opened_at)+' · '+(x.exit_reason||x.source_status||'')})+'</div><div><div class="mini-title">漏选爆发</div>'+rows(opp.slice(0,8),function(x){return x.symbol||'--'},function(x){return pct(x.gain_pct)},function(x){return time(x.detect_time)+' · '+(x.reason_missed||'')})+'</div><div><div class="mini-title">舆情事件</div>'+rows(news.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.title||'--')},function(x){return x.importance||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.decision||'未处理')})+'</div><div><div class="mini-title">链上信号</div>'+rows(chain.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.signal_label||x.signal_code||'--')},function(x){return x.severity||x.confidence||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.direction||'')})+'</div></div>'}
|
function renderRecent(d){var opp=(d.opportunity&&d.opportunity.missed_explosions)||[], trades=(d.paper_trading&&d.paper_trading.recent_trades)||[], news=(d.evidence&&d.evidence.recent_news)||[], chain=(d.evidence&&d.evidence.recent_onchain)||[];$('recentPanel').innerHTML='<div class="split"><div><div class="mini-title">最近策略交易</div>'+rows(trades.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.status||'--')},function(x){return x.status==='closed'?pct(x.realized_pnl_pct):pct(x.pnl_pct)},function(x){return time(x.opened_at)+' · '+(x.exit_reason||x.source_status||'')})+'</div><div><div class="mini-title">漏选爆发</div>'+rows(opp.slice(0,8),function(x){return x.symbol||'--'},function(x){return pct(x.gain_pct)},function(x){return time(x.detect_time)+' · '+(x.reason_missed||'')})+'</div><div><div class="mini-title">舆情事件</div>'+rows(news.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.title||'--')},function(x){return x.importance||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.decision||'未处理')})+'</div><div><div class="mini-title">链上信号</div>'+rows(chain.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.signal_label||x.signal_code||'--')},function(x){return x.severity||x.confidence||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.direction||'')})+'</div></div>'}
|
||||||
function render(d){$('principles').innerHTML=(d.principles||[]).map(function(x){return '<div class="principle">'+esc(x)+'</div>'}).join('');renderOpportunity(d.opportunity||{});renderPaper(d.paper_trading||{});renderEvidence(d.evidence||{});renderIteration(d.iteration||{});renderRecent(d)}
|
function render(d){renderStrategyDigest(d);$('principles').innerHTML=(d.principles||[]).map(function(x){return '<div class="principle">'+esc(x)+'</div>'}).join('');renderOpportunity(d.opportunity||{});renderPaper(d.paper_trading||{});renderEvidence(d.evidence||{});renderIteration(d.iteration||{});renderRecent(d)}
|
||||||
async function loadAll(){try{var days=$('daysSel').value;var d=await (await fetch(API+'/api/review-center/dashboard?days='+days+'&_ts='+Date.now(),{cache:'no-store'})).json();render(d)}catch(e){['principles','opportunityPanel','paperPanel','evidencePanel','iterationPanel','recentPanel'].forEach(function(id){$(id).innerHTML='<div class="empty">加载失败</div>'})}}
|
async function loadAll(){try{var days=$('daysSel').value;var d=await (await fetch(API+'/api/review-center/dashboard?days='+days+'&_ts='+Date.now(),{cache:'no-store'})).json();render(d)}catch(e){['strategyDigest','principles','opportunityPanel','paperPanel','evidencePanel','iterationPanel','recentPanel'].forEach(function(id){$(id).innerHTML='<div class="empty">加载失败</div>'})}}
|
||||||
loadAll();
|
loadAll();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
<section class="panel"><div class="panel-head"><div class="panel-title">市场环境归因</div><div class="panel-note">环境 -> 转化</div></div><div class="panel-body" id="envPerf"><div class="loading">加载中...</div></div></section>
|
<section class="panel"><div class="panel-head"><div class="panel-title">市场环境归因</div><div class="panel-note">环境 -> 转化</div></div><div class="panel-body" id="envPerf"><div class="loading">加载中...</div></div></section>
|
||||||
<section class="panel"><div class="panel-head"><div class="panel-title">证据源归因</div><div class="panel-note">链上 / 舆情</div></div><div class="panel-body" id="evidencePerf"><div class="loading">加载中...</div></div></section>
|
<section class="panel"><div class="panel-head"><div class="panel-title">证据源归因</div><div class="panel-note">链上 / 舆情</div></div><div class="panel-body" id="evidencePerf"><div class="loading">加载中...</div></div></section>
|
||||||
<section class="panel"><div class="panel-head"><div class="panel-title">版本归因</div><div class="panel-note">版本 -> 转化</div></div><div class="panel-body" id="versionPerf"><div class="loading">加载中...</div></div></section>
|
<section class="panel"><div class="panel-head"><div class="panel-title">版本归因</div><div class="panel-note">版本 -> 转化</div></div><div class="panel-body" id="versionPerf"><div class="loading">加载中...</div></div></section>
|
||||||
|
<section class="panel full"><div class="panel-head"><div><div class="panel-title">交易级因子归因</div><div class="panel-note" id="tradeDef">只统计已平仓策略交易,用真实账本收益评价因子、入场路径、退出原因和环境。</div></div></div><div class="panel-body" id="tradePerf"><div class="loading">加载中...</div></div></section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -35,6 +36,8 @@
|
|||||||
function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]})}
|
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 pct(x){return Number(x||0).toFixed(1)+'%'}function usd(x){x=Number(x||0);return (x>=0?'+':'-')+'$'+Math.abs(x).toFixed(2)}
|
||||||
function table(rows,key){if(!rows||!rows.length)return'<div class="empty">暂无数据</div>';return '<div class="table-wrap"><table class="table"><thead><tr><th>归因项</th><th>机会</th><th>可执行</th><th>现买</th><th>策略执行</th><th>执行转化</th><th>策略胜率</th><th>策略已实现</th></tr></thead><tbody>'+rows.map(function(r){return '<tr><td><span class="tag">'+esc(r[key]||'--')+'</span></td><td class="num">'+esc(r.opportunity_count||0)+'</td><td>'+esc(r.actionable_count||0)+'</td><td>'+esc(r.buy_now_count||0)+'</td><td>'+esc(r.paper_trade_count||0)+'</td><td class="num">'+pct(r.actionable_conversion_pct)+'</td><td class="num">'+pct(r.paper_win_rate_pct)+'</td><td class="'+(Number(r.paper_realized_pnl_usdt||0)>=0?'green':'red')+'">'+usd(r.paper_realized_pnl_usdt)+'</td></tr>'}).join('')+'</tbody></table></div>'}
|
function table(rows,key){if(!rows||!rows.length)return'<div class="empty">暂无数据</div>';return '<div class="table-wrap"><table class="table"><thead><tr><th>归因项</th><th>机会</th><th>可执行</th><th>现买</th><th>策略执行</th><th>执行转化</th><th>策略胜率</th><th>策略已实现</th></tr></thead><tbody>'+rows.map(function(r){return '<tr><td><span class="tag">'+esc(r[key]||'--')+'</span></td><td class="num">'+esc(r.opportunity_count||0)+'</td><td>'+esc(r.actionable_count||0)+'</td><td>'+esc(r.buy_now_count||0)+'</td><td>'+esc(r.paper_trade_count||0)+'</td><td class="num">'+pct(r.actionable_conversion_pct)+'</td><td class="num">'+pct(r.paper_win_rate_pct)+'</td><td class="'+(Number(r.paper_realized_pnl_usdt||0)>=0?'green':'red')+'">'+usd(r.paper_realized_pnl_usdt)+'</td></tr>'}).join('')+'</tbody></table></div>'}
|
||||||
async function load(){try{var d=await (await fetch('/api/strategy/insights?_ts='+Date.now(),{cache:'no-store'})).json();var o=d.overview||{};definition.textContent=o.definition||'策略归因只看机会转化和策略交易转化。';metrics.innerHTML=[['机会样本',o.total_opportunities||0,'blue'],['可执行机会',o.actionable_count||0,''],['现在可买',o.buy_now_count||0,'green'],['策略执行',o.paper_trade_count||0,'green'],['策略胜率',pct(o.paper_win_rate_pct),''],['已实现收益',usd(o.paper_realized_pnl_usdt),Number(o.paper_realized_pnl_usdt||0)>=0?'green':'red']].map(function(x){return '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('');factorPerf.innerHTML=table(d.factor_attribution,'factor');envPerf.innerHTML=table(d.market_environment,'environment');evidencePerf.innerHTML=table(d.evidence_attribution,'evidence');versionPerf.innerHTML=table(d.version_performance,'strategy_version')}catch(e){metrics.innerHTML='<div class="empty">加载失败</div>'}}load();
|
function tradeTable(title,rows,key){if(!rows||!rows.length)return '<div><div class="panel-note" style="margin:8px 0">'+esc(title)+'</div><div class="empty">暂无已平仓交易样本</div></div>';return '<div><div class="panel-note" style="margin:8px 0">'+esc(title)+'</div><div class="table-wrap"><table class="table"><thead><tr><th>归因项</th><th>平仓交易</th><th>胜率</th><th>已实现收益</th><th>平均收益率</th><th>最好</th><th>最差</th></tr></thead><tbody>'+rows.map(function(r){return '<tr><td><span class="tag">'+esc(r[key]||'--')+'</span></td><td class="num">'+esc(r.closed_trade_count||0)+'</td><td class="num">'+pct(r.win_rate_pct)+'</td><td class="'+(Number(r.realized_pnl_usdt||0)>=0?'green':'red')+'">'+usd(r.realized_pnl_usdt)+'</td><td class="'+(Number(r.avg_realized_pnl_pct||0)>=0?'green':'red')+'">'+pct(r.avg_realized_pnl_pct)+'</td><td class="green">'+pct(r.best_pnl_pct)+'</td><td class="red">'+pct(r.worst_pnl_pct)+'</td></tr>'}).join('')+'</tbody></table></div></div>'}
|
||||||
|
function renderTradeAttribution(t){t=t||{};tradeDef.textContent=t.definition||'交易级归因只统计已平仓策略交易。';tradePerf.innerHTML=tradeTable('因子真实交易表现',t.factor,'factor')+tradeTable('入场路径与方向',t.entry_path,'entry_path')+tradeTable('退出原因',t.exit_reason,'exit_reason')+tradeTable('市场环境',t.market_environment,'environment')}
|
||||||
|
async function load(){try{var d=await (await fetch('/api/strategy/insights?_ts='+Date.now(),{cache:'no-store'})).json();var o=d.overview||{};definition.textContent=o.definition||'策略归因只看机会转化和策略交易转化。';metrics.innerHTML=[['机会样本',o.total_opportunities||0,'blue'],['可执行机会',o.actionable_count||0,''],['现在可买',o.buy_now_count||0,'green'],['策略执行',o.paper_trade_count||0,'green'],['策略胜率',pct(o.paper_win_rate_pct),''],['已实现收益',usd(o.paper_realized_pnl_usdt),Number(o.paper_realized_pnl_usdt||0)>=0?'green':'red']].map(function(x){return '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('');factorPerf.innerHTML=table(d.factor_attribution,'factor');envPerf.innerHTML=table(d.market_environment,'environment');evidencePerf.innerHTML=table(d.evidence_attribution,'evidence');versionPerf.innerHTML=table(d.version_performance,'strategy_version');renderTradeAttribution(d.trade_attribution)}catch(e){metrics.innerHTML='<div class="empty">加载失败</div>'}}load();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -145,6 +145,15 @@ class PersonalizationAndStrategyInsightTests(unittest.TestCase):
|
|||||||
self.assertEqual(factor['paper_trade_count'], 1)
|
self.assertEqual(factor['paper_trade_count'], 1)
|
||||||
self.assertAlmostEqual(factor['paper_realized_pnl_usdt'], 240)
|
self.assertAlmostEqual(factor['paper_realized_pnl_usdt'], 240)
|
||||||
self.assertNotIn('avg_pnl_pct', factor)
|
self.assertNotIn('avg_pnl_pct', factor)
|
||||||
|
trade_factor = next(x for x in insight['trade_attribution']['factor'] if x['factor'] == '底部抬高')
|
||||||
|
self.assertEqual(trade_factor['closed_trade_count'], 1)
|
||||||
|
self.assertEqual(trade_factor['win_count'], 1)
|
||||||
|
self.assertEqual(trade_factor['win_rate_pct'], 100)
|
||||||
|
self.assertAlmostEqual(trade_factor['realized_pnl_usdt'], 240)
|
||||||
|
self.assertAlmostEqual(trade_factor['avg_realized_pnl_pct'], 5)
|
||||||
|
entry_path = next(x for x in insight['trade_attribution']['entry_path'] if x['entry_path'] == '入场:现价确认')
|
||||||
|
self.assertEqual(entry_path['closed_trade_count'], 1)
|
||||||
|
self.assertAlmostEqual(entry_path['realized_pnl_usdt'], 240)
|
||||||
env = next(x for x in insight['market_environment'] if x['environment'] == 'btc_trend:up')
|
env = next(x for x in insight['market_environment'] if x['environment'] == 'btc_trend:up')
|
||||||
self.assertEqual(env['opportunity_count'], 3)
|
self.assertEqual(env['opportunity_count'], 3)
|
||||||
|
|
||||||
|
|||||||
@ -335,17 +335,17 @@ def test_pipeline_page_nav_hides_watchlist_entry_and_watchlist_route_survives(te
|
|||||||
assert watch_resp.status_code == 200
|
assert watch_resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_user_nav_keeps_research_pages_in_admin_section(temp_db):
|
def test_user_nav_keeps_review_center_but_hides_legacy_research_pages(temp_db):
|
||||||
client = TestClient(web_server.app)
|
client = TestClient(web_server.app)
|
||||||
resp = client.get("/app")
|
resp = client.get("/app")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
html = resp.text
|
html = resp.text
|
||||||
|
|
||||||
assert 'href="/watchlist"' not in html
|
assert 'href="/watchlist"' not in html
|
||||||
assert 'href="/pipeline" style="display:none"' in html
|
assert 'href="/logs" style="display:none"' in html
|
||||||
assert 'href="/strategy" style="display:none"' in html
|
assert 'href="/review-center" style="display:none"' in html
|
||||||
assert 'href="/iteration" style="display:none"' in html
|
assert 'href="/strategy"' not in html
|
||||||
assert "研发" in html
|
assert 'href="/iteration"' not in html
|
||||||
|
|
||||||
|
|
||||||
def test_confirm_candidates_prefer_recent_fine_screened_state(temp_db):
|
def test_confirm_candidates_prefer_recent_fine_screened_state(temp_db):
|
||||||
|
|||||||
@ -156,6 +156,25 @@ def test_review_updates_signal_performance_by_code_not_label(temp_review_env):
|
|||||||
assert "1H 量价齐飞K(量3.7x)" not in stats
|
assert "1H 量价齐飞K(量3.7x)" not in stats
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_factor_weight_governance_promotes_and_eliminates(monkeypatch, temp_review_env):
|
||||||
|
changes = []
|
||||||
|
monkeypatch.setattr(review_engine, "update_signal_weight", lambda signal, weight: changes.append((signal, weight)))
|
||||||
|
monkeypatch.setattr(review_engine, "get_signal_weights", lambda: {
|
||||||
|
"good_factor": {"category": "前瞻", "total_count": 12, "hit_rate": 70, "avg_pnl": 4.2, "weight": 1.0},
|
||||||
|
"bad_factor": {"category": "前瞻", "total_count": 12, "hit_rate": 5, "avg_pnl": -2.0, "weight": 1.2},
|
||||||
|
"thin_factor": {"category": "PA", "total_count": 2, "hit_rate": 100, "avg_pnl": 6.0, "weight": 1.0},
|
||||||
|
})
|
||||||
|
|
||||||
|
result = review_engine._apply_daily_factor_weight_governance()
|
||||||
|
|
||||||
|
assert ("good_factor", 2.8) in changes
|
||||||
|
assert ("bad_factor", 0.0) in changes
|
||||||
|
assert all(signal != "thin_factor" for signal, _ in changes)
|
||||||
|
actions = {item["signal"]: item["action"] for item in result}
|
||||||
|
assert actions["good_factor"] == "升权"
|
||||||
|
assert actions["bad_factor"] == "淘汰"
|
||||||
|
|
||||||
|
|
||||||
def test_review_without_tracking_is_not_counted_as_signal_performance(temp_review_env):
|
def test_review_without_tracking_is_not_counted_as_signal_performance(temp_review_env):
|
||||||
db_path = str(temp_review_env)
|
db_path = str(temp_review_env)
|
||||||
rec_id = _insert_rec(db_path)
|
rec_id = _insert_rec(db_path)
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from app.db import auth_db
|
from app.db import auth_db
|
||||||
from app.db.review_center import get_review_center_dashboard
|
from app.db.review_center import get_review_center_dashboard
|
||||||
@ -80,3 +82,69 @@ def test_review_center_separates_opportunity_and_paper_pnl(pg_conn):
|
|||||||
assert "total_pnl_usdt" not in data["opportunity"]["summary"]
|
assert "total_pnl_usdt" not in data["opportunity"]["summary"]
|
||||||
assert data["paper_trading"]["summary"]["closed_count"] == 1
|
assert data["paper_trading"]["summary"]["closed_count"] == 1
|
||||||
assert data["paper_trading"]["summary"]["realized_pnl_usdt"] == 490
|
assert data["paper_trading"]["summary"]["realized_pnl_usdt"] == 490
|
||||||
|
assert data["paper_trading"]["trade_attribution"]["entry_path"][0]["closed_trade_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_center_iteration_digest_summarizes_actions(pg_conn):
|
||||||
|
now = datetime.now().isoformat(timespec="seconds")
|
||||||
|
today = now[:10]
|
||||||
|
changed_rules = [
|
||||||
|
{
|
||||||
|
"type": "factor_weight_governance",
|
||||||
|
"signal": "breakout_pullback_d1",
|
||||||
|
"action": "升权",
|
||||||
|
"old_weight": 1.0,
|
||||||
|
"new_weight": 1.8,
|
||||||
|
"sample_size": 12,
|
||||||
|
"hit_rate": 70,
|
||||||
|
"avg_pnl": 4.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "factor_weight_governance",
|
||||||
|
"signal": "unknown",
|
||||||
|
"action": "降权",
|
||||||
|
"old_weight": 0.3,
|
||||||
|
"new_weight": 0.15,
|
||||||
|
"sample_size": 13,
|
||||||
|
"hit_rate": 15.4,
|
||||||
|
"avg_pnl": 0.9,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
pg_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO strategy_iteration_log (
|
||||||
|
run_date, created_at, title, changed_rules_json, metrics_json,
|
||||||
|
release_decision, release_reason, strategy_version
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, '第1轮复盘迭代',
|
||||||
|
%s, %s, 'hold', '继续观察', 'v1.7.11'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
today,
|
||||||
|
now,
|
||||||
|
json.dumps(changed_rules, ensure_ascii=False),
|
||||||
|
json.dumps({"factor_weight_updates": 2, "effective_review_count": 3}, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pg_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO strategy_rule_candidate (
|
||||||
|
created_at, source, rule_type, signal_name, rule_description,
|
||||||
|
sample_size, confidence_score, status
|
||||||
|
) VALUES (
|
||||||
|
%s, 'dual_attribution_failure', 'penalty',
|
||||||
|
'前瞻信号不足', '失败模式进入灰度', 10, 95, 'gray'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
pg_conn.commit()
|
||||||
|
|
||||||
|
data = get_review_center_dashboard(days=30)
|
||||||
|
digest = data["iteration"]["digest"]
|
||||||
|
|
||||||
|
assert digest["latest"]["metrics"]["factor_weight_updates"] == 2
|
||||||
|
assert digest["upgraded"][0]["signal"] == "breakout_pullback_d1"
|
||||||
|
assert digest["downgraded"][0]["signal"] == "unknown"
|
||||||
|
assert digest["gray"][0]["signal"] == "前瞻信号不足"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user