"""Strategy attribution read model based on opportunity and paper-trading conversion.""" import json import re from datetime import datetime, timedelta from app.core.strategy_registry import normalize_strategy_code, registered_strategy_codes, strategy_definition, strategy_label from app.db.schema import get_conn def safe_list_json(value): try: if isinstance(value, list): return value if isinstance(value, str) and value.strip(): parsed = json.loads(value) return parsed if isinstance(parsed, list) else [] except Exception: pass return [] def safe_dict_json(value): try: if isinstance(value, dict): return value if isinstance(value, str) and value.strip(): parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {} except Exception: pass return {} def _safe_float(value, default=0.0): try: if value is None or value == "": return default return float(value) except Exception: return default def _safe_int(value, default=0): try: if value is None or value == "": return default return int(value) except Exception: return default def _pct(part, total): return round(float(part or 0) / float(total or 0) * 100, 2) if total else 0.0 def _strategy_direction(definition) -> str: direction = str((definition.entry_gate_config or {}).get("direction") or "long").strip().lower() return "short" if direction == "short" else "long" def _direction_label(direction: str) -> str: return "空" if str(direction or "").lower() == "short" else "多" def evaluate_strategy_decision(metrics: dict) -> dict: """Turn strategy metrics into an explicit lifecycle recommendation. This is advisory only. It does not mutate strategy configs; release/pause should still go through the strategy iteration gate. """ signal_count = _safe_int(metrics.get("signal_count")) opportunity_count = _safe_int(metrics.get("opportunity_count")) trade_count = _safe_int(metrics.get("trade_count")) closed_count = _safe_int(metrics.get("closed_trade_count")) win_rate = _safe_float(metrics.get("win_rate_pct")) avg_pnl = _safe_float(metrics.get("avg_realized_pnl_pct")) realized = _safe_float(metrics.get("realized_pnl_usdt")) worst = _safe_float(metrics.get("worst_pnl_pct")) fill_rate = _safe_float(metrics.get("order_fill_rate_pct")) trade_conversion = _safe_float(metrics.get("trade_conversion_pct")) score = 50.0 score += min(signal_count, 40) * 0.25 score += min(opportunity_count, 40) * 0.2 score += min(trade_conversion, 40) * 0.25 score += (win_rate - 50) * 0.35 if closed_count else 0 score += max(-12, min(12, avg_pnl)) * 1.8 score += 6 if realized > 0 else (-6 if realized < 0 else 0) score += 4 if fill_rate >= 40 else 0 score -= 8 if worst <= -8 else 0 score = round(max(0, min(100, score)), 1) reasons = [] next_actions = [] decision = "observe" decision_label = "继续观察" if signal_count < 5 and opportunity_count < 5: decision = "collect_samples" decision_label = "样本不足" reasons.append("信号和机会样本不足,暂不判断优劣") next_actions.append("继续收集样本,不要直接调高权重") elif trade_count == 0: decision = "review_entry_gate" decision_label = "检查入场闸门" reasons.append("已有发现样本,但还没有进入策略交易") next_actions.append("检查 RR、买点距离、全局风控和挂单成交条件") elif closed_count < 5: decision = "gray" decision_label = "灰度观察" reasons.append("已有交易样本,但平仓数量不足 5 笔") next_actions.append("保持 paper-only,等更多已平仓样本") elif win_rate >= 55 and avg_pnl > 0 and realized > 0: decision = "promote" decision_label = "优先保留" reasons.append("胜率、平均收益和已实现收益同时为正") next_actions.append("允许维持或小幅提升策略权重,但仍需观察回撤") elif win_rate < 35 or avg_pnl <= -2 or realized < 0 and worst <= -6: decision = "pause" decision_label = "暂停/降权" reasons.append("胜率或平均收益不达标,且存在较差回撤") next_actions.append("暂停新增真实跟单,只保留观察或降低仓位") elif fill_rate < 15 and opportunity_count >= 10: decision = "tune_entry" decision_label = "优化入场" reasons.append("机会样本不少,但挂单成交或执行转化偏低") next_actions.append("复查挂单价格、有效期、回踩距离和成交触发") else: decision = "keep" decision_label = "保留运行" reasons.append("当前表现没有触发暂停或升级条件") next_actions.append("继续按当前门槛运行并积累样本") return { "decision": decision, "decision_label": decision_label, "evaluation_score": score, "reasons": reasons, "next_actions": next_actions, } def get_strategy_evaluation(days: int = 30) -> dict: days = max(1, min(_safe_int(days, 30), 365)) since = (datetime.now() - timedelta(days=days)).isoformat() codes = registered_strategy_codes() metrics = {} for code in codes: definition = strategy_definition(code) metrics[code] = { "strategy_code": code, "strategy_name": definition.strategy_name, "description": definition.description, "mode": definition.mode, "status": definition.status, "direction": _strategy_direction(definition), "direction_label": _direction_label(_strategy_direction(definition)), "signal_count": 0, "candidate_signal_count": 0, "observe_signal_count": 0, "avg_signal_confidence": 0.0, "opportunity_count": 0, "actionable_count": 0, "buy_now_count": 0, "wait_pullback_count": 0, "observe_count": 0, "order_count": 0, "filled_order_count": 0, "canceled_order_count": 0, "trade_count": 0, "long_trade_count": 0, "short_trade_count": 0, "open_trade_count": 0, "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, } def bucket(code): normalized = normalize_strategy_code(code) if normalized not in metrics: definition = strategy_definition(normalized) direction = _strategy_direction(definition) metrics[normalized] = { "strategy_code": normalized, "strategy_name": definition.strategy_name, "description": definition.description, "mode": definition.mode, "status": definition.status, "direction": direction, "direction_label": _direction_label(direction), "signal_count": 0, "candidate_signal_count": 0, "observe_signal_count": 0, "avg_signal_confidence": 0.0, "opportunity_count": 0, "actionable_count": 0, "buy_now_count": 0, "wait_pullback_count": 0, "observe_count": 0, "order_count": 0, "filled_order_count": 0, "canceled_order_count": 0, "trade_count": 0, "long_trade_count": 0, "short_trade_count": 0, "open_trade_count": 0, "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, } return metrics[normalized] conn = get_conn() try: for row in conn.execute( """ SELECT strategy_code, COUNT(*) AS signal_count, SUM(CASE WHEN signal_status='candidate' THEN 1 ELSE 0 END) AS candidate_count, SUM(CASE WHEN signal_status='observe' THEN 1 ELSE 0 END) AS observe_count, AVG(confidence) AS avg_confidence FROM strategy_signals WHERE created_at >= %s GROUP BY strategy_code """, (since,), ).fetchall(): b = bucket(row.get("strategy_code")) b["signal_count"] = _safe_int(row.get("signal_count")) b["candidate_signal_count"] = _safe_int(row.get("candidate_count")) b["observe_signal_count"] = _safe_int(row.get("observe_count")) b["avg_signal_confidence"] = round(_safe_float(row.get("avg_confidence")), 2) for row in conn.execute( """ SELECT strategy_code, COUNT(*) AS opportunity_count, SUM(CASE WHEN execution_status IN ('buy_now','wait_pullback') THEN 1 ELSE 0 END) AS actionable_count, SUM(CASE WHEN execution_status='buy_now' THEN 1 ELSE 0 END) AS buy_now_count, SUM(CASE WHEN execution_status='wait_pullback' THEN 1 ELSE 0 END) AS wait_pullback_count, SUM(CASE WHEN execution_status='observe' THEN 1 ELSE 0 END) AS observe_count FROM recommendation WHERE rec_time >= %s GROUP BY strategy_code """, (since,), ).fetchall(): b = bucket(row.get("strategy_code")) for key in ("opportunity_count", "actionable_count", "buy_now_count", "wait_pullback_count", "observe_count"): b[key] = _safe_int(row.get(key)) for row in conn.execute( """ SELECT COALESCE(NULLIF(po.strategy_code, ''), r.strategy_code) AS strategy_code, COUNT(*) AS order_count, SUM(CASE WHEN po.status='filled' THEN 1 ELSE 0 END) AS filled_count, SUM(CASE WHEN po.status IN ('canceled','rejected','expired') THEN 1 ELSE 0 END) AS canceled_count FROM paper_orders po LEFT JOIN recommendation r ON r.id = po.recommendation_id WHERE po.created_at >= %s GROUP BY COALESCE(NULLIF(po.strategy_code, ''), r.strategy_code) """, (since,), ).fetchall(): b = bucket(row.get("strategy_code")) b["order_count"] = _safe_int(row.get("order_count")) b["filled_order_count"] = _safe_int(row.get("filled_count")) b["canceled_order_count"] = _safe_int(row.get("canceled_count")) for row in conn.execute( """ SELECT COALESCE(NULLIF(pt.strategy_code, ''), r.strategy_code) AS strategy_code, pt.side, pt.status FROM paper_trades pt LEFT JOIN recommendation r ON r.id = pt.recommendation_id WHERE pt.opened_at >= %s OR (pt.status='closed' AND COALESCE(pt.closed_at, pt.updated_at, pt.opened_at) >= %s) """, (since, since), ).fetchall(): b = bucket(row.get("strategy_code")) status = row.get("status") or "" side = str(row.get("side") or "long").strip().lower() b["trade_count"] += 1 if side == "short": b["short_trade_count"] += 1 else: b["long_trade_count"] += 1 if status == "open": b["open_trade_count"] += 1 for row in conn.execute( """ SELECT COALESCE(NULLIF(pt.strategy_code, ''), r.strategy_code) AS strategy_code, pt.side, pt.realized_pnl_pct, pt.realized_pnl_usdt FROM paper_trades pt LEFT JOIN recommendation r ON r.id = pt.recommendation_id WHERE pt.status='closed' AND COALESCE(pt.closed_at, pt.updated_at, pt.opened_at) >= %s """, (since,), ).fetchall(): b = bucket(row.get("strategy_code")) b["closed_trade_count"] += 1 pnl_pct = _safe_float(row.get("realized_pnl_pct")) pnl_usdt = _safe_float(row.get("realized_pnl_usdt")) 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) finally: conn.close() strategies = [] for item in metrics.values(): values = item.pop("pnl_pct_values", []) item["realized_pnl_usdt"] = round(item["realized_pnl_usdt"], 4) item["avg_realized_pnl_pct"] = round(sum(values) / len(values), 4) if values else 0.0 item["win_rate_pct"] = _pct(item["win_count"], item["closed_trade_count"]) item["actionable_rate_pct"] = _pct(item["actionable_count"], item["opportunity_count"]) item["trade_conversion_pct"] = _pct(item["trade_count"], item["opportunity_count"]) item["order_fill_rate_pct"] = _pct(item["filled_order_count"], item["order_count"]) item["candidate_signal_rate_pct"] = _pct(item["candidate_signal_count"], item["signal_count"]) item["best_pnl_pct"] = item["best_pnl_pct"] if item["best_pnl_pct"] is not None else 0.0 item["worst_pnl_pct"] = item["worst_pnl_pct"] if item["worst_pnl_pct"] is not None else 0.0 item.update(evaluate_strategy_decision(item)) strategies.append(item) strategies.sort(key=lambda x: (x["evaluation_score"], x["realized_pnl_usdt"], x["closed_trade_count"], x["signal_count"]), reverse=True) decisions = {} for item in strategies: decisions[item["decision"]] = decisions.get(item["decision"], 0) + 1 return { "definition": "策略评价按 strategy_code 独立统计发现、执行、成交、收益和回撤,并给出保留/灰度/暂停等建议;建议不直接改配置,仍需经过策略迭代闸门。", "days": days, "generated_at": datetime.now().isoformat(timespec="seconds"), "summary": { "strategy_count": len(strategies), "active_signal_strategy_count": sum(1 for x in strategies if x["signal_count"] > 0), "traded_strategy_count": sum(1 for x in strategies if x["trade_count"] > 0), "promote_count": decisions.get("promote", 0), "pause_count": decisions.get("pause", 0), "gray_count": decisions.get("gray", 0), "collect_samples_count": decisions.get("collect_samples", 0), }, "decision_distribution": [{"name": k, "count": v} for k, v in sorted(decisions.items(), key=lambda x: (-x[1], x[0]))], "strategies": strategies, } def get_strategy_insights(days: int | None = None): """Strategy attribution based on opportunity and paper-trading conversion. 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. """ if days is not None: days = max(1, min(_safe_int(days, 30), 365)) since = (datetime.now() - timedelta(days=days)).isoformat() rec_where = "WHERE r.rec_time >= %s" rec_params = (since,) trade_where = "WHERE pt.status='closed' AND COALESCE(pt.closed_at, pt.updated_at, pt.opened_at) >= %s" trade_params = (since,) else: days = None since = None rec_where = "" rec_params = () trade_where = "WHERE pt.status='closed'" trade_params = () conn = get_conn() rows = conn.execute( f""" SELECT r.*, pt.id AS paper_trade_id, 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.strategy_code AS paper_strategy_code, 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, po.id AS paper_order_id, po.status AS paper_order_status, po.strategy_code AS paper_order_strategy_code FROM recommendation r LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id LEFT JOIN paper_orders po ON po.recommendation_id = r.id {rec_where} ORDER BY r.rec_time DESC, r.id DESC """, rec_params, ).fetchall() trade_rows = conn.execute( f""" SELECT r.*, pt.id AS paper_trade_id, 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.strategy_code AS paper_strategy_code, 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, po.id AS paper_order_id, po.status AS paper_order_status, po.strategy_code AS paper_order_strategy_code FROM paper_trades pt LEFT JOIN recommendation r ON r.id = pt.recommendation_id LEFT JOIN paper_orders po ON po.recommendation_id = pt.recommendation_id {trade_where} ORDER BY COALESCE(pt.closed_at, pt.updated_at, pt.opened_at) DESC, pt.id DESC """, trade_params, ).fetchall() conn.close() items = [dict(r) for r in rows] trade_items = [dict(r) for r in trade_rows] actionable_statuses = {"buy_now", "wait_pullback"} total = len(items) 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 = trade_items 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_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": "策略归因只看机会转化和策略交易转化;收益只来自交易账本,不读取 recommendation.pnl_pct。", } def add_bucket(bucket_map, key, item): if not key: return 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) factor_map = {} env_map = {} version_map = {} evidence_map = {} trade_factor_map = {} trade_entry_map = {} trade_exit_map = {} trade_env_map = {} trade_evidence_map = {} trade_version_map = {} strategy_code_map = {} trade_strategy_code_map = {} trade_factor_group_map = {} trade_regime_map = {} trade_score_band_map = {} watch_map = {} order_map = {} for item in items: 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")) ep = safe_dict_json(item.get("entry_plan_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) mc = safe_dict_json(item.get("market_context_json")) factor_breakdown = safe_dict_json(mc.get("factor_score_breakdown")) or safe_dict_json(ep.get("factor_score_breakdown")) score_components = safe_dict_json(mc.get("score_components")) or safe_dict_json(ep.get("score_components")) market_regime = safe_dict_json(mc.get("market_regime")) or safe_dict_json(ep.get("market_regime")) regime_name = market_regime.get("regime") or mc.get("market_regime") for key in ("btc_trend", "market_regime", "altcoin_regime", "sentiment"): if mc.get(key): add_bucket(env_map, f"{key}:{mc.get(key)}", item) if regime_name: add_bucket(env_map, f"regime:{regime_name}", item) for bucket in env_buckets_from_market_context(mc): add_bucket(env_map, bucket, item) if item.get("strategy_version"): add_bucket(version_map, str(item.get("strategy_version")).strip(), item) strategy_code = normalize_strategy_code(item.get("strategy_code") or item.get("paper_strategy_code") or item.get("paper_order_strategy_code")) add_bucket(strategy_code_map, strategy_code, item) if (item.get("execution_status") or "") in {"observe", "wait_pullback"} or (item.get("display_bucket") or "") == "watch_pool": add_watch_bucket(watch_map, watch_bucket(item), item) if item.get("paper_order_id"): add_order_bucket(order_map, order_bucket(item), item) for item in trade_items: 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")) ep = safe_dict_json(item.get("entry_plan_json")) mc = safe_dict_json(item.get("market_context_json")) factor_breakdown = safe_dict_json(mc.get("factor_score_breakdown")) or safe_dict_json(ep.get("factor_score_breakdown")) score_components = safe_dict_json(mc.get("score_components")) or safe_dict_json(ep.get("score_components")) market_regime = safe_dict_json(mc.get("market_regime")) or safe_dict_json(ep.get("market_regime")) regime_name = market_regime.get("regime") or mc.get("market_regime") strategy_code = normalize_strategy_code(item.get("paper_strategy_code") or item.get("strategy_code") or item.get("paper_order_strategy_code")) for factor in labels: add_trade_bucket(trade_factor_map, str(factor).strip(), item) for group in factor_groups_from_breakdown(factor_breakdown): add_trade_bucket(trade_factor_group_map, group, 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) add_trade_bucket(trade_score_band_map, score_band("机会分", score_components.get("opportunity_score")), item) add_trade_bucket(trade_score_band_map, score_band("买点分", score_components.get("entry_score")), item) add_trade_bucket(trade_score_band_map, score_band("风险分", score_components.get("risk_score")), item) if regime_name: add_trade_bucket(trade_regime_map, f"regime:{regime_name}", 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) if item.get("strategy_version"): add_trade_bucket(trade_version_map, str(item.get("strategy_version")).strip(), item) add_trade_bucket(trade_strategy_code_map, strategy_code, item) return { "overview": overview, "days": days, "metric_definition": { "opportunity_count": "进入 opportunity/recommendation 表的机会样本数,不代表交易。", "actionable_count": "确认层输出 buy_now 或 wait_pullback 的样本数。", "paper_trade_count": "已经被策略交易账本执行的样本数。", "paper_realized_pnl_usdt": "仅来自交易账本的已平仓策略收益。", }, "factor_attribution": serialize_buckets("factor", factor_map)[:30], "market_environment": serialize_buckets("environment", env_map)[:20], "evidence_attribution": serialize_buckets("evidence", evidence_map)[:20], "version_performance": serialize_buckets("strategy_version", version_map, sort_by_version=True)[:20], "strategy_performance": add_strategy_labels(serialize_buckets("strategy_code", strategy_code_map)[:20]), "trade_attribution": { "definition": "交易级归因只统计已平仓策略交易,用 realized_pnl_usdt / realized_pnl_pct 衡量因子、入场路径、退出原因和环境的真实账本表现。", "factor": serialize_trade_buckets("factor", trade_factor_map)[:30], "factor_group": serialize_trade_buckets("factor_group", trade_factor_group_map)[:20], "entry_path": serialize_trade_buckets("entry_path", trade_entry_map)[:20], "exit_reason": serialize_trade_buckets("exit_reason", trade_exit_map)[:20], "market_regime": serialize_trade_buckets("market_regime", trade_regime_map)[:20], "score_band": serialize_trade_buckets("score_band", trade_score_band_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], "strategy_code": add_strategy_labels(serialize_trade_buckets("strategy_code", trade_strategy_code_map)[:20]), }, "watch_order_attribution": { "definition": "观察池和挂单池只评价机会是否推进,不计入交易收益;用于判断没买/等回踩是否合理。", "watch_pool": serialize_watch_buckets("watch_bucket", watch_map)[:20], "paper_orders": serialize_order_buckets("order_bucket", order_map)[:20], }, } def add_strategy_labels(rows): for item in rows or []: code = item.get("strategy_code") or item.get("name") or "" item["strategy_code"] = normalize_strategy_code(code) item["strategy_name"] = strategy_label(item["strategy_code"]) return rows 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 factor_groups_from_breakdown(breakdown): groups = breakdown.get("groups") if isinstance(breakdown, dict) else {} if isinstance(groups, dict): return [str(k) for k, v in groups.items() if float((v or {}).get("score") or 0) != 0] items = breakdown.get("items") if isinstance(breakdown, dict) else [] result = [] for item in items if isinstance(items, list) else []: group = str((item or {}).get("factor_group") or "").strip() if group: result.append(group) return sorted(set(result)) def score_band(label, value): try: n = float(value) except Exception: return f"{label}:未知" if n >= 8: band = "高" elif n >= 3: band = "中" elif n >= 0: band = "低" else: band = "负" return f"{label}:{band}({n:g})" def watch_bucket(item): status = str(item.get("execution_status") or item.get("display_bucket") or "watch").strip() if status == "wait_pullback": return "观察:等待回踩" if status == "observe": return "观察:普通观察" return f"观察:{status or '未标记'}" def order_bucket(item): status = str(item.get("paper_order_status") or "unknown").strip() source = str(item.get("paper_source_status") or item.get("execution_status") or "").strip() if status == "filled": return "挂单:已成交" if status == "canceled": return "挂单:已取消" if status == "pending": return "挂单:等待中" return f"挂单:{source or status}" def add_watch_bucket(bucket_map, key, item): if not key: return b = bucket_map.setdefault(key, { "opportunity_count": 0, "executed_count": 0, "order_count": 0, "invalid_count": 0, }) b["opportunity_count"] += 1 if item.get("paper_trade_id"): b["executed_count"] += 1 if item.get("paper_order_id"): b["order_count"] += 1 if (item.get("execution_status") or "") == "invalid" or (item.get("status") or "") in {"expired", "invalid", "archived"}: b["invalid_count"] += 1 def add_order_bucket(bucket_map, key, item): if not key: return b = bucket_map.setdefault(key, { "order_count": 0, "filled_count": 0, "canceled_count": 0, "trade_count": 0, }) status = str(item.get("paper_order_status") or "") b["order_count"] += 1 if status == "filled": b["filled_count"] += 1 if status == "canceled": b["canceled_count"] += 1 if item.get("paper_trade_id"): b["trade_count"] += 1 def serialize_watch_buckets(name_key, bucket_map): rows = [] for key, bucket in bucket_map.items(): total = bucket["opportunity_count"] rows.append({ name_key: key, **bucket, "executed_pct": round(bucket["executed_count"] / total * 100, 1) if total else 0, "order_pct": round(bucket["order_count"] / total * 100, 1) if total else 0, "invalid_pct": round(bucket["invalid_count"] / total * 100, 1) if total else 0, }) rows.sort(key=lambda x: (-x["opportunity_count"], x[name_key])) return rows def serialize_order_buckets(name_key, bucket_map): rows = [] for key, bucket in bucket_map.items(): total = bucket["order_count"] rows.append({ name_key: key, **bucket, "fill_pct": round(bucket["filled_count"] / total * 100, 1) if total else 0, "cancel_pct": round(bucket["canceled_count"] / total * 100, 1) if total else 0, }) rows.sort(key=lambda x: (-x["order_count"], x[name_key])) return rows def env_buckets_from_market_context(mc): """Convert market_context_json numeric fields into attribution buckets.""" buckets = [] try: change_24h = float(mc.get("change_24h", 0) or 0) turn_1h = float(mc.get("turnover_acceleration_1h", 0) or 0) turn_4h = float(mc.get("turnover_acceleration_4h", 0) or 0) volume_24h = float(mc.get("volume_24h") or mc.get("quote_volume_24h") or 0) funding = float(mc.get("funding_rate", 0) or 0) except Exception: change_24h = turn_1h = turn_4h = volume_24h = funding = 0 if change_24h >= 8: buckets.append("24h涨幅:强势拉升≥8%") elif change_24h >= 3: buckets.append("24h涨幅:温和上涨3-8%") elif change_24h <= -3: buckets.append("24h涨幅:回撤≤-3%") else: buckets.append("24h涨幅:震荡-3~3%") if turn_1h >= 3: buckets.append("1h成交加速:爆量≥3x") elif turn_1h >= 1.5: buckets.append("1h成交加速:放量1.5-3x") elif turn_1h > 0: buckets.append("1h成交加速:平量<1.5x") if turn_4h >= 3: buckets.append("4h成交加速:爆量≥3x") elif turn_4h >= 1.5: buckets.append("4h成交加速:放量1.5-3x") elif turn_4h > 0: buckets.append("4h成交加速:平量<1.5x") if volume_24h >= 100_000_000: buckets.append("24h成交额:高流动性≥1亿") elif volume_24h >= 10_000_000: buckets.append("24h成交额:中等流动性1千万-1亿") elif volume_24h > 0: buckets.append("24h成交额:低流动性<1千万") if funding >= 0.0005: buckets.append("资金费率:多头拥挤") elif funding <= -0.0005: buckets.append("资金费率:空头拥挤") return buckets def serialize_buckets(name_key, bucket_map, sort_by_version=False): rows = [] for key, bucket in bucket_map.items(): rows.append({ name_key: key, "opportunity_count": bucket["opportunity_count"], "actionable_count": bucket["actionable_count"], "buy_now_count": bucket["buy_now_count"], "paper_trade_count": bucket["paper_trade_count"], "closed_paper_trade_count": bucket["closed_paper_trade_count"], "paper_win_count": bucket["paper_win_count"], "actionable_conversion_pct": round(bucket["actionable_count"] / bucket["opportunity_count"] * 100, 1) if bucket["opportunity_count"] else 0, "paper_conversion_pct": round(bucket["paper_trade_count"] / bucket["buy_now_count"] * 100, 1) if bucket["buy_now_count"] else 0, "paper_win_rate_pct": round(bucket["paper_win_count"] / bucket["closed_paper_trade_count"] * 100, 1) if bucket["closed_paper_trade_count"] else 0, "paper_realized_pnl_usdt": round(bucket["paper_realized_pnl_usdt"], 4), }) if sort_by_version: 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["opportunity_count"], -x["actionable_conversion_pct"], x[name_key])) 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): text = str(version or '').strip() if text.startswith('v') or text.startswith('V'): text = text[1:] parts = [] for chunk in text.replace('-', '.').split('.'): if chunk.isdigit(): parts.append(int(chunk)) else: match = re.match(r'^(\d+)', chunk) if match: parts.append(int(match.group(1))) else: parts.append(chunk) return tuple(parts)