Files
quant-trader-service/training/trader_training/diagnostics.py
T
Codex 9acb3460a1 Improve Trader V4 training pipeline
Align entry labels with max future edge, tune direction labeling, and harden regression evaluation.

Add training diagnostics, price-plan search, feature screening, and nonlinear benchmark scripts.
2026-06-27 19:57:29 +08:00

352 lines
15 KiB
Python

from __future__ import annotations
import logging
from typing import Any
import numpy as np
import pandas as pd
from trader_training.io_utils import read_parquet, run_root, write_json, write_text
from trader_training.pm import _pm_frame, _simulate_open_trades, _threshold_candidates, _trade_metrics
from trader_training.schemas import FIT_SPLIT, LATEST_STRESS_SPLIT, TUNE_SPLIT, VALIDATION_LOCKED_SPLIT
DIAGNOSTIC_SPLITS = (FIT_SPLIT, TUNE_SPLIT, VALIDATION_LOCKED_SPLIT, LATEST_STRESS_SPLIT)
PM_EVAL_SPLITS = (TUNE_SPLIT, VALIDATION_LOCKED_SPLIT, LATEST_STRESS_SPLIT)
def diagnose_training_run(args: Any) -> None:
root = run_root(args)
label_summary = _label_summary(root)
pm_summary = _pm_summary(root)
payload = {
"run_id": args.run_id,
"label_summary": label_summary,
"pm_summary": pm_summary,
"conclusion": _diagnostic_conclusion(pm_summary),
}
write_json(root / "diagnostics" / "training_failure_diagnostics.json", _jsonable(payload))
write_text(root / "diagnostics" / "training_failure_diagnostics.md", _markdown_report(payload))
logging.info(
"trader.training.diagnostics_written runId=%s conclusion=%s path=%s",
args.run_id,
payload["conclusion"]["status"],
root / "diagnostics" / "training_failure_diagnostics.md",
)
def _label_summary(root) -> dict[str, Any]:
direction = read_parquet(root / "label" / "direction_labels.parquet")
entry = read_parquet(root / "label" / "entry_labels.parquet")
summary: dict[str, Any] = {}
for split_id in DIAGNOSTIC_SPLITS:
direction_split = direction[direction["split_id"].eq(split_id)].copy()
entry_split = entry[entry["split_id"].eq(split_id)].copy()
item: dict[str, Any] = {"direction": {}, "entry": {}}
if not direction_split.empty:
item["direction"] = {
"rows": len(direction_split),
"label_ratio": direction_split["direction_label"].value_counts(normalize=True).round(6).to_dict(),
"future_return_bps_quantile": _quantiles(direction_split["future_return_bps"], (0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99)),
}
if not entry_split.empty:
grouped = entry_split.groupby("side", observed=False)
item["entry"] = {
"rows": len(entry_split),
"target_rate_by_side": grouped["entry_target"].mean().round(6).to_dict(),
"edge_mean_by_side": grouped["expected_net_edge_bps"].mean().round(6).to_dict(),
"edge_quantile_by_side": {
str(side): _quantiles(group["expected_net_edge_bps"], (0.05, 0.5, 0.95))
for side, group in grouped
},
}
summary[split_id] = item
return summary
def _pm_summary(root) -> dict[str, Any]:
summary: dict[str, Any] = {}
for split_id in PM_EVAL_SPLITS:
frame = _pm_frame(root, split_id)
item = {
"rows": len(frame),
"score_distribution": _score_distribution(frame),
"gate_funnel": _gate_funnel(frame),
"relaxed_variants": _relaxed_variants(frame),
"top_bucket_edge": _top_bucket_edge(frame),
"grid_search_any_trade": _grid_trade_summary(frame),
}
summary[split_id] = item
return summary
def _score_distribution(frame: pd.DataFrame) -> dict[str, dict[str, float]]:
columns = [
"long_prob",
"short_prob",
"long_entry_prob",
"short_entry_prob",
"market_risk_prob",
"pred_long_expected_net_edge_bps",
"pred_short_expected_net_edge_bps",
"actual_long_expected_net_edge_bps",
"actual_short_expected_net_edge_bps",
]
return {column: _quantiles(frame[column], (0.0, 0.05, 0.5, 0.95, 1.0)) for column in columns}
def _gate_funnel(frame: pd.DataFrame) -> dict[str, Any]:
thresholds = {
"long_open_prob": 0.54,
"short_open_prob": 0.54,
"min_entry_prob": 0.50,
"max_market_risk_prob": 0.55,
"min_expected_edge_bps": 1.0,
"min_direction_margin": 0.02,
}
long_steps = {
"long_prob >= 0.54": frame["long_prob"] >= thresholds["long_open_prob"],
"long_prob - short_prob >= 0.02": (frame["long_prob"] - frame["short_prob"]) >= thresholds["min_direction_margin"],
"long_entry_prob >= 0.50": frame["long_entry_prob"] >= thresholds["min_entry_prob"],
"market_risk_prob <= 0.55": frame["market_risk_prob"] <= thresholds["max_market_risk_prob"],
"pred_long_expected_net_edge_bps >= 1.0": frame["pred_long_expected_net_edge_bps"] >= thresholds["min_expected_edge_bps"],
}
short_steps = {
"short_prob >= 0.54": frame["short_prob"] >= thresholds["short_open_prob"],
"short_prob - long_prob >= 0.02": (frame["short_prob"] - frame["long_prob"]) >= thresholds["min_direction_margin"],
"short_entry_prob >= 0.50": frame["short_entry_prob"] >= thresholds["min_entry_prob"],
"market_risk_prob <= 0.55": frame["market_risk_prob"] <= thresholds["max_market_risk_prob"],
"pred_short_expected_net_edge_bps >= 1.0": frame["pred_short_expected_net_edge_bps"] >= thresholds["min_expected_edge_bps"],
}
return {
"thresholds": thresholds,
"long": _cumulative_gate_counts(long_steps, len(frame)),
"short": _cumulative_gate_counts(short_steps, len(frame)),
}
def _cumulative_gate_counts(steps: dict[str, pd.Series], total_rows: int) -> dict[str, Any]:
mask = np.ones(total_rows, dtype=bool)
cumulative = []
single = {}
for name, step in steps.items():
values = step.to_numpy(dtype=bool)
single[name] = int(values.sum())
mask &= values
cumulative.append({"gate": name, "rows_after_gate": int(mask.sum())})
return {"single_gate_pass": single, "cumulative": cumulative}
def _relaxed_variants(frame: pd.DataFrame) -> dict[str, Any]:
variants = {
"no_risk_no_edge": {"prob": 0.54, "entry": 0.50, "margin": 0.02, "risk": 1.0, "edge": -99.0},
"entry_only_55": {"prob": 0.0, "entry": 0.55, "margin": -99.0, "risk": 1.0, "edge": -99.0},
"direction_only_54": {"prob": 0.54, "entry": 0.0, "margin": 0.02, "risk": 1.0, "edge": -99.0},
"very_loose": {"prob": 0.50, "entry": 0.45, "margin": 0.0, "risk": 1.0, "edge": -99.0},
}
result: dict[str, Any] = {}
for name, thresholds in variants.items():
trades = _variant_trades(frame, thresholds)
result[name] = _plain_trade_metrics(trades)
return result
def _variant_trades(frame: pd.DataFrame, thresholds: dict[str, float]) -> pd.DataFrame:
long_mask = (
(frame["long_prob"] >= thresholds["prob"])
& ((frame["long_prob"] - frame["short_prob"]) >= thresholds["margin"])
& (frame["long_entry_prob"] >= thresholds["entry"])
& (frame["market_risk_prob"] <= thresholds["risk"])
& (frame["pred_long_expected_net_edge_bps"] >= thresholds["edge"])
)
short_mask = (
(frame["short_prob"] >= thresholds["prob"])
& ((frame["short_prob"] - frame["long_prob"]) >= thresholds["margin"])
& (frame["short_entry_prob"] >= thresholds["entry"])
& (frame["market_risk_prob"] <= thresholds["risk"])
& (frame["pred_short_expected_net_edge_bps"] >= thresholds["edge"])
)
long = frame.loc[long_mask].copy()
long["side"] = "LONG"
long["actual_edge_bps"] = long["actual_long_expected_net_edge_bps"]
short = frame.loc[short_mask].copy()
short["side"] = "SHORT"
short["actual_edge_bps"] = short["actual_short_expected_net_edge_bps"]
return pd.concat([long, short], ignore_index=True)
def _plain_trade_metrics(trades: pd.DataFrame) -> dict[str, Any]:
if trades.empty:
return {"rows": 0, "win_rate": 0.0, "avg_actual_edge_bps": 0.0}
return {
"rows": len(trades),
"win_rate": float((trades["actual_edge_bps"].astype(float) > 0).mean()),
"avg_actual_edge_bps": float(trades["actual_edge_bps"].astype(float).mean()),
"side_counts": trades["side"].value_counts().to_dict(),
}
def _top_bucket_edge(frame: pd.DataFrame) -> dict[str, Any]:
side = np.where(frame["long_prob"] >= frame["short_prob"], "LONG", "SHORT")
side_prob = np.where(side == "LONG", frame["long_prob"], frame["short_prob"])
side_edge = np.where(side == "LONG", frame["actual_long_expected_net_edge_bps"], frame["actual_short_expected_net_edge_bps"])
direction_frame = pd.DataFrame({"score": side_prob, "actual_edge_bps": side_edge, "side": side})
direction_top = {}
for fraction in (0.01, 0.02, 0.05, 0.10):
top = direction_frame.sort_values("score", ascending=False).head(max(1, int(len(direction_frame) * fraction)))
direction_top[str(fraction)] = _plain_trade_metrics(top.rename(columns={"actual_edge_bps": "actual_edge_bps"}))
return {
"direction_top_score": direction_top,
"long_entry_prob_deciles": _decile_edge(frame, "long_entry_prob", "actual_long_expected_net_edge_bps", "long_entry_target"),
"short_entry_prob_deciles": _decile_edge(frame, "short_entry_prob", "actual_short_expected_net_edge_bps", "short_entry_target"),
}
def _decile_edge(frame: pd.DataFrame, score_col: str, edge_col: str, target_col: str) -> list[dict[str, Any]]:
sample = frame[[score_col, edge_col, target_col]].dropna().copy()
if sample.empty:
return []
sample["bucket"] = pd.qcut(sample[score_col].rank(method="first"), 10, labels=False) + 1
rows = []
for bucket, group in sample.groupby("bucket", observed=False):
rows.append(
{
"bucket": int(bucket),
"rows": len(group),
"score_min": float(group[score_col].min()),
"score_max": float(group[score_col].max()),
"hit_rate": float(group[target_col].mean()),
"avg_actual_edge_bps": float(group[edge_col].mean()),
}
)
return rows
def _grid_trade_summary(frame: pd.DataFrame) -> dict[str, Any]:
nonzero = 0
best_by_count = None
best_metrics = None
for thresholds in _threshold_candidates():
trades = _simulate_open_trades(frame, thresholds)
metrics = _trade_metrics(trades)
if metrics["trade_count"] > 0:
nonzero += 1
if best_metrics is None or metrics["trade_count"] > best_metrics["trade_count"]:
best_by_count = thresholds
best_metrics = metrics
return {
"candidate_count": len(_threshold_candidates()),
"candidates_with_trade": nonzero,
"best_by_trade_count": best_by_count,
"best_metrics": best_metrics,
}
def _diagnostic_conclusion(pm_summary: dict[str, Any]) -> dict[str, Any]:
tune = pm_summary.get(TUNE_SPLIT, {})
gate = tune.get("gate_funnel", {})
long_single = gate.get("long", {}).get("single_gate_pass", {})
short_single = gate.get("short", {}).get("single_gate_pass", {})
pred_edge_blocked = (
long_single.get("pred_long_expected_net_edge_bps >= 1.0", 0) == 0
and short_single.get("pred_short_expected_net_edge_bps >= 1.0", 0) == 0
)
relaxed = tune.get("relaxed_variants", {})
any_relaxed_positive = any(item.get("avg_actual_edge_bps", 0.0) > 0 for item in relaxed.values())
if pred_edge_blocked and not any_relaxed_positive:
return {
"status": "MODEL_SIGNAL_NOT_TRADABLE",
"plain_reason": "Entry 预测的净收益基本都是负数;即使放松风险和收益门槛,选出来的样本平均仍亏。",
"next_action": "优先重查 Entry 标签和价格计划,再考虑更强模型;不要直接放松 PM 阈值上线。",
}
if pred_edge_blocked:
return {
"status": "ENTRY_EDGE_GATE_BLOCKED",
"plain_reason": "PM 没有交易主要是 Entry 预测净收益过低。",
"next_action": "重训 Entry 或调整价格计划后再搜索 PM 阈值。",
}
return {
"status": "NEEDS_MANUAL_REVIEW",
"plain_reason": "没有发现单一硬挡板,需要人工继续看各模型分数和回测明细。",
"next_action": "查看 diagnostics 报告中的漏斗和放松阈值结果。",
}
def _quantiles(series: pd.Series, points: tuple[float, ...]) -> dict[str, float]:
values = pd.to_numeric(series, errors="coerce").replace([np.inf, -np.inf], np.nan).dropna()
if values.empty:
return {}
result = values.quantile(list(points)).round(6).to_dict()
return {str(key): float(value) for key, value in result.items()}
def _markdown_report(payload: dict[str, Any]) -> str:
lines = [
"# Trader Training Failure Diagnostics",
"",
f"- run_id: `{payload['run_id']}`",
f"- status: `{payload['conclusion']['status']}`",
f"- 结论: {payload['conclusion']['plain_reason']}",
f"- 下一步: {payload['conclusion']['next_action']}",
"",
"## 标签分布",
"",
]
for split_id, item in payload["label_summary"].items():
direction = item.get("direction", {})
entry = item.get("entry", {})
lines.append(f"### {split_id}")
lines.append("")
if direction:
lines.append(f"- Direction 行数: {direction['rows']}")
lines.append(f"- Direction 标签比例: `{direction['label_ratio']}`")
lines.append(f"- 45 分钟未来收益分位: `{direction['future_return_bps_quantile']}`")
if entry:
lines.append(f"- Entry 行数: {entry['rows']}")
lines.append(f"- Entry 命中率: `{entry['target_rate_by_side']}`")
lines.append(f"- Entry 平均净收益: `{entry['edge_mean_by_side']}`")
lines.append("")
lines.extend(["## PM 挡单漏斗", ""])
for split_id, item in payload["pm_summary"].items():
lines.append(f"### {split_id}")
lines.append("")
lines.append(f"- 样本数: {item['rows']}")
lines.append(f"- 网格里有交易的候选数: {item['grid_search_any_trade']['candidates_with_trade']} / {item['grid_search_any_trade']['candidate_count']}")
lines.append("")
for side in ("long", "short"):
lines.append(f"#### {side.upper()}")
lines.append("")
rows = item["gate_funnel"][side]["cumulative"]
lines.append("| 条件 | 剩余样本 |")
lines.append("| --- | ---: |")
for row in rows:
lines.append(f"| {row['gate']} | {row['rows_after_gate']} |")
lines.append("")
lines.append("#### 放松条件后的结果")
lines.append("")
lines.append("| 方案 | 样本数 | 胜率 | 平均真实净收益bps |")
lines.append("| --- | ---: | ---: | ---: |")
for name, metrics in item["relaxed_variants"].items():
lines.append(
f"| {name} | {metrics['rows']} | {metrics['win_rate']:.4f} | {metrics['avg_actual_edge_bps']:.4f} |"
)
lines.append("")
return "\n".join(lines) + "\n"
def _jsonable(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, list):
return [_jsonable(item) for item in value]
if isinstance(value, tuple):
return [_jsonable(item) for item in value]
if isinstance(value, (np.integer,)):
return int(value)
if isinstance(value, (np.floating,)):
return float(value)
if isinstance(value, np.ndarray):
return value.tolist()
return value