Improve Trader entry quality training diagnostics

This commit is contained in:
Codex
2026-06-28 00:50:37 +08:00
parent 87849a66a7
commit 340d1dd91b
11 changed files with 1895 additions and 110 deletions
+240 -32
View File
@@ -11,6 +11,13 @@ from trader_training.io_utils import read_json, read_parquet, run_root, sha256_j
from trader_training.schemas import LATEST_STRESS_SPLIT, PM_CONFIG_VERSION, TUNE_SPLIT, VALIDATION_LOCKED_SPLIT
DEFAULT_BACKTEST_PRICE_PLAN = {
"stopDistanceBps": 35.0,
"costBps": 4.0,
"maxHoldMinutes": 45,
}
def default_pm_config() -> dict:
return {
"pmConfigVersion": PM_CONFIG_VERSION,
@@ -70,6 +77,7 @@ def default_pm_config() -> dict:
def search_pm_thresholds(args: Any) -> None:
root = run_root(args)
frame = _pm_tune_frame(root)
price_plan = _price_plan_context(root)
candidate_rows: list[dict[str, Any]] = []
best_score = -float("inf")
best_thresholds: dict[str, float] | None = None
@@ -77,7 +85,8 @@ def search_pm_thresholds(args: Any) -> None:
best_trades = pd.DataFrame()
for thresholds in _threshold_candidates():
trades = _simulate_open_trades(frame, thresholds)
config = _pm_config_from_thresholds(thresholds)
trades = _simulate_open_trades(frame, thresholds, config, price_plan)
metrics = _trade_metrics(trades)
score = _score_thresholds(metrics)
candidate_rows.append({**thresholds, **metrics, "score": score})
@@ -134,11 +143,27 @@ def integrated_backtest(args: Any) -> None:
trades_path = root / "pm-search" / "pm_backtest_trades.parquet"
# PM search is allowed to use tune_inner, but final acceptance must be
# measured on the sealed validation_locked and latest_stress splits.
tune_trades = read_parquet(trades_path) if trades_path.is_file() else _simulate_open_trades(_pm_tune_frame(root), _thresholds_from_config(pm_payload["config"]))
price_plan = _price_plan_context(root)
tune_trades = read_parquet(trades_path) if trades_path.is_file() else _simulate_open_trades(
_pm_tune_frame(root),
_thresholds_from_config(pm_payload["config"]),
pm_payload["config"],
price_plan,
)
tune_trades["eval_split"] = TUNE_SPLIT
validation_locked_trades = _simulate_open_trades(_pm_frame(root, VALIDATION_LOCKED_SPLIT), _thresholds_from_config(pm_payload["config"]))
validation_locked_trades = _simulate_open_trades(
_pm_frame(root, VALIDATION_LOCKED_SPLIT),
_thresholds_from_config(pm_payload["config"]),
pm_payload["config"],
price_plan,
)
validation_locked_trades["eval_split"] = VALIDATION_LOCKED_SPLIT
stress_trades = _simulate_open_trades(_pm_frame(root, LATEST_STRESS_SPLIT), _thresholds_from_config(pm_payload["config"]))
stress_trades = _simulate_open_trades(
_pm_frame(root, LATEST_STRESS_SPLIT),
_thresholds_from_config(pm_payload["config"]),
pm_payload["config"],
price_plan,
)
stress_trades["eval_split"] = LATEST_STRESS_SPLIT
trades = pd.concat([tune_trades, validation_locked_trades, stress_trades], ignore_index=True)
metrics = {
@@ -154,6 +179,8 @@ def integrated_backtest(args: Any) -> None:
"backtest_manifest_id": f"backtest-{args.run_id}",
"mode": "VALIDATION_PM_BACKTEST",
"pm_config_hash_sha256": pm_payload["config_hash_sha256"],
"price_plan_id": price_plan.get("pricePlanId"),
"price_plan_config_hash": price_plan.get("pricePlanConfigHash"),
"metrics": metrics,
"status_reasons": status_reasons,
"status": status,
@@ -179,6 +206,14 @@ def _pm_tune_frame(root) -> pd.DataFrame:
return _pm_frame(root, TUNE_SPLIT)
def _price_plan_context(root) -> dict[str, Any]:
path = root / "label" / "price_plan_context.json"
if path.is_file():
return read_json(path)
logging.warning("trader.training.price_plan_missing_for_pm path=%s usingDefault=%s", path, DEFAULT_BACKTEST_PRICE_PLAN)
return DEFAULT_BACKTEST_PRICE_PLAN.copy()
def _pm_frame(root, split_id: str) -> pd.DataFrame:
prediction_files = {
TUNE_SPLIT: "tune_predictions.parquet",
@@ -194,12 +229,14 @@ def _pm_frame(root, split_id: str) -> pd.DataFrame:
}
)
risk = read_parquet(root / "model" / "risk" / prediction_file)
price_plan = _price_plan_context(root)
entry_dataset = read_parquet(root / "dataset" / "entry_train.parquet").rename(
columns={
"long_expected_net_edge_bps": "actual_long_expected_net_edge_bps",
"short_expected_net_edge_bps": "actual_short_expected_net_edge_bps",
}
)
entry_plan_outcome = _entry_plan_outcome_frame(root)
entry_cols = [
"sample_id",
"long_entry_prob",
@@ -214,26 +251,92 @@ def _pm_frame(root, split_id: str) -> pd.DataFrame:
.merge(entry[entry_cols], on="sample_id", how="inner")
.merge(risk[risk_cols], on="sample_id", how="inner")
.merge(entry_dataset[actual_cols], on="sample_id", how="inner")
.merge(entry_plan_outcome, on="sample_id", how="inner")
)
if frame.empty:
raise ValueError(f"PM frame is empty for {split_id}; check model predictions and entry dataset")
frame["model_pred_long_expected_net_edge_bps"] = frame["pred_long_expected_net_edge_bps"]
frame["model_pred_short_expected_net_edge_bps"] = frame["pred_short_expected_net_edge_bps"]
edge_mode = "MODEL_EXPECTED_NET_EDGE"
if price_plan.get("entryTargetMethod") not in {"OPPORTUNITY_MFE_V1", "OPPORTUNITY_QUALITY_V1"}:
frame["pred_long_expected_net_edge_bps"] = _probability_implied_edge(frame["long_entry_prob"], price_plan)
frame["pred_short_expected_net_edge_bps"] = _probability_implied_edge(frame["short_entry_prob"], price_plan)
edge_mode = "ENTRY_PROBABILITY_PAYOFF"
logging.info(
"trader.training.pm_frame_loaded splitId=%s rowCount=%s splitCounts=%s",
"trader.training.pm_frame_loaded splitId=%s rowCount=%s splitCounts=%s edgeMode=%s",
split_id,
len(frame),
frame["split_id"].value_counts().to_dict(),
edge_mode,
)
return frame
def _probability_implied_edge(entry_prob: pd.Series, price_plan: dict[str, Any]) -> pd.Series:
target_net_bps = float(price_plan.get("targetDistanceBps", 0.0)) - float(price_plan.get("costBps", 0.0))
stop_net_bps = -float(price_plan.get("stopDistanceBps", DEFAULT_BACKTEST_PRICE_PLAN["stopDistanceBps"])) - float(
price_plan.get("costBps", DEFAULT_BACKTEST_PRICE_PLAN["costBps"])
)
probability = pd.to_numeric(entry_prob, errors="coerce").fillna(0.0).clip(lower=0.0, upper=1.0)
# Entry 的概率头比收益回归头稳定。这里用固定止盈止损的盈亏比把概率换成期望收益,
# 让低命中、高赔率计划也能被 PM 正常搜索;真实结果仍由标签里的实际路径收益评估。
return probability * target_net_bps + (1.0 - probability) * stop_net_bps
def _entry_plan_outcome_frame(root) -> pd.DataFrame:
labels = read_parquet(root / "label" / "entry_labels.parquet").copy()
required = {
"sample_id",
"side",
"gross_edge_bps",
"cost_bps",
"target_hit",
"stop_hit",
"time_to_target_ms",
"time_to_stop_ms",
"time_to_exit_ms",
}
missing = sorted(required - set(labels.columns))
if missing:
raise ValueError(f"entry_labels is missing PM outcome columns: {missing}")
labels["trade_net_edge_bps"] = pd.to_numeric(labels["gross_edge_bps"], errors="coerce").fillna(0.0) - pd.to_numeric(
labels["cost_bps"], errors="coerce"
).fillna(0.0)
def side_frame(side: str, prefix: str) -> pd.DataFrame:
return labels[labels["side"].eq(side)][
[
"sample_id",
"trade_net_edge_bps",
"target_hit",
"stop_hit",
"time_to_target_ms",
"time_to_stop_ms",
"time_to_exit_ms",
]
].rename(
columns={
"trade_net_edge_bps": f"{prefix}_trade_net_edge_bps",
"target_hit": f"{prefix}_target_hit",
"stop_hit": f"{prefix}_stop_hit",
"time_to_target_ms": f"{prefix}_time_to_target_ms",
"time_to_stop_ms": f"{prefix}_time_to_stop_ms",
"time_to_exit_ms": f"{prefix}_time_to_exit_ms",
}
)
return side_frame("LONG", "long").merge(side_frame("SHORT", "short"), on="sample_id", how="inner")
def _threshold_candidates() -> list[dict[str, float]]:
# 1.01 表示这一侧不开仓,用来检查“只做多”或“只做空”是否更稳。
values = itertools.product(
[0.50, 0.52, 0.54, 0.56, 0.58],
[0.50, 0.52, 0.54, 0.56, 0.58],
[0.10, 0.12, 0.14, 0.16, 0.20, 0.30, 0.50],
[0.55, 0.75, 0.90, 1.00],
[-8.0, -4.0, 0.0, 1.0, 3.0],
[0.00, 0.01, 0.02, 0.05],
[0.50, 0.60, 0.70, 1.01],
[0.50, 0.60, 0.70, 1.01],
[0.03, 0.50, 0.70, 0.85],
[0.45, 0.65, 0.85],
[0.0, 8.0, 15.0, 25.0],
[0.02, 0.06, 0.10],
)
return [
{
@@ -248,23 +351,39 @@ def _threshold_candidates() -> list[dict[str, float]]:
]
def _simulate_open_trades(frame: pd.DataFrame, thresholds: dict[str, float]) -> pd.DataFrame:
def _simulate_open_trades(
frame: pd.DataFrame,
thresholds: dict[str, float],
pm_config: dict[str, Any] | None = None,
price_plan: dict[str, Any] | None = None,
) -> pd.DataFrame:
direction_margin = (frame["long_prob"] - frame["short_prob"]).abs()
long_mask = (
(frame["long_prob"] >= thresholds["long_open_prob"])
& ((frame["long_prob"] - frame["short_prob"]) >= thresholds["min_direction_margin"])
& (frame["long_entry_prob"] >= thresholds["min_entry_prob"])
& (frame["market_risk_prob"] <= thresholds["max_market_risk_prob"])
& (frame["pred_long_expected_net_edge_bps"] >= thresholds["min_expected_edge_bps"])
(frame["long_prob"] > thresholds["long_open_prob"])
& (direction_margin > thresholds["min_direction_margin"])
& (frame["long_entry_prob"] > thresholds["min_entry_prob"])
& (frame["market_risk_prob"] < thresholds["max_market_risk_prob"])
& (frame["pred_long_expected_net_edge_bps"] > thresholds["min_expected_edge_bps"])
)
short_mask = (
(frame["short_prob"] >= thresholds["short_open_prob"])
& ((frame["short_prob"] - frame["long_prob"]) >= thresholds["min_direction_margin"])
& (frame["short_entry_prob"] >= thresholds["min_entry_prob"])
& (frame["market_risk_prob"] <= thresholds["max_market_risk_prob"])
& (frame["pred_short_expected_net_edge_bps"] >= thresholds["min_expected_edge_bps"])
(frame["short_prob"] > thresholds["short_open_prob"])
& (direction_margin > thresholds["min_direction_margin"])
& (frame["short_entry_prob"] > thresholds["min_entry_prob"])
& (frame["market_risk_prob"] < thresholds["max_market_risk_prob"])
& (frame["pred_short_expected_net_edge_bps"] > thresholds["min_expected_edge_bps"])
)
long_score = (
frame["pred_long_expected_net_edge_bps"].clip(lower=0.0)
* frame["long_prob"]
* frame["long_entry_prob"]
* (1.0 - frame["market_risk_prob"].clip(lower=0.0, upper=1.0))
)
short_score = (
frame["pred_short_expected_net_edge_bps"].clip(lower=0.0)
* frame["short_prob"]
* frame["short_entry_prob"]
* (1.0 - frame["market_risk_prob"].clip(lower=0.0, upper=1.0))
)
long_score = frame["pred_long_expected_net_edge_bps"] + (frame["long_prob"] - frame["short_prob"]) * 10.0
short_score = frame["pred_short_expected_net_edge_bps"] + (frame["short_prob"] - frame["long_prob"]) * 10.0
side = np.where(long_mask & (~short_mask | (long_score >= short_score)), "LONG", np.where(short_mask, "SHORT", ""))
trades = frame.loc[side != ""].copy().reset_index(drop=True)
if trades.empty:
@@ -274,11 +393,19 @@ def _simulate_open_trades(frame: pd.DataFrame, thresholds: dict[str, float]) ->
trades["direction_prob"] = np.where(is_long, trades["long_prob"], trades["short_prob"])
trades["entry_prob"] = np.where(is_long, trades["long_entry_prob"], trades["short_entry_prob"])
trades["predicted_edge_bps"] = np.where(is_long, trades["pred_long_expected_net_edge_bps"], trades["pred_short_expected_net_edge_bps"])
trades["actual_edge_bps"] = np.where(is_long, trades["actual_long_expected_net_edge_bps"], trades["actual_short_expected_net_edge_bps"])
trades["actual_edge_bps"] = np.where(is_long, trades["long_trade_net_edge_bps"], trades["short_trade_net_edge_bps"])
trades["label_max_edge_bps"] = np.where(is_long, trades["actual_long_expected_net_edge_bps"], trades["actual_short_expected_net_edge_bps"])
trades["entry_target"] = np.where(is_long, trades["long_entry_target"], trades["short_entry_target"])
trades["planned_ratio"] = _planned_ratio(trades["predicted_edge_bps"], trades["market_risk_prob"], thresholds["min_expected_edge_bps"])
effective_pm_config = pm_config or _pm_config_from_thresholds(thresholds)
effective_price_plan = price_plan or DEFAULT_BACKTEST_PRICE_PLAN
trades["time_to_exit_ms"] = _time_to_exit_ms(trades, is_long, effective_price_plan)
trades["planned_ratio"] = _planned_ratio_like_position_manager(trades, effective_pm_config["sizing"], effective_price_plan)
trades = trades[trades["planned_ratio"] > 0].copy()
if trades.empty:
return _empty_trade_frame()
trades["weighted_edge_bps"] = trades["actual_edge_bps"] * trades["planned_ratio"]
trades["threshold_hash"] = sha256_json(thresholds)[:16]
trades = _enforce_non_overlapping_entries(trades, effective_pm_config, effective_price_plan)
return trades[
[
"sample_id",
@@ -290,8 +417,10 @@ def _simulate_open_trades(frame: pd.DataFrame, thresholds: dict[str, float]) ->
"entry_prob",
"market_risk_prob",
"predicted_edge_bps",
"label_max_edge_bps",
"actual_edge_bps",
"entry_target",
"time_to_exit_ms",
"planned_ratio",
"weighted_edge_bps",
"threshold_hash",
@@ -311,8 +440,10 @@ def _empty_trade_frame() -> pd.DataFrame:
"entry_prob",
"market_risk_prob",
"predicted_edge_bps",
"label_max_edge_bps",
"actual_edge_bps",
"entry_target",
"time_to_exit_ms",
"planned_ratio",
"weighted_edge_bps",
"threshold_hash",
@@ -320,10 +451,78 @@ def _empty_trade_frame() -> pd.DataFrame:
)
def _planned_ratio(predicted_edge: pd.Series, market_risk: pd.Series, min_edge: float) -> np.ndarray:
edge_strength = ((predicted_edge.astype(float) - min_edge) / 20.0).clip(lower=0.0, upper=1.5)
risk_discount = (1.0 - market_risk.astype(float)).clip(lower=0.0, upper=1.0)
return (edge_strength * risk_discount).clip(lower=0.05, upper=1.0).to_numpy()
def _time_to_exit_ms(trades: pd.DataFrame, is_long: pd.Series, price_plan: dict[str, Any]) -> np.ndarray:
max_hold_ms = int(price_plan.get("maxHoldMinutes", DEFAULT_BACKTEST_PRICE_PLAN["maxHoldMinutes"])) * 60_000
long_exit_col = "long_time_to_exit_ms"
short_exit_col = "short_time_to_exit_ms"
if long_exit_col in trades.columns and short_exit_col in trades.columns:
label_exit_ms = np.where(is_long, trades[long_exit_col], trades[short_exit_col]).astype("float64")
return np.where(np.isfinite(label_exit_ms) & (label_exit_ms > 0), label_exit_ms, max_hold_ms)
target_hit = np.where(is_long, trades["long_target_hit"], trades["short_target_hit"])
stop_hit = np.where(is_long, trades["long_stop_hit"], trades["short_stop_hit"])
target_ms = np.where(is_long, trades["long_time_to_target_ms"], trades["short_time_to_target_ms"]).astype("float64")
stop_ms = np.where(is_long, trades["long_time_to_stop_ms"], trades["short_time_to_stop_ms"]).astype("float64")
return np.where((target_hit == 1) & (target_ms >= 0), target_ms, np.where((stop_hit == 1) & (stop_ms >= 0), stop_ms, max_hold_ms))
def _enforce_non_overlapping_entries(trades: pd.DataFrame, pm_config: dict[str, Any], price_plan: dict[str, Any]) -> pd.DataFrame:
if trades.empty:
return trades
cooldown_ms = int(pm_config.get("add", {}).get("cooldownMinutes", 0)) * 60_000
max_hold_ms = int(price_plan.get("maxHoldMinutes", DEFAULT_BACKTEST_PRICE_PLAN["maxHoldMinutes"])) * 60_000
sort_columns = ["symbol", "event_time", "predicted_edge_bps"]
sorted_keys = trades[["symbol", "event_time", "predicted_edge_bps", "time_to_exit_ms"]].sort_values(
sort_columns,
ascending=[True, True, False],
)
event_ns = pd.to_datetime(sorted_keys["event_time"], utc=True).astype("int64").to_numpy()
symbols = sorted_keys["symbol"].astype(str).to_numpy()
exit_delay_values = pd.to_numeric(sorted_keys["time_to_exit_ms"], errors="coerce").fillna(max_hold_ms).to_numpy(dtype="float64")
original_indices = sorted_keys.index.to_numpy()
next_available_ns_by_symbol: dict[str, int] = {}
keep: list[int] = []
for index, symbol, event_time_ns, exit_delay_ms in zip(original_indices, symbols, event_ns, exit_delay_values):
next_available_ns = next_available_ns_by_symbol.get(symbol)
if next_available_ns is not None and event_time_ns < next_available_ns:
continue
keep.append(index)
if not np.isfinite(exit_delay_ms) or exit_delay_ms <= 0:
exit_delay_ms = max_hold_ms
next_available_ns_by_symbol[symbol] = int(event_time_ns + (exit_delay_ms + cooldown_ms) * 1_000_000)
return trades.loc[keep].sort_values("event_time").reset_index(drop=True)
def _planned_ratio_like_position_manager(trades: pd.DataFrame, sizing: dict[str, Any], price_plan: dict[str, Any]) -> np.ndarray:
expected_edge = trades["predicted_edge_bps"].astype(float).clip(lower=0.0)
direction_strength = trades["direction_prob"].astype(float).clip(lower=0.0, upper=1.0)
entry_prob = trades["entry_prob"].astype(float).clip(lower=0.0, upper=1.0)
market_risk = trades["market_risk_prob"].astype(float).clip(lower=0.0, upper=1.0)
min_edge = float(sizing["minEdgeBps"])
stop_loss_budget = max(
float(price_plan.get("stopDistanceBps", DEFAULT_BACKTEST_PRICE_PLAN["stopDistanceBps"]))
+ float(price_plan.get("costBps", DEFAULT_BACKTEST_PRICE_PLAN["costBps"])),
1.0,
)
raw = (
float(sizing["baseRatio"])
* (expected_edge / stop_loss_budget)
* direction_strength
* entry_prob
* (1.0 - market_risk)
)
hard_cap = min(
float(sizing["maxSingleLegRatio"]),
float(sizing["maxTotalPositionRatio"]),
float(sizing["maxLiquidityUsageRatio"]),
float(sizing["maxLossPerTradeBps"]) / stop_loss_budget,
)
min_ratio = float(sizing["minInitialRatio"])
if hard_cap < min_ratio:
return np.zeros(len(trades), dtype="float64")
ratio = raw.clip(lower=min_ratio, upper=hard_cap)
return np.where(expected_edge >= min_edge, ratio, 0.0)
def _trade_metrics(trades: pd.DataFrame) -> dict[str, Any]:
@@ -336,6 +535,9 @@ def _trade_metrics(trades: pd.DataFrame) -> dict[str, Any]:
"total_weighted_edge_bps": 0.0,
"max_drawdown_bps": 0.0,
"avg_planned_ratio": 0.0,
"min_planned_ratio": 0.0,
"p50_planned_ratio": 0.0,
"max_planned_ratio": 0.0,
"profit_factor": 0.0,
"max_consecutive_losses": 0,
}
@@ -351,6 +553,9 @@ def _trade_metrics(trades: pd.DataFrame) -> dict[str, Any]:
"total_weighted_edge_bps": float(equity.iloc[-1]),
"max_drawdown_bps": float(drawdown.max()),
"avg_planned_ratio": float(trades["planned_ratio"].astype(float).mean()),
"min_planned_ratio": float(trades["planned_ratio"].astype(float).min()),
"p50_planned_ratio": float(trades["planned_ratio"].astype(float).median()),
"max_planned_ratio": float(trades["planned_ratio"].astype(float).max()),
"profit_factor": float(gains / losses) if losses > 0 else float("inf"),
"max_consecutive_losses": _max_consecutive_losses(trades["weighted_edge_bps"].astype(float).to_numpy()),
}
@@ -382,6 +587,8 @@ def _backtest_status(metrics: dict[str, dict[str, Any]]) -> tuple[str, list[str]
reasons.append("validation_locked_avg_trade_edge_not_positive")
if validation_locked["max_consecutive_losses"] > 8:
reasons.append("validation_locked_max_consecutive_losses_above_8")
if validation_locked["trade_count"] > 0 and validation_locked["max_planned_ratio"] <= 0.050001:
reasons.append("validation_locked_sizing_collapsed_to_min_initial")
if stress["trade_count"] < 20:
reasons.append("latest_stress_trade_count_below_20")
if stress["profit_factor"] < 1.0:
@@ -427,6 +634,7 @@ def _pm_config_from_thresholds(thresholds: dict[str, float]) -> dict:
}
)
config["add"]["maxMarketRiskProb"] = thresholds["max_market_risk_prob"]
config["add"]["minEntryProb"] = thresholds["min_entry_prob"]
config["add"]["minExpectedEdgeBps"] = thresholds["min_expected_edge_bps"]
config["sizing"]["minEdgeBps"] = thresholds["min_expected_edge_bps"]
config["sizing"]["maxSingleLegRatio"] = 1.0
@@ -479,7 +687,7 @@ def _write_pm_report(path, candidates: pd.DataFrame, best_thresholds: dict[str,
lines = [
"# PM Threshold Report",
"",
"本次不是固定写死阈值,而是在验证集上试一组可复现的阈值,选择净收益、回撤、交易数量综合更好的那组",
"本次不是固定写死阈值,而是在调参集上试一组可复现的阈值。PM 回测使用固定止盈止损后的真实净收益,并且开仓后按持仓结束时间加冷却时间阻止重叠开仓",
"",
"## Best Thresholds",
"",
@@ -505,7 +713,7 @@ def _write_backtest_report(path, result: dict[str, Any]) -> None:
lines = [
"# Integrated Backtest Report",
"",
"这里用验证集模型输出和 PM 阈值生成交易明细,统计净收益、胜率、回撤和分段表现。",
"这里用验证集模型输出和 PM 阈值生成交易明细,统计净收益、胜率、回撤和分段表现。收益按固定止盈止损计划的真实净收益计算,不使用窗口内最大可拿收益。",
"",
"```json",
str(result).replace("'", '"'),